Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc56aac3bd | ||
|
|
fd27d40b85 |
No files matched your search
+2
-5
@@ -2,15 +2,12 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
Docs: https://docs.clawd.bot
|
||||||
|
|
||||||
## 2026.1.22 (unreleased)
|
## 2026.1.22 (Unreleased)
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
|
- 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: 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.
|
- Node: launch node host service with `node run` (not `node start`) and align node host hints/docs. (#1461) Thanks @ameno-.
|
||||||
- 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
|
## 2026.1.21-2
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ Manage the service:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot node status
|
clawdbot node status
|
||||||
clawdbot node run
|
|
||||||
clawdbot node stop
|
clawdbot node stop
|
||||||
clawdbot node restart
|
clawdbot node restart
|
||||||
clawdbot node uninstall
|
clawdbot node uninstall
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Gateway architecture
|
# Gateway architecture
|
||||||
|
|
||||||
Last updated: 2026-01-22
|
Last updated: 2026-01-19
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -34,8 +34,7 @@ Last updated: 2026-01-22
|
|||||||
|
|
||||||
### Nodes (macOS / iOS / Android / headless)
|
### Nodes (macOS / iOS / Android / headless)
|
||||||
- Connect to the **same WS server** with `role: node`.
|
- Connect to the **same WS server** with `role: node`.
|
||||||
- Provide a device identity in `connect`; pairing is **device‑based** (role `node`) and
|
- Pair with the Gateway to receive a token.
|
||||||
approval lives in the device pairing store.
|
|
||||||
- Expose commands like `canvas.*`, `camera.*`, `screen.record`, `location.get`.
|
- Expose commands like `canvas.*`, `camera.*`, `screen.record`, `location.get`.
|
||||||
|
|
||||||
Protocol details:
|
Protocol details:
|
||||||
|
|||||||
@@ -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
|
Clients can send richer periodic beacons via the `system-event` method. The mac
|
||||||
app uses this to report host name, IP, and `lastInputSeconds`.
|
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
|
When a node bridge connection authenticates, the Gateway emits a presence entry
|
||||||
upserts a presence entry for that node (same flow as other WS clients).
|
for that node and refreshes it periodically so it doesn’t expire.
|
||||||
|
|
||||||
## Merge + dedupe rules (why `instanceId` matters)
|
## Merge + dedupe rules (why `instanceId` matters)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
are allowed to join. UIs (macOS app, future clients) are just frontends that
|
||||||
approve or reject pending requests.
|
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
|
## Concepts
|
||||||
|
|
||||||
- **Pending request**: a node asked to join; requires approval.
|
- **Pending request**: a node asked to join; requires approval.
|
||||||
|
|||||||
+15
-17
@@ -8,11 +8,9 @@ read_when:
|
|||||||
|
|
||||||
# Nodes
|
# 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 bridge 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 WS server and exposes its local canvas/camera commands as a node (so `clawdbot nodes …` works against this Mac).
|
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Nodes are **peripherals**, not gateways. They don’t run the gateway service.
|
- Nodes are **peripherals**, not gateways. They don’t run the gateway service.
|
||||||
@@ -20,23 +18,21 @@ Notes:
|
|||||||
|
|
||||||
## Pairing + status
|
## Pairing + status
|
||||||
|
|
||||||
**WS nodes use device pairing.** Nodes present a device identity during `connect`; the Gateway
|
Pairing is gateway-owned and approval-based. See [Gateway pairing](/gateway/pairing) for the full flow.
|
||||||
creates a device pairing request for `role: node`. Approve via the devices CLI (or UI).
|
|
||||||
|
|
||||||
Quick CLI:
|
Quick CLI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot devices list
|
clawdbot nodes pending
|
||||||
clawdbot devices approve <requestId>
|
clawdbot nodes approve <requestId>
|
||||||
clawdbot devices reject <requestId>
|
clawdbot nodes reject <requestId>
|
||||||
clawdbot nodes status
|
clawdbot nodes status
|
||||||
clawdbot nodes describe --node <idOrNameOrIp>
|
clawdbot nodes describe --node <idOrNameOrIp>
|
||||||
|
clawdbot nodes rename --node <idOrNameOrIp> --name "Kitchen iPad"
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- `nodes status` marks a node as **paired** when its device pairing role includes `node`.
|
- `nodes rename` stores a display name override in the gateway pairing store.
|
||||||
- `node.pair.*` (CLI: `clawdbot nodes pending/approve/reject`) is a separate gateway-owned
|
|
||||||
node pairing store; it does **not** gate the WS `connect` handshake.
|
|
||||||
|
|
||||||
## Remote node host (system.run)
|
## Remote node host (system.run)
|
||||||
|
|
||||||
@@ -61,9 +57,11 @@ clawdbot node run --host <gateway-host> --port 18789 --display-name "Build Node"
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot node install --host <gateway-host> --port 18789 --display-name "Build Node"
|
clawdbot node install --host <gateway-host> --port 18789 --display-name "Build Node"
|
||||||
clawdbot node start
|
clawdbot node status
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Install starts the service. Use `clawdbot node restart` if you need to re-launch it.
|
||||||
|
|
||||||
### Pair + name
|
### Pair + name
|
||||||
|
|
||||||
On the gateway host:
|
On the gateway host:
|
||||||
@@ -279,7 +277,7 @@ Nodes may include a `permissions` map in `node.list` / `node.describe`, keyed by
|
|||||||
## Headless node host (cross-platform)
|
## Headless node host (cross-platform)
|
||||||
|
|
||||||
Clawdbot can run a **headless node host** (no UI) that connects to the Gateway
|
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.
|
or for running a minimal node alongside a server.
|
||||||
|
|
||||||
Start it:
|
Start it:
|
||||||
@@ -296,9 +294,9 @@ Notes:
|
|||||||
- On macOS, the headless node host prefers the companion app exec host when reachable and falls
|
- 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
|
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.
|
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
|
## Mac node mode
|
||||||
|
|
||||||
- The macOS menubar app connects to the Gateway WS server as a node (so `clawdbot nodes …` works against this Mac).
|
- 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 Gateway port and connects to `localhost`.
|
- In remote mode, the app opens an SSH tunnel for the bridge port and connects to `localhost`.
|
||||||
+10
-12
@@ -45,29 +45,27 @@ Stored under `~/.clawdbot/credentials/`:
|
|||||||
Treat these as sensitive (they gate access to your assistant).
|
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
|
Nodes (iOS/Android, future hardware, etc.) connect to the Gateway and request to join.
|
||||||
creates a device pairing request that must be approved.
|
The Gateway keeps an authoritative allowlist; new nodes require explicit approve/reject.
|
||||||
|
|
||||||
### Approve a node device
|
### Approve a node
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot devices list
|
clawdbot nodes pending
|
||||||
clawdbot devices approve <requestId>
|
clawdbot nodes approve <requestId>
|
||||||
clawdbot devices reject <requestId>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Where the state lives
|
### Where the state lives
|
||||||
|
|
||||||
Stored under `~/.clawdbot/devices/`:
|
Stored under `~/.clawdbot/nodes/`:
|
||||||
- `pending.json` (short-lived; pending requests expire)
|
- `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
|
Full protocol + design notes: [Gateway pairing](/gateway/pairing)
|
||||||
separate gateway-owned pairing store. WS nodes still require device pairing.
|
|
||||||
|
|
||||||
|
|
||||||
## Related docs
|
## Related docs
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ Use `gog` for Gmail/Calendar/Drive/Contacts/Sheets/Docs. Requires OAuth setup.
|
|||||||
|
|
||||||
Setup (once)
|
Setup (once)
|
||||||
- `gog auth credentials /path/to/client_secret.json`
|
- `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`
|
- `gog auth list`
|
||||||
|
|
||||||
Common commands
|
Common commands
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ describe("formatAssistantErrorText", () => {
|
|||||||
const msg = makeAssistantError(
|
const msg = makeAssistantError(
|
||||||
'{"type":"error","error":{"message":"Something exploded","type":"server_error"}}',
|
'{"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", () => {
|
it("renders a generic unknown error message when raw is empty", () => {
|
||||||
expect(formatRawAssistantErrorForUi("")).toContain("unknown error");
|
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", () => {
|
it("sanitizes HTTP status errors with error hints", () => {
|
||||||
expect(sanitizeUserFacingText("500 Internal Server Error")).toBe(
|
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", () => {
|
it("sanitizes raw API error payloads", () => {
|
||||||
const raw = '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}';
|
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.");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -201,14 +201,6 @@ export function formatRawAssistantErrorForUi(raw?: string): string {
|
|||||||
const trimmed = (raw ?? "").trim();
|
const trimmed = (raw ?? "").trim();
|
||||||
if (!trimmed) return "LLM request failed with an unknown error.";
|
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);
|
const info = parseApiErrorInfo(trimmed);
|
||||||
if (info?.message) {
|
if (info?.message) {
|
||||||
const prefix = info.httpCode ? `HTTP ${info.httpCode}` : "LLM error";
|
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.";
|
return "The AI service is temporarily overloaded. Please try again in a moment.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLikelyHttpErrorText(raw) || isRawApiErrorPayload(raw)) {
|
if (isRawApiErrorPayload(raw)) {
|
||||||
return formatRawAssistantErrorForUi(raw);
|
return "The AI service returned an error. Please try again.";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Never return raw unhandled errors - log for debugging but return safe message
|
// 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)) {
|
if (isRawApiErrorPayload(trimmed) || isLikelyHttpErrorText(trimmed)) {
|
||||||
return formatRawAssistantErrorForUi(trimmed);
|
return "The AI service returned an error. Please try again.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ERROR_PREFIX_RE.test(trimmed)) {
|
if (ERROR_PREFIX_RE.test(trimmed)) {
|
||||||
@@ -311,7 +303,7 @@ export function sanitizeUserFacingText(text: string): string {
|
|||||||
if (isTimeoutErrorMessage(trimmed)) {
|
if (isTimeoutErrorMessage(trimmed)) {
|
||||||
return "LLM request timed out.";
|
return "LLM request timed out.";
|
||||||
}
|
}
|
||||||
return formatRawAssistantErrorForUi(trimmed);
|
return "The AI service returned an error. Please try again.";
|
||||||
}
|
}
|
||||||
|
|
||||||
return stripped;
|
return stripped;
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
|
||||||
import {
|
import { createAgentSession, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent";
|
||||||
createAgentSession,
|
|
||||||
estimateTokens,
|
|
||||||
SessionManager,
|
|
||||||
SettingsManager,
|
|
||||||
} from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js";
|
import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js";
|
||||||
import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js";
|
import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js";
|
||||||
@@ -375,26 +370,6 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
session.agent.replaceMessages(limited);
|
session.agent.replaceMessages(limited);
|
||||||
}
|
}
|
||||||
const result = await session.compact(params.customInstructions);
|
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 {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
compacted: true,
|
compacted: true,
|
||||||
@@ -402,7 +377,6 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
summary: result.summary,
|
summary: result.summary,
|
||||||
firstKeptEntryId: result.firstKeptEntryId,
|
firstKeptEntryId: result.firstKeptEntryId,
|
||||||
tokensBefore: result.tokensBefore,
|
tokensBefore: result.tokensBefore,
|
||||||
tokensAfter,
|
|
||||||
details: result.details,
|
details: result.details,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { formatToolAggregate } from "../../../auto-reply/tool-meta.js";
|
|||||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||||
import {
|
import {
|
||||||
formatAssistantErrorText,
|
formatAssistantErrorText,
|
||||||
formatRawAssistantErrorForUi,
|
|
||||||
getApiErrorPayloadFingerprint,
|
getApiErrorPayloadFingerprint,
|
||||||
isRawApiErrorPayload,
|
isRawApiErrorPayload,
|
||||||
normalizeTextForComparison,
|
normalizeTextForComparison,
|
||||||
@@ -65,12 +64,6 @@ export function buildEmbeddedRunPayloads(params: {
|
|||||||
const rawErrorFingerprint = rawErrorMessage
|
const rawErrorFingerprint = rawErrorMessage
|
||||||
? getApiErrorPayloadFingerprint(rawErrorMessage)
|
? getApiErrorPayloadFingerprint(rawErrorMessage)
|
||||||
: null;
|
: null;
|
||||||
const formattedRawErrorMessage = rawErrorMessage
|
|
||||||
? formatRawAssistantErrorForUi(rawErrorMessage)
|
|
||||||
: null;
|
|
||||||
const normalizedFormattedRawErrorMessage = formattedRawErrorMessage
|
|
||||||
? normalizeTextForComparison(formattedRawErrorMessage)
|
|
||||||
: null;
|
|
||||||
const normalizedRawErrorText = rawErrorMessage
|
const normalizedRawErrorText = rawErrorMessage
|
||||||
? normalizeTextForComparison(rawErrorMessage)
|
? normalizeTextForComparison(rawErrorMessage)
|
||||||
: null;
|
: null;
|
||||||
@@ -123,15 +116,10 @@ export function buildEmbeddedRunPayloads(params: {
|
|||||||
if (trimmed === genericErrorText) return true;
|
if (trimmed === genericErrorText) return true;
|
||||||
}
|
}
|
||||||
if (rawErrorMessage && trimmed === rawErrorMessage) return true;
|
if (rawErrorMessage && trimmed === rawErrorMessage) return true;
|
||||||
if (formattedRawErrorMessage && trimmed === formattedRawErrorMessage) return true;
|
|
||||||
if (normalizedRawErrorText) {
|
if (normalizedRawErrorText) {
|
||||||
const normalized = normalizeTextForComparison(trimmed);
|
const normalized = normalizeTextForComparison(trimmed);
|
||||||
if (normalized && normalized === normalizedRawErrorText) return true;
|
if (normalized && normalized === normalizedRawErrorText) return true;
|
||||||
}
|
}
|
||||||
if (normalizedFormattedRawErrorMessage) {
|
|
||||||
const normalized = normalizeTextForComparison(trimmed);
|
|
||||||
if (normalized && normalized === normalizedFormattedRawErrorMessage) return true;
|
|
||||||
}
|
|
||||||
if (rawErrorFingerprint) {
|
if (rawErrorFingerprint) {
|
||||||
const fingerprint = getApiErrorPayloadFingerprint(trimmed);
|
const fingerprint = getApiErrorPayloadFingerprint(trimmed);
|
||||||
if (fingerprint && fingerprint === rawErrorFingerprint) return true;
|
if (fingerprint && fingerprint === rawErrorFingerprint) return true;
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ export type EmbeddedPiCompactResult = {
|
|||||||
summary: string;
|
summary: string;
|
||||||
firstKeptEntryId: string;
|
firstKeptEntryId: string;
|
||||||
tokensBefore: number;
|
tokensBefore: number;
|
||||||
tokensAfter?: number;
|
|
||||||
details?: unknown;
|
details?: unknown;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,8 +31,7 @@ const subagentRuns = new Map<string, SubagentRunRecord>();
|
|||||||
let sweeper: NodeJS.Timeout | null = null;
|
let sweeper: NodeJS.Timeout | null = null;
|
||||||
let listenerStarted = false;
|
let listenerStarted = false;
|
||||||
let listenerStop: (() => void) | null = null;
|
let listenerStop: (() => void) | null = null;
|
||||||
// Use var to avoid TDZ on circular init paths that can call restoreSubagentRunsOnce early.
|
let restoreAttempted = false;
|
||||||
var restoreAttempted = false;
|
|
||||||
|
|
||||||
function persistSubagentRuns() {
|
function persistSubagentRuns() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
sessions_send: "Send a message to another session/sub-agent",
|
sessions_send: "Send a message to another session/sub-agent",
|
||||||
sessions_spawn: "Spawn a sub-agent session",
|
sessions_spawn: "Spawn a sub-agent session",
|
||||||
session_status:
|
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",
|
image: "Analyze an image with the configured image model",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ export function createSessionStatusTool(opts?: {
|
|||||||
label: "Session Status",
|
label: "Session Status",
|
||||||
name: "session_status",
|
name: "session_status",
|
||||||
description:
|
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,
|
parameters: SessionStatusToolSchema,
|
||||||
execute: async (_toolCallId, args) => {
|
execute: async (_toolCallId, args) => {
|
||||||
const params = args as Record<string, unknown>;
|
const params = args as Record<string, unknown>;
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ describe("block streaming", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function waitForCalls(fn: () => number, calls: number) {
|
async function waitForCalls(fn: () => number, calls: number) {
|
||||||
const deadline = Date.now() + 15000;
|
const deadline = Date.now() + 5000;
|
||||||
while (fn() < calls) {
|
while (fn() < calls) {
|
||||||
if (Date.now() > deadline) {
|
if (Date.now() > deadline) {
|
||||||
throw new Error(`Expected ${calls} call(s), got ${fn()}`);
|
throw new Error(`Expected ${calls} call(s), got ${fn()}`);
|
||||||
|
|||||||
@@ -83,13 +83,18 @@ export const handleCompactCommand: CommandHandler = async (params) => {
|
|||||||
ownerNumbers: params.command.ownerList.length > 0 ? params.command.ownerList : undefined,
|
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
|
const compactLabel = result.ok
|
||||||
? result.compacted
|
? result.compacted
|
||||||
? result.result?.tokensBefore != null && result.result?.tokensAfter != null
|
? result.result?.tokensBefore
|
||||||
? `Compacted (${formatTokenCount(result.result.tokensBefore)} → ${formatTokenCount(result.result.tokensAfter)})`
|
? `Compacted (${formatTokenCount(result.result.tokensBefore)} before)`
|
||||||
: result.result?.tokensBefore
|
: "Compacted"
|
||||||
? `Compacted (${formatTokenCount(result.result.tokensBefore)} before)`
|
|
||||||
: "Compacted"
|
|
||||||
: "Compaction skipped"
|
: "Compaction skipped"
|
||||||
: "Compaction failed";
|
: "Compaction failed";
|
||||||
if (result.ok && result.compacted) {
|
if (result.ok && result.compacted) {
|
||||||
@@ -98,20 +103,8 @@ export const handleCompactCommand: CommandHandler = async (params) => {
|
|||||||
sessionStore: params.sessionStore,
|
sessionStore: params.sessionStore,
|
||||||
sessionKey: params.sessionKey,
|
sessionKey: params.sessionKey,
|
||||||
storePath: params.storePath,
|
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 reason = result.reason?.trim();
|
||||||
const line = reason
|
const line = reason
|
||||||
? `${compactLabel}: ${reason} • ${contextSummary}`
|
? `${compactLabel}: ${reason} • ${contextSummary}`
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ export async function applyInlineDirectivesFastLane(params: {
|
|||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
isGroup: boolean;
|
isGroup: boolean;
|
||||||
sessionEntry: SessionEntry;
|
sessionEntry?: SessionEntry;
|
||||||
sessionStore: Record<string, SessionEntry>;
|
sessionStore?: Record<string, SessionEntry>;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
storePath?: string;
|
storePath?: string;
|
||||||
elevatedEnabled: boolean;
|
elevatedEnabled: boolean;
|
||||||
|
|||||||
@@ -77,6 +77,100 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
|
|||||||
expect(result?.text).not.toContain("failed");
|
expect(result?.text).not.toContain("failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows error message when sessionEntry is missing", async () => {
|
||||||
|
const directives = parseInlineDirectives("/model openai/gpt-4o");
|
||||||
|
const sessionStore = {};
|
||||||
|
|
||||||
|
const result = await handleDirectiveOnly({
|
||||||
|
cfg: baseConfig(),
|
||||||
|
directives,
|
||||||
|
sessionEntry: undefined, // Missing!
|
||||||
|
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("failed");
|
||||||
|
expect(result?.text).toContain("session state unavailable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error message when sessionStore is missing", async () => {
|
||||||
|
const directives = parseInlineDirectives("/model openai/gpt-4o");
|
||||||
|
const sessionEntry: SessionEntry = {
|
||||||
|
sessionId: "s1",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await handleDirectiveOnly({
|
||||||
|
cfg: baseConfig(),
|
||||||
|
directives,
|
||||||
|
sessionEntry,
|
||||||
|
sessionStore: undefined, // Missing!
|
||||||
|
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("failed");
|
||||||
|
expect(result?.text).toContain("session state unavailable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error message when sessionKey is missing", 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: undefined, // Missing!
|
||||||
|
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("failed");
|
||||||
|
expect(result?.text).toContain("session state unavailable");
|
||||||
|
});
|
||||||
|
|
||||||
it("shows no model message when no /model directive", async () => {
|
it("shows no model message when no /model directive", async () => {
|
||||||
const directives = parseInlineDirectives("hello world");
|
const directives = parseInlineDirectives("hello world");
|
||||||
const sessionEntry: SessionEntry = {
|
const sessionEntry: SessionEntry = {
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ function resolveExecDefaults(params: {
|
|||||||
export async function handleDirectiveOnly(params: {
|
export async function handleDirectiveOnly(params: {
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
directives: InlineDirectives;
|
directives: InlineDirectives;
|
||||||
sessionEntry: SessionEntry;
|
sessionEntry?: SessionEntry;
|
||||||
sessionStore: Record<string, SessionEntry>;
|
sessionStore?: Record<string, SessionEntry>;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
storePath?: string;
|
storePath?: string;
|
||||||
elevatedEnabled: boolean;
|
elevatedEnabled: boolean;
|
||||||
@@ -288,110 +288,114 @@ export async function handleDirectiveOnly(params: {
|
|||||||
nextThinkLevel === "xhigh" &&
|
nextThinkLevel === "xhigh" &&
|
||||||
!supportsXHighThinking(resolvedProvider, resolvedModel);
|
!supportsXHighThinking(resolvedProvider, resolvedModel);
|
||||||
|
|
||||||
const prevElevatedLevel =
|
let didPersistModel = false;
|
||||||
currentElevatedLevel ??
|
if (sessionEntry && sessionStore && sessionKey) {
|
||||||
(sessionEntry.elevatedLevel as ElevatedLevel | undefined) ??
|
const prevElevatedLevel =
|
||||||
(elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel));
|
currentElevatedLevel ??
|
||||||
const prevReasoningLevel =
|
(sessionEntry.elevatedLevel as ElevatedLevel | undefined) ??
|
||||||
currentReasoningLevel ?? (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off";
|
(elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel));
|
||||||
let elevatedChanged =
|
const prevReasoningLevel =
|
||||||
directives.hasElevatedDirective &&
|
currentReasoningLevel ?? (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off";
|
||||||
directives.elevatedLevel !== undefined &&
|
let elevatedChanged =
|
||||||
elevatedEnabled &&
|
directives.hasElevatedDirective &&
|
||||||
elevatedAllowed;
|
directives.elevatedLevel !== undefined &&
|
||||||
let reasoningChanged =
|
elevatedEnabled &&
|
||||||
directives.hasReasoningDirective && directives.reasoningLevel !== undefined;
|
elevatedAllowed;
|
||||||
if (directives.hasThinkDirective && directives.thinkLevel) {
|
let reasoningChanged =
|
||||||
if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel;
|
directives.hasReasoningDirective && directives.reasoningLevel !== undefined;
|
||||||
else sessionEntry.thinkingLevel = directives.thinkLevel;
|
if (directives.hasThinkDirective && directives.thinkLevel) {
|
||||||
}
|
if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel;
|
||||||
if (shouldDowngradeXHigh) {
|
else sessionEntry.thinkingLevel = directives.thinkLevel;
|
||||||
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 (directives.execSecurity) {
|
if (shouldDowngradeXHigh) {
|
||||||
sessionEntry.execSecurity = directives.execSecurity;
|
sessionEntry.thinkingLevel = "high";
|
||||||
}
|
}
|
||||||
if (directives.execAsk) {
|
if (directives.hasVerboseDirective && directives.verboseLevel) {
|
||||||
sessionEntry.execAsk = directives.execAsk;
|
applyVerboseOverride(sessionEntry, directives.verboseLevel);
|
||||||
}
|
}
|
||||||
if (directives.execNode) {
|
if (directives.hasReasoningDirective && directives.reasoningLevel) {
|
||||||
sessionEntry.execNode = directives.execNode;
|
if (directives.reasoningLevel === "off") delete sessionEntry.reasoningLevel;
|
||||||
|
else sessionEntry.reasoningLevel = directives.reasoningLevel;
|
||||||
|
reasoningChanged =
|
||||||
|
directives.reasoningLevel !== prevReasoningLevel && directives.reasoningLevel !== undefined;
|
||||||
}
|
}
|
||||||
}
|
if (directives.hasElevatedDirective && directives.elevatedLevel) {
|
||||||
if (modelSelection) {
|
// Unlike other toggles, elevated defaults can be "on".
|
||||||
applyModelOverrideToSessionEntry({
|
// Persist "off" explicitly so `/elevated off` actually overrides defaults.
|
||||||
entry: sessionEntry,
|
sessionEntry.elevatedLevel = directives.elevatedLevel;
|
||||||
selection: modelSelection,
|
elevatedChanged =
|
||||||
profileOverride,
|
elevatedChanged ||
|
||||||
});
|
(directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
|
||||||
}
|
|
||||||
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") {
|
if (directives.hasExecDirective && directives.hasExecOptions) {
|
||||||
sessionEntry.queueCap = directives.cap;
|
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) {
|
if (modelSelection) {
|
||||||
sessionEntry.queueDrop = directives.dropPolicy;
|
applyModelOverrideToSessionEntry({
|
||||||
|
entry: sessionEntry,
|
||||||
|
selection: modelSelection,
|
||||||
|
profileOverride,
|
||||||
|
});
|
||||||
|
didPersistModel = true;
|
||||||
}
|
}
|
||||||
}
|
if (directives.hasQueueDirective && directives.queueReset) {
|
||||||
sessionEntry.updatedAt = Date.now();
|
delete sessionEntry.queueMode;
|
||||||
sessionStore[sessionKey] = sessionEntry;
|
delete sessionEntry.queueDebounceMs;
|
||||||
if (storePath) {
|
delete sessionEntry.queueCap;
|
||||||
await updateSessionStore(storePath, (store) => {
|
delete sessionEntry.queueDrop;
|
||||||
store[sessionKey] = sessionEntry;
|
} else if (directives.hasQueueDirective) {
|
||||||
});
|
if (directives.queueMode) sessionEntry.queueMode = directives.queueMode;
|
||||||
}
|
if (typeof directives.debounceMs === "number") {
|
||||||
if (modelSelection) {
|
sessionEntry.queueDebounceMs = directives.debounceMs;
|
||||||
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
|
}
|
||||||
if (nextLabel !== initialModelLabel) {
|
if (typeof directives.cap === "number") {
|
||||||
enqueueSystemEvent(formatModelSwitchEvent(nextLabel, modelSelection.alias), {
|
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,
|
sessionKey,
|
||||||
contextKey: `model:${nextLabel}`,
|
contextKey: "mode:elevated",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (reasoningChanged) {
|
||||||
|
const nextReasoning = (sessionEntry.reasoningLevel ?? "off") as ReasoningLevel;
|
||||||
|
enqueueSystemEvent(formatReasoningEvent(nextReasoning), {
|
||||||
|
sessionKey,
|
||||||
|
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[] = [];
|
const parts: string[] = [];
|
||||||
@@ -445,7 +449,7 @@ export async function handleDirectiveOnly(params: {
|
|||||||
`Thinking level set to high (xhigh not supported for ${resolvedProvider}/${resolvedModel}).`,
|
`Thinking level set to high (xhigh not supported for ${resolvedProvider}/${resolvedModel}).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (modelSelection) {
|
if (modelSelection && didPersistModel) {
|
||||||
const label = `${modelSelection.provider}/${modelSelection.model}`;
|
const label = `${modelSelection.provider}/${modelSelection.model}`;
|
||||||
const labelWithAlias = modelSelection.alias ? `${modelSelection.alias} (${label})` : label;
|
const labelWithAlias = modelSelection.alias ? `${modelSelection.alias} (${label})` : label;
|
||||||
parts.push(
|
parts.push(
|
||||||
@@ -456,6 +460,10 @@ export async function handleDirectiveOnly(params: {
|
|||||||
if (profileOverride) {
|
if (profileOverride) {
|
||||||
parts.push(`Auth profile set to ${profileOverride}.`);
|
parts.push(`Auth profile set to ${profileOverride}.`);
|
||||||
}
|
}
|
||||||
|
} else if (modelSelection && !didPersistModel) {
|
||||||
|
parts.push(
|
||||||
|
`Model switch to ${modelSelection.provider}/${modelSelection.model} failed (session state unavailable).`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (directives.hasQueueDirective && directives.queueMode) {
|
if (directives.hasQueueDirective && directives.queueMode) {
|
||||||
parts.push(formatDirectiveAck(`Queue mode set to ${directives.queueMode}.`));
|
parts.push(formatDirectiveAck(`Queue mode set to ${directives.queueMode}.`));
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ export async function applyInlineDirectiveOverrides(params: {
|
|||||||
agentId: string;
|
agentId: string;
|
||||||
agentDir: string;
|
agentDir: string;
|
||||||
agentCfg: AgentDefaults;
|
agentCfg: AgentDefaults;
|
||||||
sessionEntry: SessionEntry;
|
sessionEntry?: SessionEntry;
|
||||||
sessionStore: Record<string, SessionEntry>;
|
sessionStore?: Record<string, SessionEntry>;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
storePath?: string;
|
storePath?: string;
|
||||||
sessionScope: Parameters<typeof buildStatusReply>[0]["sessionScope"];
|
sessionScope: Parameters<typeof buildStatusReply>[0]["sessionScope"];
|
||||||
|
|||||||
@@ -89,8 +89,8 @@ export async function resolveReplyDirectives(params: {
|
|||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
agentCfg: AgentDefaults;
|
agentCfg: AgentDefaults;
|
||||||
sessionCtx: TemplateContext;
|
sessionCtx: TemplateContext;
|
||||||
sessionEntry: SessionEntry;
|
sessionEntry?: SessionEntry;
|
||||||
sessionStore: Record<string, SessionEntry>;
|
sessionStore?: Record<string, SessionEntry>;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
storePath?: string;
|
storePath?: string;
|
||||||
sessionScope: Parameters<typeof applyInlineDirectiveOverrides>[0]["sessionScope"];
|
sessionScope: Parameters<typeof applyInlineDirectiveOverrides>[0]["sessionScope"];
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js";
|
import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js";
|
||||||
import { incrementCompactionCount, prependSystemEvents } from "./session-updates.js";
|
import { prependSystemEvents } from "./session-updates.js";
|
||||||
|
|
||||||
describe("prependSystemEvents", () => {
|
describe("prependSystemEvents", () => {
|
||||||
it("adds a local timestamp to queued system events by default", async () => {
|
it("adds a local timestamp to queued system events by default", async () => {
|
||||||
@@ -29,37 +29,3 @@ describe("prependSystemEvents", () => {
|
|||||||
vi.useRealTimers();
|
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -237,39 +237,23 @@ export async function incrementCompactionCount(params: {
|
|||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
storePath?: string;
|
storePath?: string;
|
||||||
now?: number;
|
now?: number;
|
||||||
/** Token count after compaction - if provided, updates cached context usage */
|
|
||||||
tokensAfter?: number;
|
|
||||||
}): Promise<number | undefined> {
|
}): Promise<number | undefined> {
|
||||||
const {
|
const { sessionEntry, sessionStore, sessionKey, storePath, now = Date.now() } = params;
|
||||||
sessionEntry,
|
|
||||||
sessionStore,
|
|
||||||
sessionKey,
|
|
||||||
storePath,
|
|
||||||
now = Date.now(),
|
|
||||||
tokensAfter,
|
|
||||||
} = params;
|
|
||||||
if (!sessionStore || !sessionKey) return undefined;
|
if (!sessionStore || !sessionKey) return undefined;
|
||||||
const entry = sessionStore[sessionKey] ?? sessionEntry;
|
const entry = sessionStore[sessionKey] ?? sessionEntry;
|
||||||
if (!entry) return undefined;
|
if (!entry) return undefined;
|
||||||
const nextCount = (entry.compactionCount ?? 0) + 1;
|
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] = {
|
sessionStore[sessionKey] = {
|
||||||
...entry,
|
...entry,
|
||||||
...updates,
|
compactionCount: nextCount,
|
||||||
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
if (storePath) {
|
if (storePath) {
|
||||||
await updateSessionStore(storePath, (store) => {
|
await updateSessionStore(storePath, (store) => {
|
||||||
store[sessionKey] = {
|
store[sessionKey] = {
|
||||||
...store[sessionKey],
|
...store[sessionKey],
|
||||||
...updates,
|
compactionCount: nextCount,
|
||||||
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ type NodeDaemonStatusOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function renderNodeServiceStartHints(): string[] {
|
function renderNodeServiceStartHints(): string[] {
|
||||||
const base = [formatCliCommand("clawdbot node install"), formatCliCommand("clawdbot node start")];
|
const base = [formatCliCommand("clawdbot node install"), formatCliCommand("clawdbot node run")];
|
||||||
switch (process.platform) {
|
switch (process.platform) {
|
||||||
case "darwin":
|
case "darwin":
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ describe("resolveAssistantIdentity avatar normalization", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe(
|
expect(resolveAssistantIdentity({ cfg }).avatar).toBe(DEFAULT_ASSISTANT_IDENTITY.avatar);
|
||||||
DEFAULT_ASSISTANT_IDENTITY.avatar,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps short text avatars", () => {
|
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", () => {
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -98,7 +98,7 @@ function sendJson(res: ServerResponse, status: number, body: unknown) {
|
|||||||
res.end(JSON.stringify(body));
|
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}`;
|
return basePath ? `${basePath}${AVATAR_PREFIX}/${agentId}` : `${AVATAR_PREFIX}/${agentId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,49 +206,11 @@ interface ServeIndexHtmlOpts {
|
|||||||
agentId?: string;
|
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) {
|
function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndexHtmlOpts) {
|
||||||
const { basePath, config, agentId } = opts;
|
const { basePath, config, agentId } = opts;
|
||||||
const identity = config
|
const identity = config
|
||||||
? resolveAssistantIdentity({ cfg: config, agentId })
|
? resolveAssistantIdentity({ cfg: config, agentId })
|
||||||
: DEFAULT_ASSISTANT_IDENTITY;
|
: 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("Content-Type", "text/html; charset=utf-8");
|
||||||
res.setHeader("Cache-Control", "no-cache");
|
res.setHeader("Cache-Control", "no-cache");
|
||||||
const raw = fs.readFileSync(indexPath, "utf8");
|
const raw = fs.readFileSync(indexPath, "utf8");
|
||||||
@@ -256,7 +218,7 @@ function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndex
|
|||||||
injectControlUiConfig(raw, {
|
injectControlUiConfig(raw, {
|
||||||
basePath,
|
basePath,
|
||||||
assistantName: identity.name,
|
assistantName: identity.name,
|
||||||
assistantAvatar: avatarValue,
|
assistantAvatar: identity.avatar,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ import {
|
|||||||
import { loadSessionEntry } from "../session-utils.js";
|
import { loadSessionEntry } from "../session-utils.js";
|
||||||
import { formatForLog } from "../ws-log.js";
|
import { formatForLog } from "../ws-log.js";
|
||||||
import { resolveAssistantIdentity } from "../assistant-identity.js";
|
import { resolveAssistantIdentity } from "../assistant-identity.js";
|
||||||
import { resolveAssistantAvatarUrl } from "../control-ui.js";
|
|
||||||
import { waitForAgentJob } from "./agent-job.js";
|
import { waitForAgentJob } from "./agent-job.js";
|
||||||
import type { GatewayRequestHandlers } from "./types.js";
|
import type { GatewayRequestHandlers } from "./types.js";
|
||||||
|
|
||||||
@@ -408,13 +407,7 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
}
|
}
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const identity = resolveAssistantIdentity({ cfg, agentId });
|
const identity = resolveAssistantIdentity({ cfg, agentId });
|
||||||
const avatarValue =
|
respond(true, identity, undefined);
|
||||||
resolveAssistantAvatarUrl({
|
|
||||||
avatar: identity.avatar,
|
|
||||||
agentId: identity.agentId,
|
|
||||||
basePath: cfg.gateway?.controlUi?.basePath,
|
|
||||||
}) ?? identity.avatar;
|
|
||||||
respond(true, { ...identity, avatar: avatarValue }, undefined);
|
|
||||||
},
|
},
|
||||||
"agent.wait": async ({ params, respond }) => {
|
"agent.wait": async ({ params, respond }) => {
|
||||||
if (!validateAgentWaitParams(params)) {
|
if (!validateAgentWaitParams(params)) {
|
||||||
|
|||||||
@@ -158,8 +158,7 @@ function renderAvatar(
|
|||||||
function isAvatarUrl(value: string): boolean {
|
function isAvatarUrl(value: string): boolean {
|
||||||
return (
|
return (
|
||||||
/^https?:\/\//i.test(value) ||
|
/^https?:\/\//i.test(value) ||
|
||||||
/^data:image\//i.test(value) ||
|
/^data:image\//i.test(value)
|
||||||
/^\//.test(value) // Relative paths from avatar endpoint
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user