Compare commits

..
64 changed files with 240 additions and 690 deletions

No files matched your search

+1 -10
View File
@@ -2,20 +2,11 @@
Docs: https://docs.clawd.bot
## 2026.1.22 (unreleased)
### Fixes
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
- Auto-reply: keep cached context token count in sync after compaction. (#1440) Thanks @robbyczgw-cla.
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
- Agents: surface concrete API error details instead of generic AI service errors.
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
## 2026.1.21-2
### Fixes
- Control UI: ignore bootstrap identity placeholder text for avatar values and fall back to the default avatar. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui
- Hooks: suppress session-memory confirmation output. (#1464) Thanks @alfranli123.
## 2026.1.21
+2 -3
View File
@@ -5,7 +5,7 @@ read_when:
---
# Gateway architecture
Last updated: 2026-01-22
Last updated: 2026-01-19
## Overview
@@ -34,8 +34,7 @@ Last updated: 2026-01-22
### Nodes (macOS / iOS / Android / headless)
- Connect to the **same WS server** with `role: node`.
- Provide a device identity in `connect`; pairing is **device‑based** (role `node`) and
approval lives in the device pairing store.
- Pair with the Gateway to receive a token.
- Expose commands like `canvas.*`, `camera.*`, `screen.record`, `location.get`.
Protocol details:
+3 -3
View File
@@ -52,10 +52,10 @@ Instances list, `client.mode === "cli"` is **not** turned into a presence entry.
Clients can send richer periodic beacons via the `system-event` method. The mac
app uses this to report host name, IP, and `lastInputSeconds`.
### 4) Node connects (role: node)
### 4) Node bridge beacons
When a node connects over the Gateway WebSocket with `role: node`, the Gateway
upserts a presence entry for that node (same flow as other WS clients).
When a node bridge connection authenticates, the Gateway emits a presence entry
for that node and refreshes it periodically so it doesn’t expire.
## Merge + dedupe rules (why `instanceId` matters)
-4
View File
@@ -11,10 +11,6 @@ In Gateway-owned pairing, the **Gateway** is the source of truth for which nodes
are allowed to join. UIs (macOS app, future clients) are just frontends that
approve or reject pending requests.
**Important:** WS nodes use **device pairing** (role `node`) during `connect`.
`node.pair.*` is a separate pairing store and does **not** gate the WS handshake.
Only clients that explicitly call `node.pair.*` use this flow.
## Concepts
- **Pending request**: a node asked to join; requires approval.
+12 -16
View File
@@ -8,11 +8,9 @@ read_when:
# Nodes
A **node** is a companion device (macOS/iOS/Android/headless) that connects to the Gateway **WebSocket** (same port as operators) with `role: "node"` and exposes a command surface (e.g. `canvas.*`, `camera.*`, `system.*`) via `node.invoke`. Protocol details: [Gateway protocol](/gateway/protocol).
A **node** is a companion device (iOS/Android today) that connects to the Gateway over the **Bridge** and exposes a command surface (e.g. `canvas.*`, `camera.*`, `system.*`) via `node.invoke`. Bridge protocol details: [Bridge protocol](/gateway/bridge-protocol).
Legacy transport: [Bridge protocol](/gateway/bridge-protocol) (TCP JSONL; for older node clients only).
macOS can also run in **node mode**: the menubar app connects to the Gateway’s WS server and exposes its local canvas/camera commands as a node (so `clawdbot nodes …` works against this Mac).
macOS can also run in **node mode**: the menubar app connects to the Gateway’s bridge and exposes its local canvas/camera commands as a node (so `clawdbot nodes …` works against this Mac).
Notes:
- Nodes are **peripherals**, not gateways. They don’t run the gateway service.
@@ -20,23 +18,21 @@ Notes:
## Pairing + status
**WS nodes use device pairing.** Nodes present a device identity during `connect`; the Gateway
creates a device pairing request for `role: node`. Approve via the devices CLI (or UI).
Pairing is gateway-owned and approval-based. See [Gateway pairing](/gateway/pairing) for the full flow.
Quick CLI:
```bash
clawdbot devices list
clawdbot devices approve <requestId>
clawdbot devices reject <requestId>
clawdbot nodes pending
clawdbot nodes approve <requestId>
clawdbot nodes reject <requestId>
clawdbot nodes status
clawdbot nodes describe --node <idOrNameOrIp>
clawdbot nodes rename --node <idOrNameOrIp> --name "Kitchen iPad"
```
Notes:
- `nodes status` marks a node as **paired** when its device pairing role includes `node`.
- `node.pair.*` (CLI: `clawdbot nodes pending/approve/reject`) is a separate gateway-owned
node pairing store; it does **not** gate the WS `connect` handshake.
- `nodes rename` stores a display name override in the gateway pairing store.
## Remote node host (system.run)
@@ -279,7 +275,7 @@ Nodes may include a `permissions` map in `node.list` / `node.describe`, keyed by
## Headless node host (cross-platform)
Clawdbot can run a **headless node host** (no UI) that connects to the Gateway
WebSocket and exposes `system.run` / `system.which`. This is useful on Linux/Windows
bridge and exposes `system.run` / `system.which`. This is useful on Linux/Windows
or for running a minimal node alongside a server.
Start it:
@@ -296,9 +292,9 @@ Notes:
- On macOS, the headless node host prefers the companion app exec host when reachable and falls
back to local execution if the app is unavailable. Set `CLAWDBOT_NODE_EXEC_HOST=app` to require
the app, or `CLAWDBOT_NODE_EXEC_FALLBACK=0` to disable fallback.
- Add `--tls` / `--tls-fingerprint` when the Gateway WS uses TLS.
- Add `--tls` / `--tls-fingerprint` when the bridge requires TLS.
## Mac node mode
- The macOS menubar app connects to the Gateway WS server as a node (so `clawdbot nodes …` works against this Mac).
- In remote mode, the app opens an SSH tunnel for the Gateway port and connects to `localhost`.
- The macOS menubar app connects to the Gateway bridge as a node (so `clawdbot nodes …` works against this Mac).
- In remote mode, the app opens an SSH tunnel for the bridge port and connects to `localhost`.
+10 -12
View File
@@ -45,29 +45,27 @@ Stored under `~/.clawdbot/credentials/`:
Treat these as sensitive (they gate access to your assistant).
## 2) Node device pairing (iOS/Android/macOS/headless nodes)
## 2) Node pairing (iOS/Android nodes joining the gateway)
Nodes connect to the Gateway as **devices** with `role: node`. The Gateway
creates a device pairing request that must be approved.
Nodes (iOS/Android, future hardware, etc.) connect to the Gateway and request to join.
The Gateway keeps an authoritative allowlist; new nodes require explicit approve/reject.
### Approve a node device
### Approve a node
```bash
clawdbot devices list
clawdbot devices approve <requestId>
clawdbot devices reject <requestId>
clawdbot nodes pending
clawdbot nodes approve <requestId>
```
### Where the state lives
Stored under `~/.clawdbot/devices/`:
Stored under `~/.clawdbot/nodes/`:
- `pending.json` (short-lived; pending requests expire)
- `paired.json` (paired devices + tokens)
- `paired.json` (paired nodes + tokens)
### Notes
### Details
- The legacy `node.pair.*` API (CLI: `clawdbot nodes pending/approve`) is a
separate gateway-owned pairing store. WS nodes still require device pairing.
Full protocol + design notes: [Gateway pairing](/gateway/pairing)
## Related docs
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/bluebubbles",
"version": "2026.1.22",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot BlueBubbles channel plugin",
"clawdbot": {
@@ -1563,100 +1563,6 @@ describe("BlueBubbles webhook monitor", () => {
expect.any(Object),
);
});
it("stops typing on idle", async () => {
const { sendBlueBubblesTyping } = await import("./chat.js");
vi.mocked(sendBlueBubblesTyping).mockClear();
const account = createMockAccount();
const config: ClawdbotConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
},
};
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.onReplyStart?.();
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
await params.dispatcherOptions.onIdle?.();
});
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
expect.any(String),
false,
expect.any(Object),
);
});
it("stops typing when no reply is sent", async () => {
const { sendBlueBubblesTyping } = await import("./chat.js");
vi.mocked(sendBlueBubblesTyping).mockClear();
const account = createMockAccount();
const config: ClawdbotConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
},
};
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async () => undefined);
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
expect.any(String),
false,
expect.any(Object),
);
});
});
describe("outbound message ids", () => {
+3 -18
View File
@@ -1713,17 +1713,8 @@ async function processMessage(
runtime.error?.(`[bluebubbles] typing start failed: ${String(err)}`);
}
},
onIdle: async () => {
if (!chatGuidForActions) return;
if (!baseUrl || !password) return;
try {
await sendBlueBubblesTyping(chatGuidForActions, false, {
cfg: config,
accountId: account.accountId,
});
} catch (err) {
logVerbose(core, runtime, `typing stop failed: ${String(err)}`);
}
onIdle: () => {
// BlueBubbles typing stop (DELETE) does not clear bubbles reliably; wait for timeout.
},
onError: (err, info) => {
runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`);
@@ -1763,13 +1754,7 @@ async function processMessage(
});
}
if (chatGuidForActions && baseUrl && password && !sentMessage) {
// Stop typing indicator when no message was sent (e.g., NO_REPLY)
sendBlueBubblesTyping(chatGuidForActions, false, {
cfg: config,
accountId: account.accountId,
}).catch((err) => {
logVerbose(core, runtime, `typing stop (no reply) failed: ${String(err)}`);
});
// BlueBubbles typing stop (DELETE) does not clear bubbles reliably; wait for timeout.
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/copilot-proxy",
"version": "2026.1.22",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot Copilot Proxy provider plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/diagnostics-otel",
"version": "2026.1.22",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot diagnostics OpenTelemetry exporter",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/discord",
"version": "2026.1.22",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot Discord channel plugin",
"clawdbot": {
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/google-antigravity-auth",
"version": "2026.1.22",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot Google Antigravity OAuth provider plugin",
"clawdbot": {
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/google-gemini-cli-auth",
"version": "2026.1.22",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot Gemini CLI OAuth provider plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/imessage",
"version": "2026.1.22",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot iMessage channel plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/lobster",
"version": "2026.1.22",
"version": "2026.1.21",
"type": "module",
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
"clawdbot": {
-5
View File
@@ -1,10 +1,5 @@
# Changelog
## 2026.1.22
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.21
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/matrix",
"version": "2026.1.22",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot Matrix channel plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/memory-core",
"version": "2026.1.22",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot core memory search plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/memory-lancedb",
"version": "2026.1.22",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
"dependencies": {
-5
View File
@@ -1,10 +1,5 @@
# Changelog
## 2026.1.22
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.21
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/msteams",
"version": "2026.1.22",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot Microsoft Teams channel plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/nextcloud-talk",
"version": "2026.1.22",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot Nextcloud Talk channel plugin",
"clawdbot": {
-5
View File
@@ -1,10 +1,5 @@
# Changelog
## 2026.1.22
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.21
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/nostr",
"version": "2026.1.22",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/signal",
"version": "2026.1.22",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot Signal channel plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/slack",
"version": "2026.1.22",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot Slack channel plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/telegram",
"version": "2026.1.22",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot Telegram channel plugin",
"clawdbot": {
-5
View File
@@ -1,10 +1,5 @@
# Changelog
## 2026.1.22
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.21
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/voice-call",
"version": "2026.1.22",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot voice-call plugin",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/whatsapp",
"version": "2026.1.22",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot WhatsApp channel plugin",
"clawdbot": {
-5
View File
@@ -1,10 +1,5 @@
# Changelog
## 2026.1.22
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.21
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/zalo",
"version": "2026.1.22",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot Zalo channel plugin",
"clawdbot": {
-5
View File
@@ -1,10 +1,5 @@
# Changelog
## 2026.1.22
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.21
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/zalouser",
"version": "2026.1.22",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "clawdbot",
"version": "2026.1.22",
"version": "2026.1.21-2",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module",
"main": "dist/index.js",
+1 -1
View File
@@ -11,7 +11,7 @@ Use `gog` for Gmail/Calendar/Drive/Contacts/Sheets/Docs. Requires OAuth setup.
Setup (once)
- `gog auth credentials /path/to/client_secret.json`
- `gog auth add you@gmail.com --services gmail,calendar,drive,contacts,docs,sheets`
- `gog auth add you@gmail.com --services gmail,calendar,drive,contacts,sheets,docs`
- `gog auth list`
Common commands
@@ -43,6 +43,8 @@ describe("formatAssistantErrorText", () => {
const msg = makeAssistantError(
'{"type":"error","error":{"message":"Something exploded","type":"server_error"}}',
);
expect(formatAssistantErrorText(msg)).toBe("LLM error server_error: Something exploded");
expect(formatAssistantErrorText(msg)).toBe(
"The AI service returned an error. Please try again.",
);
});
});
@@ -17,10 +17,4 @@ describe("formatRawAssistantErrorForUi", () => {
it("renders a generic unknown error message when raw is empty", () => {
expect(formatRawAssistantErrorForUi("")).toContain("unknown error");
});
it("formats plain HTTP status lines", () => {
expect(formatRawAssistantErrorForUi("500 Internal Server Error")).toBe(
"HTTP 500: Internal Server Error",
);
});
});
@@ -19,12 +19,12 @@ describe("sanitizeUserFacingText", () => {
it("sanitizes HTTP status errors with error hints", () => {
expect(sanitizeUserFacingText("500 Internal Server Error")).toBe(
"HTTP 500: Internal Server Error",
"The AI service returned an error. Please try again.",
);
});
it("sanitizes raw API error payloads", () => {
const raw = '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}';
expect(sanitizeUserFacingText(raw)).toBe("LLM error server_error: Something exploded");
expect(sanitizeUserFacingText(raw)).toBe("The AI service returned an error. Please try again.");
});
});
+4 -12
View File
@@ -201,14 +201,6 @@ export function formatRawAssistantErrorForUi(raw?: string): string {
const trimmed = (raw ?? "").trim();
if (!trimmed) return "LLM request failed with an unknown error.";
const httpMatch = trimmed.match(HTTP_STATUS_PREFIX_RE);
if (httpMatch) {
const rest = httpMatch[2].trim();
if (!rest.startsWith("{")) {
return `HTTP ${httpMatch[1]}: ${rest}`;
}
}
const info = parseApiErrorInfo(trimmed);
if (info?.message) {
const prefix = info.httpCode ? `HTTP ${info.httpCode}` : "LLM error";
@@ -269,8 +261,8 @@ export function formatAssistantErrorText(
return "The AI service is temporarily overloaded. Please try again in a moment.";
}
if (isLikelyHttpErrorText(raw) || isRawApiErrorPayload(raw)) {
return formatRawAssistantErrorForUi(raw);
if (isRawApiErrorPayload(raw)) {
return "The AI service returned an error. Please try again.";
}
// Never return raw unhandled errors - log for debugging but return safe message
@@ -301,7 +293,7 @@ export function sanitizeUserFacingText(text: string): string {
}
if (isRawApiErrorPayload(trimmed) || isLikelyHttpErrorText(trimmed)) {
return formatRawAssistantErrorForUi(trimmed);
return "The AI service returned an error. Please try again.";
}
if (ERROR_PREFIX_RE.test(trimmed)) {
@@ -311,7 +303,7 @@ export function sanitizeUserFacingText(text: string): string {
if (isTimeoutErrorMessage(trimmed)) {
return "LLM request timed out.";
}
return formatRawAssistantErrorForUi(trimmed);
return "The AI service returned an error. Please try again.";
}
return stripped;
+1 -27
View File
@@ -1,12 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import {
createAgentSession,
estimateTokens,
SessionManager,
SettingsManager,
} from "@mariozechner/pi-coding-agent";
import { createAgentSession, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent";
import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js";
import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js";
@@ -375,26 +370,6 @@ export async function compactEmbeddedPiSession(params: {
session.agent.replaceMessages(limited);
}
const result = await session.compact(params.customInstructions);
// Estimate tokens after compaction with the same context-usage heuristics.
let tokensAfter: number | undefined;
try {
const usage =
typeof session.getContextUsage === "function"
? session.getContextUsage()
: undefined;
let estimate = usage?.tokens;
if (!Number.isFinite(estimate) || !estimate || estimate <= 0) {
estimate = 0;
for (const message of session.messages) {
estimate += estimateTokens(message);
}
}
if (Number.isFinite(estimate) && estimate > 0 && estimate <= result.tokensBefore) {
tokensAfter = estimate;
}
} catch {
tokensAfter = undefined;
}
return {
ok: true,
compacted: true,
@@ -402,7 +377,6 @@ export async function compactEmbeddedPiSession(params: {
summary: result.summary,
firstKeptEntryId: result.firstKeptEntryId,
tokensBefore: result.tokensBefore,
tokensAfter,
details: result.details,
},
};
@@ -6,7 +6,6 @@ import { formatToolAggregate } from "../../../auto-reply/tool-meta.js";
import type { ClawdbotConfig } from "../../../config/config.js";
import {
formatAssistantErrorText,
formatRawAssistantErrorForUi,
getApiErrorPayloadFingerprint,
isRawApiErrorPayload,
normalizeTextForComparison,
@@ -65,12 +64,6 @@ export function buildEmbeddedRunPayloads(params: {
const rawErrorFingerprint = rawErrorMessage
? getApiErrorPayloadFingerprint(rawErrorMessage)
: null;
const formattedRawErrorMessage = rawErrorMessage
? formatRawAssistantErrorForUi(rawErrorMessage)
: null;
const normalizedFormattedRawErrorMessage = formattedRawErrorMessage
? normalizeTextForComparison(formattedRawErrorMessage)
: null;
const normalizedRawErrorText = rawErrorMessage
? normalizeTextForComparison(rawErrorMessage)
: null;
@@ -123,15 +116,10 @@ export function buildEmbeddedRunPayloads(params: {
if (trimmed === genericErrorText) return true;
}
if (rawErrorMessage && trimmed === rawErrorMessage) return true;
if (formattedRawErrorMessage && trimmed === formattedRawErrorMessage) return true;
if (normalizedRawErrorText) {
const normalized = normalizeTextForComparison(trimmed);
if (normalized && normalized === normalizedRawErrorText) return true;
}
if (normalizedFormattedRawErrorMessage) {
const normalized = normalizeTextForComparison(trimmed);
if (normalized && normalized === normalizedFormattedRawErrorMessage) return true;
}
if (rawErrorFingerprint) {
const fingerprint = getApiErrorPayloadFingerprint(trimmed);
if (fingerprint && fingerprint === rawErrorFingerprint) return true;
-1
View File
@@ -59,7 +59,6 @@ export type EmbeddedPiCompactResult = {
summary: string;
firstKeptEntryId: string;
tokensBefore: number;
tokensAfter?: number;
details?: unknown;
};
};
+1 -2
View File
@@ -31,8 +31,7 @@ const subagentRuns = new Map<string, SubagentRunRecord>();
let sweeper: NodeJS.Timeout | null = null;
let listenerStarted = false;
let listenerStop: (() => void) | null = null;
// Use var to avoid TDZ on circular init paths that can call restoreSubagentRunsOnce early.
var restoreAttempted = false;
let restoreAttempted = false;
function persistSubagentRuns() {
try {
+1 -1
View File
@@ -212,7 +212,7 @@ export function buildAgentSystemPrompt(params: {
sessions_send: "Send a message to another session/sub-agent",
sessions_spawn: "Spawn a sub-agent session",
session_status:
"Show a /status-equivalent status card (usage + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override",
"Show a /status-equivalent status card (usage + Reasoning/Verbose/Elevated); optional per-session model override",
image: "Analyze an image with the configured image model",
};
+1 -1
View File
@@ -215,7 +215,7 @@ export function createSessionStatusTool(opts?: {
label: "Session Status",
name: "session_status",
description:
"Show a /status-equivalent session status card (usage + cost when available). Use for model-use questions (📊 session_status). Optional: set per-session model override (model=default resets overrides).",
"Show a /status-equivalent session status card. Optional: set per-session model override (model=default resets overrides). Includes usage + cost when available.",
parameters: SessionStatusToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
+1 -1
View File
@@ -42,7 +42,7 @@ describe("block streaming", () => {
});
async function waitForCalls(fn: () => number, calls: number) {
const deadline = Date.now() + 15000;
const deadline = Date.now() + 1500;
while (fn() < calls) {
if (Date.now() > deadline) {
throw new Error(`Expected ${calls} call(s), got ${fn()}`);
+10 -17
View File
@@ -83,13 +83,18 @@ export const handleCompactCommand: CommandHandler = async (params) => {
ownerNumbers: params.command.ownerList.length > 0 ? params.command.ownerList : undefined,
});
const totalTokens =
params.sessionEntry.totalTokens ??
(params.sessionEntry.inputTokens ?? 0) + (params.sessionEntry.outputTokens ?? 0);
const contextSummary = formatContextUsageShort(
totalTokens > 0 ? totalTokens : null,
params.contextTokens ?? params.sessionEntry.contextTokens ?? null,
);
const compactLabel = result.ok
? result.compacted
? result.result?.tokensBefore != null && result.result?.tokensAfter != null
? `Compacted (${formatTokenCount(result.result.tokensBefore)} → ${formatTokenCount(result.result.tokensAfter)})`
: result.result?.tokensBefore
? `Compacted (${formatTokenCount(result.result.tokensBefore)} before)`
: "Compacted"
? result.result?.tokensBefore
? `Compacted (${formatTokenCount(result.result.tokensBefore)} before)`
: "Compacted"
: "Compaction skipped"
: "Compaction failed";
if (result.ok && result.compacted) {
@@ -98,20 +103,8 @@ export const handleCompactCommand: CommandHandler = async (params) => {
sessionStore: params.sessionStore,
sessionKey: params.sessionKey,
storePath: params.storePath,
// Update token counts after compaction
tokensAfter: result.result?.tokensAfter,
});
}
// Use the post-compaction token count for context summary if available
const tokensAfterCompaction = result.result?.tokensAfter;
const totalTokens =
tokensAfterCompaction ??
params.sessionEntry.totalTokens ??
(params.sessionEntry.inputTokens ?? 0) + (params.sessionEntry.outputTokens ?? 0);
const contextSummary = formatContextUsageShort(
totalTokens > 0 ? totalTokens : null,
params.contextTokens ?? params.sessionEntry.contextTokens ?? null,
);
const reason = result.reason?.trim();
const line = reason
? `${compactLabel}: ${reason} • ${contextSummary}`
@@ -15,8 +15,8 @@ export async function applyInlineDirectivesFastLane(params: {
cfg: ClawdbotConfig;
agentId?: string;
isGroup: boolean;
sessionEntry: SessionEntry;
sessionStore: Record<string, SessionEntry>;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey: string;
storePath?: string;
elevatedEnabled: boolean;
@@ -1,113 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { ModelAliasIndex } from "../../agents/model-selection.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { parseInlineDirectives } from "./directive-handling.js";
import { handleDirectiveOnly } from "./directive-handling.impl.js";
// Mock dependencies
vi.mock("../../agents/agent-scope.js", () => ({
resolveAgentConfig: vi.fn(() => ({})),
resolveAgentDir: vi.fn(() => "/tmp/agent"),
resolveSessionAgentId: vi.fn(() => "main"),
}));
vi.mock("../../agents/sandbox.js", () => ({
resolveSandboxRuntimeStatus: vi.fn(() => ({ sandboxed: false })),
}));
vi.mock("../../config/sessions.js", () => ({
updateSessionStore: vi.fn(async () => {}),
}));
vi.mock("../../infra/system-events.js", () => ({
enqueueSystemEvent: vi.fn(),
}));
function baseAliasIndex(): ModelAliasIndex {
return { byAlias: new Map(), byKey: new Map() };
}
function baseConfig(): ClawdbotConfig {
return {
commands: { text: true },
agents: { defaults: {} },
} as unknown as ClawdbotConfig;
}
describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
const allowedModelKeys = new Set(["anthropic/claude-opus-4-5", "openai/gpt-4o"]);
const allowedModelCatalog = [
{ provider: "anthropic", id: "claude-opus-4-5" },
{ provider: "openai", id: "gpt-4o" },
];
it("shows success message when session state is available", async () => {
const directives = parseInlineDirectives("/model openai/gpt-4o");
const sessionEntry: SessionEntry = {
sessionId: "s1",
updatedAt: Date.now(),
};
const sessionStore = { "agent:main:dm:1": sessionEntry };
const result = await handleDirectiveOnly({
cfg: baseConfig(),
directives,
sessionEntry,
sessionStore,
sessionKey: "agent:main:dm:1",
storePath: "/tmp/sessions.json",
elevatedEnabled: false,
elevatedAllowed: false,
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
aliasIndex: baseAliasIndex(),
allowedModelKeys,
allowedModelCatalog,
resetModelOverride: false,
provider: "anthropic",
model: "claude-opus-4-5",
initialModelLabel: "anthropic/claude-opus-4-5",
formatModelSwitchEvent: (label) => `Switched to ${label}`,
});
expect(result?.text).toContain("Model set to");
expect(result?.text).toContain("openai/gpt-4o");
expect(result?.text).not.toContain("failed");
});
it("shows no model message when no /model directive", async () => {
const directives = parseInlineDirectives("hello world");
const sessionEntry: SessionEntry = {
sessionId: "s1",
updatedAt: Date.now(),
};
const sessionStore = { "agent:main:dm:1": sessionEntry };
const result = await handleDirectiveOnly({
cfg: baseConfig(),
directives,
sessionEntry,
sessionStore,
sessionKey: "agent:main:dm:1",
storePath: "/tmp/sessions.json",
elevatedEnabled: false,
elevatedAllowed: false,
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
aliasIndex: baseAliasIndex(),
allowedModelKeys,
allowedModelCatalog,
resetModelOverride: false,
provider: "anthropic",
model: "claude-opus-4-5",
initialModelLabel: "anthropic/claude-opus-4-5",
formatModelSwitchEvent: (label) => `Switched to ${label}`,
});
// No model directive = no model message
expect(result?.text ?? "").not.toContain("Model set to");
expect(result?.text ?? "").not.toContain("failed");
});
});
+98 -96
View File
@@ -62,8 +62,8 @@ function resolveExecDefaults(params: {
export async function handleDirectiveOnly(params: {
cfg: ClawdbotConfig;
directives: InlineDirectives;
sessionEntry: SessionEntry;
sessionStore: Record<string, SessionEntry>;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey: string;
storePath?: string;
elevatedEnabled: boolean;
@@ -288,110 +288,112 @@ export async function handleDirectiveOnly(params: {
nextThinkLevel === "xhigh" &&
!supportsXHighThinking(resolvedProvider, resolvedModel);
const prevElevatedLevel =
currentElevatedLevel ??
(sessionEntry.elevatedLevel as ElevatedLevel | undefined) ??
(elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel));
const prevReasoningLevel =
currentReasoningLevel ?? (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off";
let elevatedChanged =
directives.hasElevatedDirective &&
directives.elevatedLevel !== undefined &&
elevatedEnabled &&
elevatedAllowed;
let reasoningChanged =
directives.hasReasoningDirective && directives.reasoningLevel !== undefined;
if (directives.hasThinkDirective && directives.thinkLevel) {
if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel;
else sessionEntry.thinkingLevel = directives.thinkLevel;
}
if (shouldDowngradeXHigh) {
sessionEntry.thinkingLevel = "high";
}
if (directives.hasVerboseDirective && directives.verboseLevel) {
applyVerboseOverride(sessionEntry, directives.verboseLevel);
}
if (directives.hasReasoningDirective && directives.reasoningLevel) {
if (directives.reasoningLevel === "off") delete sessionEntry.reasoningLevel;
else sessionEntry.reasoningLevel = directives.reasoningLevel;
reasoningChanged =
directives.reasoningLevel !== prevReasoningLevel && directives.reasoningLevel !== undefined;
}
if (directives.hasElevatedDirective && directives.elevatedLevel) {
// Unlike other toggles, elevated defaults can be "on".
// Persist "off" explicitly so `/elevated off` actually overrides defaults.
sessionEntry.elevatedLevel = directives.elevatedLevel;
elevatedChanged =
elevatedChanged ||
(directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
}
if (directives.hasExecDirective && directives.hasExecOptions) {
if (directives.execHost) {
sessionEntry.execHost = directives.execHost;
if (sessionEntry && sessionStore && sessionKey) {
const prevElevatedLevel =
currentElevatedLevel ??
(sessionEntry.elevatedLevel as ElevatedLevel | undefined) ??
(elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel));
const prevReasoningLevel =
currentReasoningLevel ?? (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off";
let elevatedChanged =
directives.hasElevatedDirective &&
directives.elevatedLevel !== undefined &&
elevatedEnabled &&
elevatedAllowed;
let reasoningChanged =
directives.hasReasoningDirective && directives.reasoningLevel !== undefined;
if (directives.hasThinkDirective && directives.thinkLevel) {
if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel;
else sessionEntry.thinkingLevel = directives.thinkLevel;
}
if (directives.execSecurity) {
sessionEntry.execSecurity = directives.execSecurity;
if (shouldDowngradeXHigh) {
sessionEntry.thinkingLevel = "high";
}
if (directives.execAsk) {
sessionEntry.execAsk = directives.execAsk;
if (directives.hasVerboseDirective && directives.verboseLevel) {
applyVerboseOverride(sessionEntry, directives.verboseLevel);
}
if (directives.execNode) {
sessionEntry.execNode = directives.execNode;
if (directives.hasReasoningDirective && directives.reasoningLevel) {
if (directives.reasoningLevel === "off") delete sessionEntry.reasoningLevel;
else sessionEntry.reasoningLevel = directives.reasoningLevel;
reasoningChanged =
directives.reasoningLevel !== prevReasoningLevel && directives.reasoningLevel !== undefined;
}
}
if (modelSelection) {
applyModelOverrideToSessionEntry({
entry: sessionEntry,
selection: modelSelection,
profileOverride,
});
}
if (directives.hasQueueDirective && directives.queueReset) {
delete sessionEntry.queueMode;
delete sessionEntry.queueDebounceMs;
delete sessionEntry.queueCap;
delete sessionEntry.queueDrop;
} else if (directives.hasQueueDirective) {
if (directives.queueMode) sessionEntry.queueMode = directives.queueMode;
if (typeof directives.debounceMs === "number") {
sessionEntry.queueDebounceMs = directives.debounceMs;
if (directives.hasElevatedDirective && directives.elevatedLevel) {
// Unlike other toggles, elevated defaults can be "on".
// Persist "off" explicitly so `/elevated off` actually overrides defaults.
sessionEntry.elevatedLevel = directives.elevatedLevel;
elevatedChanged =
elevatedChanged ||
(directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
}
if (typeof directives.cap === "number") {
sessionEntry.queueCap = directives.cap;
if (directives.hasExecDirective && directives.hasExecOptions) {
if (directives.execHost) {
sessionEntry.execHost = directives.execHost;
}
if (directives.execSecurity) {
sessionEntry.execSecurity = directives.execSecurity;
}
if (directives.execAsk) {
sessionEntry.execAsk = directives.execAsk;
}
if (directives.execNode) {
sessionEntry.execNode = directives.execNode;
}
}
if (directives.dropPolicy) {
sessionEntry.queueDrop = directives.dropPolicy;
if (modelSelection) {
applyModelOverrideToSessionEntry({
entry: sessionEntry,
selection: modelSelection,
profileOverride,
});
}
}
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
}
if (modelSelection) {
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
if (nextLabel !== initialModelLabel) {
enqueueSystemEvent(formatModelSwitchEvent(nextLabel, modelSelection.alias), {
if (directives.hasQueueDirective && directives.queueReset) {
delete sessionEntry.queueMode;
delete sessionEntry.queueDebounceMs;
delete sessionEntry.queueCap;
delete sessionEntry.queueDrop;
} else if (directives.hasQueueDirective) {
if (directives.queueMode) sessionEntry.queueMode = directives.queueMode;
if (typeof directives.debounceMs === "number") {
sessionEntry.queueDebounceMs = directives.debounceMs;
}
if (typeof directives.cap === "number") {
sessionEntry.queueCap = directives.cap;
}
if (directives.dropPolicy) {
sessionEntry.queueDrop = directives.dropPolicy;
}
}
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
}
if (modelSelection) {
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
if (nextLabel !== initialModelLabel) {
enqueueSystemEvent(formatModelSwitchEvent(nextLabel, modelSelection.alias), {
sessionKey,
contextKey: `model:${nextLabel}`,
});
}
}
if (elevatedChanged) {
const nextElevated = (sessionEntry.elevatedLevel ?? "off") as ElevatedLevel;
enqueueSystemEvent(formatElevatedEvent(nextElevated), {
sessionKey,
contextKey: "mode:elevated",
});
}
if (reasoningChanged) {
const nextReasoning = (sessionEntry.reasoningLevel ?? "off") as ReasoningLevel;
enqueueSystemEvent(formatReasoningEvent(nextReasoning), {
sessionKey,
contextKey: `model:${nextLabel}`,
contextKey: "mode:reasoning",
});
}
}
if (elevatedChanged) {
const nextElevated = (sessionEntry.elevatedLevel ?? "off") as ElevatedLevel;
enqueueSystemEvent(formatElevatedEvent(nextElevated), {
sessionKey,
contextKey: "mode:elevated",
});
}
if (reasoningChanged) {
const nextReasoning = (sessionEntry.reasoningLevel ?? "off") as ReasoningLevel;
enqueueSystemEvent(formatReasoningEvent(nextReasoning), {
sessionKey,
contextKey: "mode:reasoning",
});
}
const parts: string[] = [];
@@ -39,8 +39,8 @@ export async function applyInlineDirectiveOverrides(params: {
agentId: string;
agentDir: string;
agentCfg: AgentDefaults;
sessionEntry: SessionEntry;
sessionStore: Record<string, SessionEntry>;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey: string;
storePath?: string;
sessionScope: Parameters<typeof buildStatusReply>[0]["sessionScope"];
+2 -2
View File
@@ -89,8 +89,8 @@ export async function resolveReplyDirectives(params: {
workspaceDir: string;
agentCfg: AgentDefaults;
sessionCtx: TemplateContext;
sessionEntry: SessionEntry;
sessionStore: Record<string, SessionEntry>;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey: string;
storePath?: string;
sessionScope: Parameters<typeof applyInlineDirectiveOverrides>[0]["sessionScope"];
+1 -35
View File
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js";
import { incrementCompactionCount, prependSystemEvents } from "./session-updates.js";
import { prependSystemEvents } from "./session-updates.js";
describe("prependSystemEvents", () => {
it("adds a local timestamp to queued system events by default", async () => {
@@ -29,37 +29,3 @@ describe("prependSystemEvents", () => {
vi.useRealTimers();
});
});
describe("incrementCompactionCount", () => {
it("updates cached total tokens after compaction without clearing input/output", async () => {
const sessionKey = "agent:main:main";
const sessionStore = {
[sessionKey]: {
sessionId: "s1",
updatedAt: 10,
compactionCount: 1,
totalTokens: 9_000,
inputTokens: 111,
outputTokens: 222,
},
};
const now = 1234;
const nextCount = await incrementCompactionCount({
sessionEntry: sessionStore[sessionKey],
sessionStore,
sessionKey,
now,
tokensAfter: 2_000,
});
expect(nextCount).toBe(2);
expect(sessionStore[sessionKey]).toMatchObject({
compactionCount: 2,
totalTokens: 2_000,
inputTokens: 111,
outputTokens: 222,
updatedAt: now,
});
});
});
+5 -21
View File
@@ -237,39 +237,23 @@ export async function incrementCompactionCount(params: {
sessionKey?: string;
storePath?: string;
now?: number;
/** Token count after compaction - if provided, updates cached context usage */
tokensAfter?: number;
}): Promise<number | undefined> {
const {
sessionEntry,
sessionStore,
sessionKey,
storePath,
now = Date.now(),
tokensAfter,
} = params;
const { sessionEntry, sessionStore, sessionKey, storePath, now = Date.now() } = params;
if (!sessionStore || !sessionKey) return undefined;
const entry = sessionStore[sessionKey] ?? sessionEntry;
if (!entry) return undefined;
const nextCount = (entry.compactionCount ?? 0) + 1;
// Build update payload with compaction count and optionally updated context usage.
const updates: Partial<SessionEntry> = {
compactionCount: nextCount,
updatedAt: now,
};
// If tokensAfter is provided, update the cached total to reflect post-compaction context size.
if (tokensAfter != null && tokensAfter > 0) {
updates.totalTokens = tokensAfter;
}
sessionStore[sessionKey] = {
...entry,
...updates,
compactionCount: nextCount,
updatedAt: now,
};
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[sessionKey] = {
...store[sessionKey],
...updates,
compactionCount: nextCount,
updatedAt: now,
};
});
}
+1 -1
View File
@@ -237,7 +237,7 @@ export async function resolveNodeProgramArguments(params: {
runtime?: GatewayRuntimePreference;
nodePath?: string;
}): Promise<GatewayProgramArgs> {
const args = ["node", "run", "--host", params.host, "--port", String(params.port)];
const args = ["node", "start", "--host", params.host, "--port", String(params.port)];
if (params.tls || params.tlsFingerprint) args.push("--tls");
if (params.tlsFingerprint) args.push("--tls-fingerprint", params.tlsFingerprint);
if (params.nodeId) args.push("--node-id", params.nodeId);
+3 -5
View File
@@ -13,9 +13,7 @@ describe("resolveAssistantIdentity avatar normalization", () => {
},
};
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe(
DEFAULT_ASSISTANT_IDENTITY.avatar,
);
expect(resolveAssistantIdentity({ cfg }).avatar).toBe(DEFAULT_ASSISTANT_IDENTITY.avatar);
});
it("keeps short text avatars", () => {
@@ -27,7 +25,7 @@ describe("resolveAssistantIdentity avatar normalization", () => {
},
};
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe("PS");
expect(resolveAssistantIdentity({ cfg }).avatar).toBe("PS");
});
it("keeps path avatars", () => {
@@ -39,6 +37,6 @@ describe("resolveAssistantIdentity avatar normalization", () => {
},
};
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe("avatars/clawd.png");
expect(resolveAssistantIdentity({ cfg }).avatar).toBe("avatars/clawd.png");
});
});
-66
View File
@@ -1,66 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveAssistantAvatarUrl } from "./control-ui.js";
describe("resolveAssistantAvatarUrl", () => {
it("keeps remote and data URLs", () => {
expect(
resolveAssistantAvatarUrl({
avatar: "https://example.com/avatar.png",
agentId: "main",
basePath: "/ui",
}),
).toBe("https://example.com/avatar.png");
expect(
resolveAssistantAvatarUrl({
avatar: "data:image/png;base64,abc",
agentId: "main",
basePath: "/ui",
}),
).toBe("data:image/png;base64,abc");
});
it("prefixes basePath for /avatar endpoints", () => {
expect(
resolveAssistantAvatarUrl({
avatar: "/avatar/main",
agentId: "main",
basePath: "/ui",
}),
).toBe("/ui/avatar/main");
expect(
resolveAssistantAvatarUrl({
avatar: "/ui/avatar/main",
agentId: "main",
basePath: "/ui",
}),
).toBe("/ui/avatar/main");
});
it("maps local avatar paths to the avatar endpoint", () => {
expect(
resolveAssistantAvatarUrl({
avatar: "avatars/me.png",
agentId: "main",
basePath: "/ui",
}),
).toBe("/ui/avatar/main");
expect(
resolveAssistantAvatarUrl({
avatar: "avatars/profile",
agentId: "main",
basePath: "/ui",
}),
).toBe("/ui/avatar/main");
});
it("keeps short text avatars", () => {
expect(
resolveAssistantAvatarUrl({
avatar: "PS",
agentId: "main",
basePath: "/ui",
}),
).toBe("PS");
});
});
+2 -40
View File
@@ -98,7 +98,7 @@ function sendJson(res: ServerResponse, status: number, body: unknown) {
res.end(JSON.stringify(body));
}
export function buildAvatarUrl(basePath: string, agentId: string): string {
function buildAvatarUrl(basePath: string, agentId: string): string {
return basePath ? `${basePath}${AVATAR_PREFIX}/${agentId}` : `${AVATAR_PREFIX}/${agentId}`;
}
@@ -206,49 +206,11 @@ interface ServeIndexHtmlOpts {
agentId?: string;
}
function looksLikeLocalAvatarPath(value: string): boolean {
if (/[\\/]/.test(value)) return true;
return /\.(png|jpe?g|gif|webp|svg|ico)$/i.test(value);
}
export function resolveAssistantAvatarUrl(params: {
avatar?: string | null;
agentId?: string | null;
basePath?: string;
}): string | undefined {
const avatar = params.avatar?.trim();
if (!avatar) return undefined;
if (/^https?:\/\//i.test(avatar) || /^data:image\//i.test(avatar)) return avatar;
const basePath = normalizeControlUiBasePath(params.basePath);
const baseAvatarPrefix = basePath ? `${basePath}${AVATAR_PREFIX}/` : `${AVATAR_PREFIX}/`;
if (basePath && avatar.startsWith(`${AVATAR_PREFIX}/`)) {
return `${basePath}${avatar}`;
}
if (avatar.startsWith(baseAvatarPrefix)) return avatar;
if (!params.agentId) return avatar;
if (looksLikeLocalAvatarPath(avatar)) {
return buildAvatarUrl(basePath, params.agentId);
}
return avatar;
}
function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndexHtmlOpts) {
const { basePath, config, agentId } = opts;
const identity = config
? resolveAssistantIdentity({ cfg: config, agentId })
: DEFAULT_ASSISTANT_IDENTITY;
const resolvedAgentId =
typeof (identity as { agentId?: string }).agentId === "string"
? (identity as { agentId?: string }).agentId
: agentId;
const avatarValue =
resolveAssistantAvatarUrl({
avatar: identity.avatar,
agentId: resolvedAgentId,
basePath,
}) ?? identity.avatar;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
const raw = fs.readFileSync(indexPath, "utf8");
@@ -256,7 +218,7 @@ function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndex
injectControlUiConfig(raw, {
basePath,
assistantName: identity.name,
assistantAvatar: avatarValue,
assistantAvatar: identity.avatar,
}),
);
}
+1 -8
View File
@@ -38,7 +38,6 @@ import {
import { loadSessionEntry } from "../session-utils.js";
import { formatForLog } from "../ws-log.js";
import { resolveAssistantIdentity } from "../assistant-identity.js";
import { resolveAssistantAvatarUrl } from "../control-ui.js";
import { waitForAgentJob } from "./agent-job.js";
import type { GatewayRequestHandlers } from "./types.js";
@@ -408,13 +407,7 @@ export const agentHandlers: GatewayRequestHandlers = {
}
const cfg = loadConfig();
const identity = resolveAssistantIdentity({ cfg, agentId });
const avatarValue =
resolveAssistantAvatarUrl({
avatar: identity.avatar,
agentId: identity.agentId,
basePath: cfg.gateway?.controlUi?.basePath,
}) ?? identity.avatar;
respond(true, { ...identity, avatar: avatarValue }, undefined);
respond(true, identity, undefined);
},
"agent.wait": async ({ params, respond }) => {
if (!validateAgentWaitParams(params)) {
+1 -1
View File
@@ -26,7 +26,7 @@ When you run `/new` to start a fresh session:
2. **Extracts conversation** - Reads the last 15 lines of conversation from the session
3. **Generates descriptive slug** - Uses LLM to create a meaningful filename slug based on conversation content
4. **Saves to memory** - Creates a new file at `<workspace>/memory/YYYY-MM-DD-slug.md`
5. **Sends confirmation** - Notifies you with the file path
5. **Logs completion** - Writes the file path to logs (no user-visible confirmation)
## Output Format
@@ -0,0 +1,43 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import handler from "./handler.js";
import { createHookEvent } from "../../hooks.js";
import type { ClawdbotConfig } from "../../../config/config.js";
import { makeTempWorkspace } from "../../../test-helpers/workspace.js";
describe("session-memory hook", () => {
it("writes a memory entry without queuing a user confirmation", async () => {
const workspaceDir = await makeTempWorkspace("clawdbot-session-memory-");
const cfg: ClawdbotConfig = {
agents: {
defaults: {
workspace: workspaceDir,
},
},
};
const context: Record<string, unknown> = {
cfg,
sessionEntry: {
sessionId: "session-123",
sessionFile: "",
},
commandSource: "test",
};
const event = createHookEvent("command", "new", "agent:main:main", context);
await handler(event);
expect(event.messages).toEqual([]);
const memoryDir = path.join(workspaceDir, "memory");
const files = await fs.readdir(memoryDir);
expect(files).toHaveLength(1);
const entry = await fs.readFile(path.join(memoryDir, files[0] ?? ""), "utf-8");
expect(entry).toContain("Session Key");
expect(entry).toContain("session-123");
});
});
+1 -2
View File
@@ -158,8 +158,7 @@ function renderAvatar(
function isAvatarUrl(value: string): boolean {
return (
/^https?:\/\//i.test(value) ||
/^data:image\//i.test(value) ||
/^\//.test(value) // Relative paths from avatar endpoint
/^data:image\//i.test(value)
);
}