Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee2cfa5ca1 | ||
|
|
0e1ebc7da1 | ||
|
|
0e3e6f6b70 | ||
|
|
337dd41ce9 | ||
|
|
88336ef4ea | ||
|
|
5ee03c82b4 | ||
|
|
111aeb2c4f | ||
|
|
23c2c638b7 | ||
|
|
6b8299eb33 | ||
|
|
9822a53649 | ||
|
|
81d392a2d7 | ||
|
|
dbcec3ffaf | ||
|
|
50c5231486 | ||
|
|
55d034358d | ||
|
|
374da34936 | ||
|
|
c578fca687 | ||
|
|
dd18765b50 | ||
|
|
3e06fe84dc | ||
|
|
640e19988f | ||
|
|
145adf540f | ||
|
|
0e3c9e4a0e |
No files matched your search
@@ -66,7 +66,7 @@
|
|||||||
- After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README.
|
- After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README.
|
||||||
|
|
||||||
## Shorthand Commands
|
## Shorthand Commands
|
||||||
- `sync up`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
|
- `sync`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
|
||||||
|
|
||||||
### PR Workflow (Review vs Land)
|
### PR Workflow (Review vs Land)
|
||||||
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
|
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
|
||||||
@@ -116,8 +116,7 @@
|
|||||||
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
|
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
|
||||||
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
|
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
|
||||||
- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema.
|
- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema.
|
||||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/<agentId>/sessions/*.jsonl` (use the `agent=<id>` value in the Runtime line of the system prompt; newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
|
||||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
||||||
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
||||||
- Voice wake forwarding tips:
|
- Voice wake forwarding tips:
|
||||||
@@ -133,19 +132,3 @@
|
|||||||
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
|
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
|
||||||
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
|
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
|
||||||
- Kill the tmux session after publish.
|
- Kill the tmux session after publish.
|
||||||
|
|
||||||
## Exclamation Mark Escaping Workaround
|
|
||||||
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# WRONG - will send "Hello\\!" with backslash
|
|
||||||
clawdbot message send --to "+1234" --message 'Hello!'
|
|
||||||
|
|
||||||
# CORRECT - use heredoc to avoid escaping
|
|
||||||
clawdbot message send --to "+1234" --message "$(cat <<'EOF'
|
|
||||||
Hello!
|
|
||||||
EOF
|
|
||||||
)"
|
|
||||||
```
|
|
||||||
|
|
||||||
This is a Claude Code quirk, not a clawdbot bug.
|
|
||||||
+5
-4
@@ -13,13 +13,14 @@ Docs: https://docs.clawd.bot
|
|||||||
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
|
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
|
||||||
- Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.
|
- Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.
|
||||||
<<<<<<< Updated upstream
|
|
||||||
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
|
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
|
||||||
||||||| Stash base
|
|
||||||
=======
|
|
||||||
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
|
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
|
||||||
>>>>>>> Stashed changes
|
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
|
||||||
|
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
|
||||||
|
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras.
|
||||||
|
- Voice call: keep Twilio notify callbacks out of streaming paths, normalize Tailscale serve paths, and honor ASCII signature ordering. (#1213) — thanks @andrew-kurin.
|
||||||
|
|
||||||
## 2026.1.18-5
|
## 2026.1.18-5
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
||||||
"version" : "0.1.0"
|
"version" : "0.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
||||||
"version" : "0.1.0"
|
"version" : "0.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -64,17 +64,24 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
|
|
||||||
/** Storage for TwiML content (for notify mode with URL-based TwiML) */
|
/** Storage for TwiML content (for notify mode with URL-based TwiML) */
|
||||||
private readonly twimlStorage = new Map<string, string>();
|
private readonly twimlStorage = new Map<string, string>();
|
||||||
|
/** Track notify-mode calls to avoid streaming on follow-up callbacks. */
|
||||||
|
private readonly notifyCalls = new Set<string>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete stored TwiML for a given `callId`.
|
* Delete stored TwiML for a given `callId`.
|
||||||
*
|
*
|
||||||
* We keep TwiML in-memory only long enough to satisfy the initial Twilio
|
* We keep TwiML in-memory only long enough to satisfy the initial Twilio
|
||||||
* webhook request (notify mode). Subsequent webhooks should not reuse it.
|
* webhook request (notify mode). The notify guard is cleared separately.
|
||||||
*/
|
*/
|
||||||
private deleteStoredTwiml(callId: string): void {
|
private deleteStoredTwiml(callId: string): void {
|
||||||
this.twimlStorage.delete(callId);
|
this.twimlStorage.delete(callId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private clearNotifyState(callId: string): void {
|
||||||
|
this.twimlStorage.delete(callId);
|
||||||
|
this.notifyCalls.delete(callId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete stored TwiML for a call, addressed by Twilio's provider call SID.
|
* Delete stored TwiML for a call, addressed by Twilio's provider call SID.
|
||||||
*
|
*
|
||||||
@@ -87,7 +94,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
const callIdMatch = webhookUrl.match(/callId=([^&]+)/);
|
const callIdMatch = webhookUrl.match(/callId=([^&]+)/);
|
||||||
if (!callIdMatch) return;
|
if (!callIdMatch) return;
|
||||||
|
|
||||||
this.deleteStoredTwiml(callIdMatch[1]);
|
this.clearNotifyState(callIdMatch[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(config: TwilioConfig, options: TwilioProviderOptions = {}) {
|
constructor(config: TwilioConfig, options: TwilioProviderOptions = {}) {
|
||||||
@@ -137,7 +144,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
*/
|
*/
|
||||||
private async apiRequest<T = unknown>(
|
private async apiRequest<T = unknown>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
params: Record<string, string>,
|
params: Record<string, string | string[]>,
|
||||||
options?: { allowNotFound?: boolean },
|
options?: { allowNotFound?: boolean },
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
return await twilioApiRequest<T>({
|
return await twilioApiRequest<T>({
|
||||||
@@ -257,12 +264,12 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
case "no-answer":
|
case "no-answer":
|
||||||
case "failed":
|
case "failed":
|
||||||
if (callIdOverride) {
|
if (callIdOverride) {
|
||||||
this.deleteStoredTwiml(callIdOverride);
|
this.clearNotifyState(callIdOverride);
|
||||||
}
|
}
|
||||||
return { ...baseEvent, type: "call.ended", reason: callStatus };
|
return { ...baseEvent, type: "call.ended", reason: callStatus };
|
||||||
case "canceled":
|
case "canceled":
|
||||||
if (callIdOverride) {
|
if (callIdOverride) {
|
||||||
this.deleteStoredTwiml(callIdOverride);
|
this.clearNotifyState(callIdOverride);
|
||||||
}
|
}
|
||||||
return { ...baseEvent, type: "call.ended", reason: "hangup-bot" };
|
return { ...baseEvent, type: "call.ended", reason: "hangup-bot" };
|
||||||
default:
|
default:
|
||||||
@@ -286,6 +293,9 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
if (!ctx) return TwilioProvider.EMPTY_TWIML;
|
if (!ctx) return TwilioProvider.EMPTY_TWIML;
|
||||||
|
|
||||||
const params = new URLSearchParams(ctx.rawBody);
|
const params = new URLSearchParams(ctx.rawBody);
|
||||||
|
const type =
|
||||||
|
typeof ctx.query?.type === "string" ? ctx.query.type.trim() : undefined;
|
||||||
|
const isStatusCallback = type === "status";
|
||||||
const callStatus = params.get("CallStatus");
|
const callStatus = params.get("CallStatus");
|
||||||
const direction = params.get("Direction");
|
const direction = params.get("Direction");
|
||||||
const callIdFromQuery =
|
const callIdFromQuery =
|
||||||
@@ -297,13 +307,21 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
|
|
||||||
// Handle initial TwiML request (when Twilio first initiates the call)
|
// Handle initial TwiML request (when Twilio first initiates the call)
|
||||||
// Check if we have stored TwiML for this call (notify mode)
|
// Check if we have stored TwiML for this call (notify mode)
|
||||||
if (callIdFromQuery) {
|
if (callIdFromQuery && !isStatusCallback) {
|
||||||
const storedTwiml = this.twimlStorage.get(callIdFromQuery);
|
const storedTwiml = this.twimlStorage.get(callIdFromQuery);
|
||||||
if (storedTwiml) {
|
if (storedTwiml) {
|
||||||
// Clean up after serving (one-time use)
|
// Clean up after serving (one-time use)
|
||||||
this.deleteStoredTwiml(callIdFromQuery);
|
this.deleteStoredTwiml(callIdFromQuery);
|
||||||
return storedTwiml;
|
return storedTwiml;
|
||||||
}
|
}
|
||||||
|
if (this.notifyCalls.has(callIdFromQuery)) {
|
||||||
|
return TwilioProvider.EMPTY_TWIML;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status callbacks should not receive TwiML.
|
||||||
|
if (isStatusCallback) {
|
||||||
|
return TwilioProvider.EMPTY_TWIML;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle subsequent webhook requests (status callbacks, etc.)
|
// Handle subsequent webhook requests (status callbacks, etc.)
|
||||||
@@ -385,16 +403,18 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
// We now serve it from the webhook endpoint instead of sending inline
|
// We now serve it from the webhook endpoint instead of sending inline
|
||||||
if (input.inlineTwiml) {
|
if (input.inlineTwiml) {
|
||||||
this.twimlStorage.set(input.callId, input.inlineTwiml);
|
this.twimlStorage.set(input.callId, input.inlineTwiml);
|
||||||
|
// Keep notify-mode calls out of streaming TwiML for any follow-up callbacks.
|
||||||
|
this.notifyCalls.add(input.callId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build request params - always use URL-based TwiML.
|
// Build request params - always use URL-based TwiML.
|
||||||
// Twilio silently ignores `StatusCallback` when using the inline `Twiml` parameter.
|
// Twilio silently ignores `StatusCallback` when using the inline `Twiml` parameter.
|
||||||
const params: Record<string, string> = {
|
const params: Record<string, string | string[]> = {
|
||||||
To: input.to,
|
To: input.to,
|
||||||
From: input.from,
|
From: input.from,
|
||||||
Url: url.toString(), // TwiML serving endpoint
|
Url: url.toString(), // TwiML serving endpoint
|
||||||
StatusCallback: statusUrl.toString(), // Separate status callback endpoint
|
StatusCallback: statusUrl.toString(), // Separate status callback endpoint
|
||||||
StatusCallbackEvent: "initiated ringing answered completed",
|
StatusCallbackEvent: ["initiated", "ringing", "answered", "completed"],
|
||||||
Timeout: "30",
|
Timeout: "30",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { twilioApiRequest } from "./api.js";
|
||||||
|
|
||||||
|
describe("twilioApiRequest", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes array params as repeated form fields", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
text: async () => "",
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||||
|
|
||||||
|
await twilioApiRequest({
|
||||||
|
baseUrl: "https://api.example.com",
|
||||||
|
accountSid: "AC123",
|
||||||
|
authToken: "token",
|
||||||
|
endpoint: "/Calls.json",
|
||||||
|
body: {
|
||||||
|
To: "+15555550123",
|
||||||
|
StatusCallbackEvent: ["initiated", "completed"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, init] = fetchMock.mock.calls[0] ?? [];
|
||||||
|
const body = init?.body as URLSearchParams | undefined;
|
||||||
|
|
||||||
|
expect(body?.getAll("StatusCallbackEvent")).toEqual([
|
||||||
|
"initiated",
|
||||||
|
"completed",
|
||||||
|
]);
|
||||||
|
expect(body?.get("To")).toBe("+15555550123");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,16 +3,34 @@ export async function twilioApiRequest<T = unknown>(params: {
|
|||||||
accountSid: string;
|
accountSid: string;
|
||||||
authToken: string;
|
authToken: string;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
body: Record<string, string>;
|
body: URLSearchParams | Record<string, string | string[]>;
|
||||||
allowNotFound?: boolean;
|
allowNotFound?: boolean;
|
||||||
}): Promise<T> {
|
}): Promise<T> {
|
||||||
|
const bodyParams =
|
||||||
|
params.body instanceof URLSearchParams
|
||||||
|
? params.body
|
||||||
|
: Object.entries(params.body).reduce<URLSearchParams>(
|
||||||
|
(acc, [key, value]) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const entry of value) {
|
||||||
|
acc.append(key, entry);
|
||||||
|
}
|
||||||
|
} else if (typeof value === "string") {
|
||||||
|
acc.append(key, value);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
new URLSearchParams(),
|
||||||
|
);
|
||||||
|
|
||||||
const response = await fetch(`${params.baseUrl}${params.endpoint}`, {
|
const response = await fetch(`${params.baseUrl}${params.endpoint}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Basic ${Buffer.from(`${params.accountSid}:${params.authToken}`).toString("base64")}`,
|
Authorization: `Basic ${Buffer.from(`${params.accountSid}:${params.authToken}`).toString("base64")}`,
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
},
|
},
|
||||||
body: new URLSearchParams(params.body),
|
// URL-encoded body required by Twilio, with repeated keys for arrays.
|
||||||
|
body: bodyParams,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -26,4 +44,3 @@ export async function twilioApiRequest<T = unknown>(params: {
|
|||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
return text ? (JSON.parse(text) as T) : (undefined as T);
|
return text ? (JSON.parse(text) as T) : (undefined as T);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,12 +230,14 @@ export async function startTailscaleTunnel(config: {
|
|||||||
throw new Error("Could not get Tailscale DNS name. Is Tailscale running?");
|
throw new Error("Could not get Tailscale DNS name. Is Tailscale running?");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const path = config.path.startsWith("/") ? config.path : `/${config.path}`;
|
||||||
|
// --set-path already mounts the path; keep the target base URL to avoid /path/path.
|
||||||
const localUrl = `http://127.0.0.1:${config.port}`;
|
const localUrl = `http://127.0.0.1:${config.port}`;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const proc = spawn(
|
const proc = spawn(
|
||||||
"tailscale",
|
"tailscale",
|
||||||
[config.mode, "--bg", "--yes", "--set-path", config.path, localUrl],
|
[config.mode, "--bg", "--yes", "--set-path", path, localUrl],
|
||||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -247,7 +249,7 @@ export async function startTailscaleTunnel(config: {
|
|||||||
proc.on("close", (code) => {
|
proc.on("close", (code) => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
const publicUrl = `https://${dnsName}${config.path}`;
|
const publicUrl = `https://${dnsName}${path}`;
|
||||||
console.log(
|
console.log(
|
||||||
`[voice-call] Tailscale ${config.mode} active: ${publicUrl}`,
|
`[voice-call] Tailscale ${config.mode} active: ${publicUrl}`,
|
||||||
);
|
);
|
||||||
@@ -256,7 +258,7 @@ export async function startTailscaleTunnel(config: {
|
|||||||
publicUrl,
|
publicUrl,
|
||||||
provider: `tailscale-${config.mode}`,
|
provider: `tailscale-${config.mode}`,
|
||||||
stop: async () => {
|
stop: async () => {
|
||||||
await stopTailscaleTunnel(config.mode, config.path);
|
await stopTailscaleTunnel(config.mode, path);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ function twilioSignature(params: {
|
|||||||
let dataToSign = params.url;
|
let dataToSign = params.url;
|
||||||
const sortedParams = Array.from(
|
const sortedParams = Array.from(
|
||||||
new URLSearchParams(params.postBody).entries(),
|
new URLSearchParams(params.postBody).entries(),
|
||||||
).sort((a, b) => a[0].localeCompare(b[0]));
|
).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
||||||
|
|
||||||
for (const [key, value] of sortedParams) {
|
for (const [key, value] of sortedParams) {
|
||||||
dataToSign += key + value;
|
dataToSign += key + value;
|
||||||
@@ -205,4 +205,33 @@ describe("verifyTwilioWebhook", () => {
|
|||||||
|
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sorts params using ASCII order", () => {
|
||||||
|
const authToken = "test-auth-token";
|
||||||
|
const publicUrl = "https://example.com/voice/webhook";
|
||||||
|
const postBody = "Z=first&a=second";
|
||||||
|
|
||||||
|
const signature = twilioSignature({
|
||||||
|
authToken,
|
||||||
|
url: publicUrl,
|
||||||
|
postBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = verifyTwilioWebhook(
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
host: "example.com",
|
||||||
|
"x-forwarded-proto": "https",
|
||||||
|
"x-twilio-signature": signature,
|
||||||
|
},
|
||||||
|
rawBody: postBody,
|
||||||
|
url: "http://local/voice/webhook",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
authToken,
|
||||||
|
{ publicUrl },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -23,9 +23,9 @@ export function validateTwilioSignature(
|
|||||||
// Build the string to sign: URL + sorted params (key+value pairs)
|
// Build the string to sign: URL + sorted params (key+value pairs)
|
||||||
let dataToSign = url;
|
let dataToSign = url;
|
||||||
|
|
||||||
// Sort params alphabetically and append key+value
|
// Sort params by ASCII order (Twilio spec) and append key+value.
|
||||||
const sortedParams = Array.from(params.entries()).sort((a, b) =>
|
const sortedParams = Array.from(params.entries()).sort((a, b) =>
|
||||||
a[0].localeCompare(b[0]),
|
a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const [key, value] of sortedParams) {
|
for (const [key, value] of sortedParams) {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ function run(cmd, args) {
|
|||||||
cwd: uiDir,
|
cwd: uiDir,
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
env: process.env,
|
env: process.env,
|
||||||
|
shell: process.platform === "win32",
|
||||||
});
|
});
|
||||||
child.on("exit", (code, signal) => {
|
child.on("exit", (code, signal) => {
|
||||||
if (signal) process.exit(1);
|
if (signal) process.exit(1);
|
||||||
@@ -67,6 +68,7 @@ function runSync(cmd, args, envOverride) {
|
|||||||
cwd: uiDir,
|
cwd: uiDir,
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
env: envOverride ?? process.env,
|
env: envOverride ?? process.env,
|
||||||
|
shell: process.platform === "win32",
|
||||||
});
|
});
|
||||||
if (result.signal) process.exit(1);
|
if (result.signal) process.exit(1);
|
||||||
if ((result.status ?? 1) !== 0) process.exit(result.status ?? 1);
|
if ((result.status ?? 1) !== 0) process.exit(result.status ?? 1);
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
---
|
---
|
||||||
name: session-logs
|
name: session-logs
|
||||||
description: Search and analyze your own conversation history from session log files using jq.
|
description: Search and analyze your own session logs (older/parent conversations) using jq.
|
||||||
metadata: {"clawdbot":{"emoji":"📜","requires":{"bins":["jq"]}}}
|
metadata: {"clawdbot":{"emoji":"📜","requires":{"bins":["jq","rg"]}}}
|
||||||
---
|
---
|
||||||
|
|
||||||
# session-logs
|
# session-logs
|
||||||
|
|
||||||
Search your complete conversation history stored in session JSONL files. Use this when you need to recall something not in your memory files.
|
Search your complete conversation history stored in session JSONL files. Use this when a user references older/parent conversations or asks what was said before.
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
|
||||||
|
Use this skill when the user asks about prior chats, parent conversations, or historical context that isn’t in memory files.
|
||||||
|
|
||||||
## Location
|
## Location
|
||||||
|
|
||||||
Session logs live at: `~/.clawdbot/agents/main/sessions/`
|
Session logs live at: `~/.clawdbot/agents/<agentId>/sessions/` (use the `agent=<id>` value from the system prompt Runtime line).
|
||||||
|
|
||||||
- **`sessions.json`** - Index mapping session keys to session IDs
|
- **`sessions.json`** - Index mapping session keys to session IDs
|
||||||
- **`<session-id>.jsonl`** - Full conversation transcript per session
|
- **`<session-id>.jsonl`** - Full conversation transcript per session
|
||||||
@@ -21,14 +25,14 @@ Each `.jsonl` file contains messages with:
|
|||||||
- `type`: "session" (metadata) or "message"
|
- `type`: "session" (metadata) or "message"
|
||||||
- `timestamp`: ISO timestamp
|
- `timestamp`: ISO timestamp
|
||||||
- `message.role`: "user", "assistant", or "toolResult"
|
- `message.role`: "user", "assistant", or "toolResult"
|
||||||
- `message.content[]`: Text, thinking, or tool calls
|
- `message.content[]`: Text, thinking, or tool calls (filter `type=="text"` for human-readable content)
|
||||||
- `message.usage.cost.total`: Cost per response
|
- `message.usage.cost.total`: Cost per response
|
||||||
|
|
||||||
## Common Queries
|
## Common Queries
|
||||||
|
|
||||||
### List all sessions by date and size
|
### List all sessions by date and size
|
||||||
```bash
|
```bash
|
||||||
for f in ~/.clawdbot/agents/main/sessions/*.jsonl; do
|
for f in ~/.clawdbot/agents/<agentId>/sessions/*.jsonl; do
|
||||||
date=$(head -1 "$f" | jq -r '.timestamp' | cut -dT -f1)
|
date=$(head -1 "$f" | jq -r '.timestamp' | cut -dT -f1)
|
||||||
size=$(ls -lh "$f" | awk '{print $5}')
|
size=$(ls -lh "$f" | awk '{print $5}')
|
||||||
echo "$date $size $(basename $f)"
|
echo "$date $size $(basename $f)"
|
||||||
@@ -37,19 +41,19 @@ done | sort -r
|
|||||||
|
|
||||||
### Find sessions from a specific day
|
### Find sessions from a specific day
|
||||||
```bash
|
```bash
|
||||||
for f in ~/.clawdbot/agents/main/sessions/*.jsonl; do
|
for f in ~/.clawdbot/agents/<agentId>/sessions/*.jsonl; do
|
||||||
head -1 "$f" | jq -r '.timestamp' | grep -q "2026-01-06" && echo "$f"
|
head -1 "$f" | jq -r '.timestamp' | grep -q "2026-01-06" && echo "$f"
|
||||||
done
|
done
|
||||||
```
|
```
|
||||||
|
|
||||||
### Extract user messages from a session
|
### Extract user messages from a session
|
||||||
```bash
|
```bash
|
||||||
jq -r 'select(.message.role == "user") | .message.content[0].text' <session>.jsonl
|
jq -r 'select(.message.role == "user") | .message.content[]? | select(.type == "text") | .text' <session>.jsonl
|
||||||
```
|
```
|
||||||
|
|
||||||
### Search for keyword in assistant responses
|
### Search for keyword in assistant responses
|
||||||
```bash
|
```bash
|
||||||
jq -r 'select(.message.role == "assistant") | .message.content[]? | select(.type == "text") | .text' <session>.jsonl | grep -i "keyword"
|
jq -r 'select(.message.role == "assistant") | .message.content[]? | select(.type == "text") | .text' <session>.jsonl | rg -i "keyword"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Get total cost for a session
|
### Get total cost for a session
|
||||||
@@ -59,7 +63,7 @@ jq -s '[.[] | .message.usage.cost.total // 0] | add' <session>.jsonl
|
|||||||
|
|
||||||
### Daily cost summary
|
### Daily cost summary
|
||||||
```bash
|
```bash
|
||||||
for f in ~/.clawdbot/agents/main/sessions/*.jsonl; do
|
for f in ~/.clawdbot/agents/<agentId>/sessions/*.jsonl; do
|
||||||
date=$(head -1 "$f" | jq -r '.timestamp' | cut -dT -f1)
|
date=$(head -1 "$f" | jq -r '.timestamp' | cut -dT -f1)
|
||||||
cost=$(jq -s '[.[] | .message.usage.cost.total // 0] | add' "$f")
|
cost=$(jq -s '[.[] | .message.usage.cost.total // 0] | add' "$f")
|
||||||
echo "$date $cost"
|
echo "$date $cost"
|
||||||
@@ -84,7 +88,7 @@ jq -r '.message.content[]? | select(.type == "toolCall") | .name' <session>.json
|
|||||||
|
|
||||||
### Search across ALL sessions for a phrase
|
### Search across ALL sessions for a phrase
|
||||||
```bash
|
```bash
|
||||||
grep -l "phrase" ~/.clawdbot/agents/main/sessions/*.jsonl
|
rg -l "phrase" ~/.clawdbot/agents/<agentId>/sessions/*.jsonl
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tips
|
## Tips
|
||||||
@@ -93,3 +97,9 @@ grep -l "phrase" ~/.clawdbot/agents/main/sessions/*.jsonl
|
|||||||
- Large sessions can be several MB - use `head`/`tail` for sampling
|
- Large sessions can be several MB - use `head`/`tail` for sampling
|
||||||
- The `sessions.json` index maps chat providers (discord, whatsapp, etc.) to session IDs
|
- The `sessions.json` index maps chat providers (discord, whatsapp, etc.) to session IDs
|
||||||
- Deleted sessions have `.deleted.<timestamp>` suffix
|
- Deleted sessions have `.deleted.<timestamp>` suffix
|
||||||
|
|
||||||
|
## Fast text-only hint (low noise)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
jq -r 'select(.type=="message") | .message.content[]? | select(.type=="text") | .text' ~/.clawdbot/agents/<agentId>/sessions/<id>.jsonl | rg 'keyword'
|
||||||
|
```
|
||||||
@@ -102,6 +102,7 @@ export async function runCliAgent(params: {
|
|||||||
tools: [],
|
tools: [],
|
||||||
contextFiles,
|
contextFiles,
|
||||||
modelDisplay,
|
modelDisplay,
|
||||||
|
agentId: sessionAgentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { sessionId: cliSessionIdToSend, isNew } = resolveSessionIdToSend({
|
const { sessionId: cliSessionIdToSend, isNew } = resolveSessionIdToSend({
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import type { ThinkLevel } from "../../auto-reply/thinking.js";
|
|||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import type { CliBackendConfig } from "../../config/types.js";
|
import type { CliBackendConfig } from "../../config/types.js";
|
||||||
import { runExec } from "../../process/exec.js";
|
import { runExec } from "../../process/exec.js";
|
||||||
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
|
|
||||||
import type { EmbeddedContextFile } from "../pi-embedded-helpers.js";
|
import type { EmbeddedContextFile } from "../pi-embedded-helpers.js";
|
||||||
|
import { buildSystemPromptParams } from "../system-prompt-params.js";
|
||||||
import { buildAgentSystemPrompt } from "../system-prompt.js";
|
import { buildAgentSystemPrompt } from "../system-prompt.js";
|
||||||
|
|
||||||
const CLI_RUN_QUEUE = new Map<string, Promise<unknown>>();
|
const CLI_RUN_QUEUE = new Map<string, Promise<unknown>>();
|
||||||
@@ -172,10 +172,19 @@ export function buildSystemPrompt(params: {
|
|||||||
tools: AgentTool[];
|
tools: AgentTool[];
|
||||||
contextFiles?: EmbeddedContextFile[];
|
contextFiles?: EmbeddedContextFile[];
|
||||||
modelDisplay: string;
|
modelDisplay: string;
|
||||||
|
agentId?: string;
|
||||||
}) {
|
}) {
|
||||||
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
|
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
|
||||||
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
|
config: params.config,
|
||||||
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
|
agentId: params.agentId,
|
||||||
|
runtime: {
|
||||||
|
host: "clawdbot",
|
||||||
|
os: `${os.type()} ${os.release()}`,
|
||||||
|
arch: os.arch(),
|
||||||
|
node: process.version,
|
||||||
|
model: params.modelDisplay,
|
||||||
|
},
|
||||||
|
});
|
||||||
return buildAgentSystemPrompt({
|
return buildAgentSystemPrompt({
|
||||||
workspaceDir: params.workspaceDir,
|
workspaceDir: params.workspaceDir,
|
||||||
defaultThinkLevel: params.defaultThinkLevel,
|
defaultThinkLevel: params.defaultThinkLevel,
|
||||||
@@ -184,13 +193,7 @@ export function buildSystemPrompt(params: {
|
|||||||
reasoningTagHint: false,
|
reasoningTagHint: false,
|
||||||
heartbeatPrompt: params.heartbeatPrompt,
|
heartbeatPrompt: params.heartbeatPrompt,
|
||||||
docsPath: params.docsPath,
|
docsPath: params.docsPath,
|
||||||
runtimeInfo: {
|
runtimeInfo,
|
||||||
host: "clawdbot",
|
|
||||||
os: `${os.type()} ${os.release()}`,
|
|
||||||
arch: os.arch(),
|
|
||||||
node: process.version,
|
|
||||||
model: params.modelDisplay,
|
|
||||||
},
|
|
||||||
toolNames: params.tools.map((tool) => tool.name),
|
toolNames: params.tools.map((tool) => tool.name),
|
||||||
modelAliasLines: buildModelAliasLines(params.config),
|
modelAliasLines: buildModelAliasLines(params.config),
|
||||||
userTimezone,
|
userTimezone,
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ import { prewarmSessionFile, trackSessionManagerAccess } from "../session-manage
|
|||||||
import { prepareSessionManagerForRun } from "../session-manager-init.js";
|
import { prepareSessionManagerForRun } from "../session-manager-init.js";
|
||||||
import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "../system-prompt.js";
|
import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "../system-prompt.js";
|
||||||
import { splitSdkTools } from "../tool-split.js";
|
import { splitSdkTools } from "../tool-split.js";
|
||||||
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../../date-time.js";
|
import { buildSystemPromptParams } from "../../system-prompt-params.js";
|
||||||
import { describeUnknownError, mapThinkingLevel } from "../utils.js";
|
import { describeUnknownError, mapThinkingLevel } from "../utils.js";
|
||||||
import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js";
|
import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js";
|
||||||
import { isTimeoutError } from "../../failover-error.js";
|
import { isTimeoutError } from "../../failover-error.js";
|
||||||
@@ -196,25 +196,25 @@ export async function runEmbeddedAttempt(
|
|||||||
return level ? { level, channel: "Telegram" } : undefined;
|
return level ? { level, channel: "Telegram" } : undefined;
|
||||||
})()
|
})()
|
||||||
: undefined;
|
: undefined;
|
||||||
const runtimeInfo = {
|
|
||||||
host: machineName,
|
|
||||||
os: `${os.type()} ${os.release()}`,
|
|
||||||
arch: os.arch(),
|
|
||||||
node: process.version,
|
|
||||||
model: `${params.provider}/${params.modelId}`,
|
|
||||||
channel: runtimeChannel,
|
|
||||||
capabilities: runtimeCapabilities,
|
|
||||||
};
|
|
||||||
|
|
||||||
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
|
|
||||||
const reasoningTagHint = isReasoningTagProvider(params.provider);
|
|
||||||
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
|
|
||||||
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
|
|
||||||
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
|
|
||||||
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
|
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
|
||||||
sessionKey: params.sessionKey,
|
sessionKey: params.sessionKey,
|
||||||
config: params.config,
|
config: params.config,
|
||||||
});
|
});
|
||||||
|
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
|
||||||
|
const reasoningTagHint = isReasoningTagProvider(params.provider);
|
||||||
|
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
|
||||||
|
config: params.config,
|
||||||
|
agentId: sessionAgentId,
|
||||||
|
runtime: {
|
||||||
|
host: machineName,
|
||||||
|
os: `${os.type()} ${os.release()}`,
|
||||||
|
arch: os.arch(),
|
||||||
|
node: process.version,
|
||||||
|
model: `${params.provider}/${params.modelId}`,
|
||||||
|
channel: runtimeChannel,
|
||||||
|
capabilities: runtimeCapabilities,
|
||||||
|
},
|
||||||
|
});
|
||||||
const isDefaultAgent = sessionAgentId === defaultAgentId;
|
const isDefaultAgent = sessionAgentId === defaultAgentId;
|
||||||
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
|
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
|
||||||
const docsPath = await resolveClawdbotDocsPath({
|
const docsPath = await resolveClawdbotDocsPath({
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function buildEmbeddedSystemPrompt(params: {
|
|||||||
/** Controls which hardcoded sections to include. Defaults to "full". */
|
/** Controls which hardcoded sections to include. Defaults to "full". */
|
||||||
promptMode?: PromptMode;
|
promptMode?: PromptMode;
|
||||||
runtimeInfo: {
|
runtimeInfo: {
|
||||||
|
agentId?: string;
|
||||||
host: string;
|
host: string;
|
||||||
os: string;
|
os: string;
|
||||||
arch: string;
|
arch: string;
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import {
|
||||||
|
formatUserTime,
|
||||||
|
resolveUserTimeFormat,
|
||||||
|
resolveUserTimezone,
|
||||||
|
type ResolvedTimeFormat,
|
||||||
|
} from "./date-time.js";
|
||||||
|
|
||||||
|
export type RuntimeInfoInput = {
|
||||||
|
agentId?: string;
|
||||||
|
host: string;
|
||||||
|
os: string;
|
||||||
|
arch: string;
|
||||||
|
node: string;
|
||||||
|
model: string;
|
||||||
|
channel?: string;
|
||||||
|
capabilities?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SystemPromptRuntimeParams = {
|
||||||
|
runtimeInfo: RuntimeInfoInput;
|
||||||
|
userTimezone: string;
|
||||||
|
userTime?: string;
|
||||||
|
userTimeFormat?: ResolvedTimeFormat;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildSystemPromptParams(params: {
|
||||||
|
config?: ClawdbotConfig;
|
||||||
|
agentId?: string;
|
||||||
|
runtime: Omit<RuntimeInfoInput, "agentId">;
|
||||||
|
}): SystemPromptRuntimeParams {
|
||||||
|
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
|
||||||
|
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
|
||||||
|
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
|
||||||
|
return {
|
||||||
|
runtimeInfo: {
|
||||||
|
agentId: params.agentId,
|
||||||
|
...params.runtime,
|
||||||
|
},
|
||||||
|
userTimezone,
|
||||||
|
userTime,
|
||||||
|
userTimeFormat,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { buildAgentSystemPrompt } from "./system-prompt.js";
|
import { buildAgentSystemPrompt, buildRuntimeLine } from "./system-prompt.js";
|
||||||
|
|
||||||
describe("buildAgentSystemPrompt", () => {
|
describe("buildAgentSystemPrompt", () => {
|
||||||
it("includes owner numbers when provided", () => {
|
it("includes owner numbers when provided", () => {
|
||||||
@@ -252,6 +252,22 @@ describe("buildAgentSystemPrompt", () => {
|
|||||||
expect(prompt).toContain("capabilities=inlineButtons");
|
expect(prompt).toContain("capabilities=inlineButtons");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes agent id in runtime when provided", () => {
|
||||||
|
const prompt = buildAgentSystemPrompt({
|
||||||
|
workspaceDir: "/tmp/clawd",
|
||||||
|
runtimeInfo: {
|
||||||
|
agentId: "work",
|
||||||
|
host: "host",
|
||||||
|
os: "macOS",
|
||||||
|
arch: "arm64",
|
||||||
|
node: "v20",
|
||||||
|
model: "anthropic/claude",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prompt).toContain("agent=work");
|
||||||
|
});
|
||||||
|
|
||||||
it("includes reasoning visibility hint", () => {
|
it("includes reasoning visibility hint", () => {
|
||||||
const prompt = buildAgentSystemPrompt({
|
const prompt = buildAgentSystemPrompt({
|
||||||
workspaceDir: "/tmp/clawd",
|
workspaceDir: "/tmp/clawd",
|
||||||
@@ -263,6 +279,31 @@ describe("buildAgentSystemPrompt", () => {
|
|||||||
expect(prompt).toContain("/status shows Reasoning");
|
expect(prompt).toContain("/status shows Reasoning");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds runtime line with agent and channel details", () => {
|
||||||
|
const line = buildRuntimeLine(
|
||||||
|
{
|
||||||
|
agentId: "work",
|
||||||
|
host: "host",
|
||||||
|
os: "macOS",
|
||||||
|
arch: "arm64",
|
||||||
|
node: "v20",
|
||||||
|
model: "anthropic/claude",
|
||||||
|
},
|
||||||
|
"telegram",
|
||||||
|
["inlineButtons"],
|
||||||
|
"low",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(line).toContain("agent=work");
|
||||||
|
expect(line).toContain("host=host");
|
||||||
|
expect(line).toContain("os=macOS (arm64)");
|
||||||
|
expect(line).toContain("node=v20");
|
||||||
|
expect(line).toContain("model=anthropic/claude");
|
||||||
|
expect(line).toContain("channel=telegram");
|
||||||
|
expect(line).toContain("capabilities=inlineButtons");
|
||||||
|
expect(line).toContain("thinking=low");
|
||||||
|
});
|
||||||
|
|
||||||
it("describes sandboxed runtime and elevated when allowed", () => {
|
it("describes sandboxed runtime and elevated when allowed", () => {
|
||||||
const prompt = buildAgentSystemPrompt({
|
const prompt = buildAgentSystemPrompt({
|
||||||
workspaceDir: "/tmp/clawd",
|
workspaceDir: "/tmp/clawd",
|
||||||
|
|||||||
+35
-21
@@ -149,6 +149,7 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
/** Controls which hardcoded sections to include. Defaults to "full". */
|
/** Controls which hardcoded sections to include. Defaults to "full". */
|
||||||
promptMode?: PromptMode;
|
promptMode?: PromptMode;
|
||||||
runtimeInfo?: {
|
runtimeInfo?: {
|
||||||
|
agentId?: string;
|
||||||
host?: string;
|
host?: string;
|
||||||
os?: string;
|
os?: string;
|
||||||
arch?: string;
|
arch?: string;
|
||||||
@@ -546,27 +547,40 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push(
|
lines.push("## Runtime", buildRuntimeLine(runtimeInfo, runtimeChannel, runtimeCapabilities, params.defaultThinkLevel), `Reasoning: ${reasoningLevel} (hidden unless on/stream). Toggle /reasoning; /status shows Reasoning when enabled.`);
|
||||||
"## Runtime",
|
|
||||||
`Runtime: ${[
|
|
||||||
runtimeInfo?.host ? `host=${runtimeInfo.host}` : "",
|
|
||||||
runtimeInfo?.os
|
|
||||||
? `os=${runtimeInfo.os}${runtimeInfo?.arch ? ` (${runtimeInfo.arch})` : ""}`
|
|
||||||
: runtimeInfo?.arch
|
|
||||||
? `arch=${runtimeInfo.arch}`
|
|
||||||
: "",
|
|
||||||
runtimeInfo?.node ? `node=${runtimeInfo.node}` : "",
|
|
||||||
runtimeInfo?.model ? `model=${runtimeInfo.model}` : "",
|
|
||||||
runtimeChannel ? `channel=${runtimeChannel}` : "",
|
|
||||||
runtimeChannel
|
|
||||||
? `capabilities=${runtimeCapabilities.length > 0 ? runtimeCapabilities.join(",") : "none"}`
|
|
||||||
: "",
|
|
||||||
`thinking=${params.defaultThinkLevel ?? "off"}`,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" | ")}`,
|
|
||||||
`Reasoning: ${reasoningLevel} (hidden unless on/stream). Toggle /reasoning; /status shows Reasoning when enabled.`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return lines.filter(Boolean).join("\n");
|
return lines.filter(Boolean).join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildRuntimeLine(
|
||||||
|
runtimeInfo?: {
|
||||||
|
agentId?: string;
|
||||||
|
host?: string;
|
||||||
|
os?: string;
|
||||||
|
arch?: string;
|
||||||
|
node?: string;
|
||||||
|
model?: string;
|
||||||
|
},
|
||||||
|
runtimeChannel?: string,
|
||||||
|
runtimeCapabilities: string[] = [],
|
||||||
|
defaultThinkLevel?: ThinkLevel,
|
||||||
|
): string {
|
||||||
|
return `Runtime: ${[
|
||||||
|
runtimeInfo?.agentId ? `agent=${runtimeInfo.agentId}` : "",
|
||||||
|
runtimeInfo?.host ? `host=${runtimeInfo.host}` : "",
|
||||||
|
runtimeInfo?.os
|
||||||
|
? `os=${runtimeInfo.os}${runtimeInfo?.arch ? ` (${runtimeInfo.arch})` : ""}`
|
||||||
|
: runtimeInfo?.arch
|
||||||
|
? `arch=${runtimeInfo.arch}`
|
||||||
|
: "",
|
||||||
|
runtimeInfo?.node ? `node=${runtimeInfo.node}` : "",
|
||||||
|
runtimeInfo?.model ? `model=${runtimeInfo.model}` : "",
|
||||||
|
runtimeChannel ? `channel=${runtimeChannel}` : "",
|
||||||
|
runtimeChannel
|
||||||
|
? `capabilities=${runtimeCapabilities.length > 0 ? runtimeCapabilities.join(",") : "none"}`
|
||||||
|
: "",
|
||||||
|
`thinking=${defaultThinkLevel ?? "off"}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" | ")}`;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { resolveSessionAgentIds } from "../../agents/agent-scope.js";
|
||||||
import { resolveBootstrapMaxChars } from "../../agents/pi-embedded-helpers.js";
|
import { resolveBootstrapMaxChars } from "../../agents/pi-embedded-helpers.js";
|
||||||
import { createClawdbotCodingTools } from "../../agents/pi-tools.js";
|
import { createClawdbotCodingTools } from "../../agents/pi-tools.js";
|
||||||
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
|
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
|
||||||
@@ -5,6 +6,7 @@ import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
|
|||||||
import { getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
|
import { getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
|
||||||
import { buildAgentSystemPrompt } from "../../agents/system-prompt.js";
|
import { buildAgentSystemPrompt } from "../../agents/system-prompt.js";
|
||||||
import { buildSystemPromptReport } from "../../agents/system-prompt-report.js";
|
import { buildSystemPromptReport } from "../../agents/system-prompt-report.js";
|
||||||
|
import { buildSystemPromptParams } from "../../agents/system-prompt-params.js";
|
||||||
import { buildToolSummaryMap } from "../../agents/tool-summaries.js";
|
import { buildToolSummaryMap } from "../../agents/tool-summaries.js";
|
||||||
import { resolveBootstrapContextForRun } from "../../agents/bootstrap-files.js";
|
import { resolveBootstrapContextForRun } from "../../agents/bootstrap-files.js";
|
||||||
import type { SessionSystemPromptReport } from "../../config/sessions/types.js";
|
import type { SessionSystemPromptReport } from "../../config/sessions/types.js";
|
||||||
@@ -87,13 +89,21 @@ async function resolveContextReport(
|
|||||||
})();
|
})();
|
||||||
const toolSummaries = buildToolSummaryMap(tools);
|
const toolSummaries = buildToolSummaryMap(tools);
|
||||||
const toolNames = tools.map((t) => t.name);
|
const toolNames = tools.map((t) => t.name);
|
||||||
const runtimeInfo = {
|
const { sessionAgentId } = resolveSessionAgentIds({
|
||||||
host: "unknown",
|
sessionKey: params.sessionKey,
|
||||||
os: "unknown",
|
config: params.cfg,
|
||||||
arch: "unknown",
|
});
|
||||||
node: process.version,
|
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
|
||||||
model: `${params.provider}/${params.model}`,
|
config: params.cfg,
|
||||||
};
|
agentId: sessionAgentId,
|
||||||
|
runtime: {
|
||||||
|
host: "unknown",
|
||||||
|
os: "unknown",
|
||||||
|
arch: "unknown",
|
||||||
|
node: process.version,
|
||||||
|
model: `${params.provider}/${params.model}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
const sandboxInfo = sandboxRuntime.sandboxed
|
const sandboxInfo = sandboxRuntime.sandboxed
|
||||||
? {
|
? {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -116,8 +126,9 @@ async function resolveContextReport(
|
|||||||
toolNames,
|
toolNames,
|
||||||
toolSummaries,
|
toolSummaries,
|
||||||
modelAliasLines: [],
|
modelAliasLines: [],
|
||||||
userTimezone: "",
|
userTimezone,
|
||||||
userTime: "",
|
userTime,
|
||||||
|
userTimeFormat,
|
||||||
contextFiles: injectedFiles,
|
contextFiles: injectedFiles,
|
||||||
skillsPrompt,
|
skillsPrompt,
|
||||||
heartbeatPrompt: undefined,
|
heartbeatPrompt: undefined,
|
||||||
|
|||||||
@@ -73,6 +73,12 @@ describe("cli program (smoke)", () => {
|
|||||||
expect(statusCommand).toHaveBeenCalled();
|
expect(statusCommand).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("registers memory command", () => {
|
||||||
|
const program = buildProgram();
|
||||||
|
const names = program.commands.map((command) => command.name());
|
||||||
|
expect(names).toContain("memory");
|
||||||
|
});
|
||||||
|
|
||||||
it("runs tui without overriding timeout", async () => {
|
it("runs tui without overriding timeout", async () => {
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
await program.parseAsync(["tui"], { from: "user" });
|
await program.parseAsync(["tui"], { from: "user" });
|
||||||
|
|||||||
@@ -1,17 +1,8 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { registerBrowserCli } from "../browser-cli.js";
|
|
||||||
import { registerConfigCli } from "../config-cli.js";
|
|
||||||
import { createProgramContext } from "./context.js";
|
import { createProgramContext } from "./context.js";
|
||||||
|
import { registerProgramCommands } from "./command-registry.js";
|
||||||
import { configureProgramHelp } from "./help.js";
|
import { configureProgramHelp } from "./help.js";
|
||||||
import { registerPreActionHooks } from "./preaction.js";
|
import { registerPreActionHooks } from "./preaction.js";
|
||||||
import { registerAgentCommands } from "./register.agent.js";
|
|
||||||
import { registerConfigureCommand } from "./register.configure.js";
|
|
||||||
import { registerMaintenanceCommands } from "./register.maintenance.js";
|
|
||||||
import { registerMessageCommands } from "./register.message.js";
|
|
||||||
import { registerOnboardCommand } from "./register.onboard.js";
|
|
||||||
import { registerSetupCommand } from "./register.setup.js";
|
|
||||||
import { registerStatusHealthSessionsCommands } from "./register.status-health-sessions.js";
|
|
||||||
import { registerSubCliCommands } from "./register.subclis.js";
|
|
||||||
|
|
||||||
export function buildProgram() {
|
export function buildProgram() {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
@@ -21,18 +12,7 @@ export function buildProgram() {
|
|||||||
configureProgramHelp(program, ctx);
|
configureProgramHelp(program, ctx);
|
||||||
registerPreActionHooks(program, ctx.programVersion);
|
registerPreActionHooks(program, ctx.programVersion);
|
||||||
|
|
||||||
registerSetupCommand(program);
|
registerProgramCommands(program, ctx, argv);
|
||||||
registerOnboardCommand(program);
|
|
||||||
registerConfigureCommand(program);
|
|
||||||
registerConfigCli(program);
|
|
||||||
registerMaintenanceCommands(program);
|
|
||||||
registerMessageCommands(program, ctx);
|
|
||||||
registerAgentCommands(program, {
|
|
||||||
agentChannelOptions: ctx.agentChannelOptions,
|
|
||||||
});
|
|
||||||
registerSubCliCommands(program, argv);
|
|
||||||
registerStatusHealthSessionsCommands(program);
|
|
||||||
registerBrowserCli(program);
|
|
||||||
|
|
||||||
return program;
|
return program;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import type { Command } from "commander";
|
||||||
|
|
||||||
|
import { agentsListCommand } from "../../commands/agents.js";
|
||||||
|
import { healthCommand } from "../../commands/health.js";
|
||||||
|
import { sessionsCommand } from "../../commands/sessions.js";
|
||||||
|
import { statusCommand } from "../../commands/status.js";
|
||||||
|
import { setVerbose } from "../../globals.js";
|
||||||
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
|
import {
|
||||||
|
getFlagValue,
|
||||||
|
getPositiveIntFlagValue,
|
||||||
|
getVerboseFlag,
|
||||||
|
hasFlag,
|
||||||
|
} from "../argv.js";
|
||||||
|
import { registerBrowserCli } from "../browser-cli.js";
|
||||||
|
import { registerConfigCli } from "../config-cli.js";
|
||||||
|
import { registerMemoryCli, runMemoryStatus } from "../memory-cli.js";
|
||||||
|
import { registerAgentCommands } from "./register.agent.js";
|
||||||
|
import { registerConfigureCommand } from "./register.configure.js";
|
||||||
|
import { registerMaintenanceCommands } from "./register.maintenance.js";
|
||||||
|
import { registerMessageCommands } from "./register.message.js";
|
||||||
|
import { registerOnboardCommand } from "./register.onboard.js";
|
||||||
|
import { registerSetupCommand } from "./register.setup.js";
|
||||||
|
import { registerStatusHealthSessionsCommands } from "./register.status-health-sessions.js";
|
||||||
|
import { registerSubCliCommands } from "./register.subclis.js";
|
||||||
|
import type { ProgramContext } from "./context.js";
|
||||||
|
|
||||||
|
type CommandRegisterParams = {
|
||||||
|
program: Command;
|
||||||
|
ctx: ProgramContext;
|
||||||
|
argv: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type RouteSpec = {
|
||||||
|
match: (path: string[]) => boolean;
|
||||||
|
loadPlugins?: boolean;
|
||||||
|
run: (argv: string[]) => Promise<boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CommandRegistration = {
|
||||||
|
id: string;
|
||||||
|
register: (params: CommandRegisterParams) => void;
|
||||||
|
routes?: RouteSpec[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const routeHealth: RouteSpec = {
|
||||||
|
match: (path) => path[0] === "health",
|
||||||
|
loadPlugins: true,
|
||||||
|
run: async (argv) => {
|
||||||
|
const json = hasFlag(argv, "--json");
|
||||||
|
const verbose = getVerboseFlag(argv, { includeDebug: true });
|
||||||
|
const timeoutMs = getPositiveIntFlagValue(argv, "--timeout");
|
||||||
|
if (timeoutMs === null) return false;
|
||||||
|
setVerbose(verbose);
|
||||||
|
await healthCommand({ json, timeoutMs, verbose }, defaultRuntime);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const routeStatus: RouteSpec = {
|
||||||
|
match: (path) => path[0] === "status",
|
||||||
|
loadPlugins: true,
|
||||||
|
run: async (argv) => {
|
||||||
|
const json = hasFlag(argv, "--json");
|
||||||
|
const deep = hasFlag(argv, "--deep");
|
||||||
|
const all = hasFlag(argv, "--all");
|
||||||
|
const usage = hasFlag(argv, "--usage");
|
||||||
|
const verbose = getVerboseFlag(argv, { includeDebug: true });
|
||||||
|
const timeoutMs = getPositiveIntFlagValue(argv, "--timeout");
|
||||||
|
if (timeoutMs === null) return false;
|
||||||
|
setVerbose(verbose);
|
||||||
|
await statusCommand({ json, deep, all, usage, timeoutMs, verbose }, defaultRuntime);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const routeSessions: RouteSpec = {
|
||||||
|
match: (path) => path[0] === "sessions",
|
||||||
|
run: async (argv) => {
|
||||||
|
const json = hasFlag(argv, "--json");
|
||||||
|
const verbose = getVerboseFlag(argv);
|
||||||
|
const store = getFlagValue(argv, "--store");
|
||||||
|
if (store === null) return false;
|
||||||
|
const active = getFlagValue(argv, "--active");
|
||||||
|
if (active === null) return false;
|
||||||
|
setVerbose(verbose);
|
||||||
|
await sessionsCommand({ json, store, active }, defaultRuntime);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const routeAgentsList: RouteSpec = {
|
||||||
|
match: (path) => path[0] === "agents" && path[1] === "list",
|
||||||
|
run: async (argv) => {
|
||||||
|
const json = hasFlag(argv, "--json");
|
||||||
|
const bindings = hasFlag(argv, "--bindings");
|
||||||
|
await agentsListCommand({ json, bindings }, defaultRuntime);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const routeMemoryStatus: RouteSpec = {
|
||||||
|
match: (path) => path[0] === "memory" && path[1] === "status",
|
||||||
|
run: async (argv) => {
|
||||||
|
const agent = getFlagValue(argv, "--agent");
|
||||||
|
if (agent === null) return false;
|
||||||
|
const json = hasFlag(argv, "--json");
|
||||||
|
const deep = hasFlag(argv, "--deep");
|
||||||
|
const index = hasFlag(argv, "--index");
|
||||||
|
const verbose = hasFlag(argv, "--verbose");
|
||||||
|
await runMemoryStatus({ agent, json, deep, index, verbose });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const commandRegistry: CommandRegistration[] = [
|
||||||
|
{
|
||||||
|
id: "setup",
|
||||||
|
register: ({ program }) => registerSetupCommand(program),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "onboard",
|
||||||
|
register: ({ program }) => registerOnboardCommand(program),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "configure",
|
||||||
|
register: ({ program }) => registerConfigureCommand(program),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "config",
|
||||||
|
register: ({ program }) => registerConfigCli(program),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "maintenance",
|
||||||
|
register: ({ program }) => registerMaintenanceCommands(program),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "message",
|
||||||
|
register: ({ program, ctx }) => registerMessageCommands(program, ctx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "memory",
|
||||||
|
register: ({ program }) => registerMemoryCli(program),
|
||||||
|
routes: [routeMemoryStatus],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "agent",
|
||||||
|
register: ({ program, ctx }) =>
|
||||||
|
registerAgentCommands(program, { agentChannelOptions: ctx.agentChannelOptions }),
|
||||||
|
routes: [routeAgentsList],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "subclis",
|
||||||
|
register: ({ program, argv }) => registerSubCliCommands(program, argv),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status-health-sessions",
|
||||||
|
register: ({ program }) => registerStatusHealthSessionsCommands(program),
|
||||||
|
routes: [routeHealth, routeStatus, routeSessions],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "browser",
|
||||||
|
register: ({ program }) => registerBrowserCli(program),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function registerProgramCommands(
|
||||||
|
program: Command,
|
||||||
|
ctx: ProgramContext,
|
||||||
|
argv: string[] = process.argv,
|
||||||
|
) {
|
||||||
|
for (const entry of commandRegistry) {
|
||||||
|
entry.register({ program, ctx, argv });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findRoutedCommand(path: string[]): RouteSpec | null {
|
||||||
|
for (const entry of commandRegistry) {
|
||||||
|
if (!entry.routes) continue;
|
||||||
|
for (const route of entry.routes) {
|
||||||
|
if (route.match(path)) return route;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
+7
-75
@@ -1,23 +1,11 @@
|
|||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { setVerbose } from "../globals.js";
|
|
||||||
import { healthCommand } from "../commands/health.js";
|
|
||||||
import { statusCommand } from "../commands/status.js";
|
|
||||||
import { sessionsCommand } from "../commands/sessions.js";
|
|
||||||
import { agentsListCommand } from "../commands/agents.js";
|
|
||||||
import { ensurePluginRegistryLoaded } from "./plugin-registry.js";
|
import { ensurePluginRegistryLoaded } from "./plugin-registry.js";
|
||||||
import { isTruthyEnvValue } from "../infra/env.js";
|
import { isTruthyEnvValue } from "../infra/env.js";
|
||||||
import { emitCliBanner } from "./banner.js";
|
import { emitCliBanner } from "./banner.js";
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
import {
|
import { getCommandPath, hasHelpOrVersion } from "./argv.js";
|
||||||
getCommandPath,
|
|
||||||
getFlagValue,
|
|
||||||
getPositiveIntFlagValue,
|
|
||||||
getVerboseFlag,
|
|
||||||
hasFlag,
|
|
||||||
hasHelpOrVersion,
|
|
||||||
} from "./argv.js";
|
|
||||||
import { ensureConfigReady } from "./program/config-guard.js";
|
import { ensureConfigReady } from "./program/config-guard.js";
|
||||||
import { runMemoryStatus } from "./memory-cli.js";
|
import { findRoutedCommand } from "./program/command-registry.js";
|
||||||
|
|
||||||
async function prepareRoutedCommand(params: {
|
async function prepareRoutedCommand(params: {
|
||||||
argv: string[];
|
argv: string[];
|
||||||
@@ -36,65 +24,9 @@ export async function tryRouteCli(argv: string[]): Promise<boolean> {
|
|||||||
if (hasHelpOrVersion(argv)) return false;
|
if (hasHelpOrVersion(argv)) return false;
|
||||||
|
|
||||||
const path = getCommandPath(argv, 2);
|
const path = getCommandPath(argv, 2);
|
||||||
const [primary, secondary] = path;
|
if (!path[0]) return false;
|
||||||
if (!primary) return false;
|
const route = findRoutedCommand(path);
|
||||||
if (primary === "health") {
|
if (!route) return false;
|
||||||
await prepareRoutedCommand({ argv, commandPath: path, loadPlugins: true });
|
await prepareRoutedCommand({ argv, commandPath: path, loadPlugins: route.loadPlugins });
|
||||||
const json = hasFlag(argv, "--json");
|
return route.run(argv);
|
||||||
const verbose = getVerboseFlag(argv, { includeDebug: true });
|
|
||||||
const timeoutMs = getPositiveIntFlagValue(argv, "--timeout");
|
|
||||||
if (timeoutMs === null) return false;
|
|
||||||
setVerbose(verbose);
|
|
||||||
await healthCommand({ json, timeoutMs, verbose }, defaultRuntime);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (primary === "status") {
|
|
||||||
await prepareRoutedCommand({ argv, commandPath: path, loadPlugins: true });
|
|
||||||
const json = hasFlag(argv, "--json");
|
|
||||||
const deep = hasFlag(argv, "--deep");
|
|
||||||
const all = hasFlag(argv, "--all");
|
|
||||||
const usage = hasFlag(argv, "--usage");
|
|
||||||
const verbose = getVerboseFlag(argv, { includeDebug: true });
|
|
||||||
const timeoutMs = getPositiveIntFlagValue(argv, "--timeout");
|
|
||||||
if (timeoutMs === null) return false;
|
|
||||||
setVerbose(verbose);
|
|
||||||
await statusCommand({ json, deep, all, usage, timeoutMs, verbose }, defaultRuntime);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (primary === "sessions") {
|
|
||||||
await prepareRoutedCommand({ argv, commandPath: path });
|
|
||||||
const json = hasFlag(argv, "--json");
|
|
||||||
const verbose = getVerboseFlag(argv);
|
|
||||||
const store = getFlagValue(argv, "--store");
|
|
||||||
if (store === null) return false;
|
|
||||||
const active = getFlagValue(argv, "--active");
|
|
||||||
if (active === null) return false;
|
|
||||||
setVerbose(verbose);
|
|
||||||
await sessionsCommand({ json, store, active }, defaultRuntime);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (primary === "agents" && secondary === "list") {
|
|
||||||
await prepareRoutedCommand({ argv, commandPath: path });
|
|
||||||
const json = hasFlag(argv, "--json");
|
|
||||||
const bindings = hasFlag(argv, "--bindings");
|
|
||||||
await agentsListCommand({ json, bindings }, defaultRuntime);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (primary === "memory" && secondary === "status") {
|
|
||||||
await prepareRoutedCommand({ argv, commandPath: path });
|
|
||||||
const agent = getFlagValue(argv, "--agent");
|
|
||||||
if (agent === null) return false;
|
|
||||||
const json = hasFlag(argv, "--json");
|
|
||||||
const deep = hasFlag(argv, "--deep");
|
|
||||||
const index = hasFlag(argv, "--index");
|
|
||||||
const verbose = hasFlag(argv, "--verbose");
|
|
||||||
await runMemoryStatus({ agent, json, deep, index, verbose });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
@@ -68,6 +68,10 @@ describe("doctor legacy state migrations", () => {
|
|||||||
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
|
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
|
||||||
) as Record<string, { sessionId: string }>;
|
) as Record<string, { sessionId: string }>;
|
||||||
expect(store["agent:main:main"]?.sessionId).toBe("b");
|
expect(store["agent:main:main"]?.sessionId).toBe("b");
|
||||||
|
expect(store["agent:main:+1555"]?.sessionId).toBe("a");
|
||||||
|
expect(store["agent:main:+1666"]?.sessionId).toBe("b");
|
||||||
|
expect(store["+1555"]).toBeUndefined();
|
||||||
|
expect(store["+1666"]).toBeUndefined();
|
||||||
expect(store["agent:main:slack:channel:C123"]?.sessionId).toBe("c");
|
expect(store["agent:main:slack:channel:C123"]?.sessionId).toBe("c");
|
||||||
expect(store["agent:main:unknown:group:abc"]?.sessionId).toBe("d");
|
expect(store["agent:main:unknown:group:abc"]?.sessionId).toBe("d");
|
||||||
expect(store["agent:main:subagent:xyz"]?.sessionId).toBe("e");
|
expect(store["agent:main:subagent:xyz"]?.sessionId).toBe("e");
|
||||||
@@ -229,4 +233,73 @@ describe("doctor legacy state migrations", () => {
|
|||||||
expect(store["agent:main:work"]?.sessionId).toBe("b");
|
expect(store["agent:main:work"]?.sessionId).toBe("b");
|
||||||
expect(store["agent:main:main"]).toBeUndefined();
|
expect(store["agent:main:main"]).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("canonicalizes legacy main keys inside the target sessions store", async () => {
|
||||||
|
const root = await makeTempRoot();
|
||||||
|
const cfg: ClawdbotConfig = {};
|
||||||
|
const targetDir = path.join(root, "agents", "main", "sessions");
|
||||||
|
writeJson5(path.join(targetDir, "sessions.json"), {
|
||||||
|
main: { sessionId: "legacy", updatedAt: 10 },
|
||||||
|
"agent:main:main": { sessionId: "fresh", updatedAt: 20 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const detected = await detectLegacyStateMigrations({
|
||||||
|
cfg,
|
||||||
|
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
|
||||||
|
});
|
||||||
|
await runLegacyStateMigrations({ detected, now: () => 123 });
|
||||||
|
|
||||||
|
const store = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
|
||||||
|
) as Record<string, { sessionId: string }>;
|
||||||
|
expect(store["main"]).toBeUndefined();
|
||||||
|
expect(store["agent:main:main"]?.sessionId).toBe("fresh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the newest entry when collapsing main aliases", async () => {
|
||||||
|
const root = await makeTempRoot();
|
||||||
|
const cfg: ClawdbotConfig = { session: { mainKey: "work" } };
|
||||||
|
const targetDir = path.join(root, "agents", "main", "sessions");
|
||||||
|
writeJson5(path.join(targetDir, "sessions.json"), {
|
||||||
|
"agent:main:main": { sessionId: "legacy", updatedAt: 50 },
|
||||||
|
"agent:main:work": { sessionId: "canonical", updatedAt: 10 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const detected = await detectLegacyStateMigrations({
|
||||||
|
cfg,
|
||||||
|
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
|
||||||
|
});
|
||||||
|
await runLegacyStateMigrations({ detected, now: () => 123 });
|
||||||
|
|
||||||
|
const store = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
|
||||||
|
) as Record<string, { sessionId: string }>;
|
||||||
|
expect(store["agent:main:work"]?.sessionId).toBe("legacy");
|
||||||
|
expect(store["agent:main:main"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-migrates when only target sessions contain legacy keys", async () => {
|
||||||
|
const root = await makeTempRoot();
|
||||||
|
const cfg: ClawdbotConfig = {};
|
||||||
|
const targetDir = path.join(root, "agents", "main", "sessions");
|
||||||
|
writeJson5(path.join(targetDir, "sessions.json"), {
|
||||||
|
main: { sessionId: "legacy", updatedAt: 10 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const log = { info: vi.fn(), warn: vi.fn() };
|
||||||
|
|
||||||
|
const result = await autoMigrateLegacyState({
|
||||||
|
cfg,
|
||||||
|
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
|
||||||
|
log,
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
|
||||||
|
) as Record<string, { sessionId: string }>;
|
||||||
|
expect(result.migrated).toBe(true);
|
||||||
|
expect(log.info).toHaveBeenCalled();
|
||||||
|
expect(store["main"]).toBeUndefined();
|
||||||
|
expect(store["agent:main:main"]?.sessionId).toBe("legacy");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -295,6 +295,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
|||||||
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
|
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
|
||||||
targetAgentId: "main",
|
targetAgentId: "main",
|
||||||
targetMainKey: "main",
|
targetMainKey: "main",
|
||||||
|
targetScope: undefined,
|
||||||
stateDir: "/tmp/state",
|
stateDir: "/tmp/state",
|
||||||
oauthDir: "/tmp/oauth",
|
oauthDir: "/tmp/oauth",
|
||||||
sessions: {
|
sessions: {
|
||||||
@@ -303,6 +304,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
|||||||
targetDir: "/tmp/state/agents/main/sessions",
|
targetDir: "/tmp/state/agents/main/sessions",
|
||||||
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
||||||
hasLegacy: false,
|
hasLegacy: false,
|
||||||
|
legacyKeys: [],
|
||||||
},
|
},
|
||||||
agentDir: {
|
agentDir: {
|
||||||
legacyDir: "/tmp/state/agent",
|
legacyDir: "/tmp/state/agent",
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
|||||||
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
|
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
|
||||||
targetAgentId: "main",
|
targetAgentId: "main",
|
||||||
targetMainKey: "main",
|
targetMainKey: "main",
|
||||||
|
targetScope: undefined,
|
||||||
stateDir: "/tmp/state",
|
stateDir: "/tmp/state",
|
||||||
oauthDir: "/tmp/oauth",
|
oauthDir: "/tmp/oauth",
|
||||||
sessions: {
|
sessions: {
|
||||||
@@ -302,6 +303,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
|||||||
targetDir: "/tmp/state/agents/main/sessions",
|
targetDir: "/tmp/state/agents/main/sessions",
|
||||||
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
||||||
hasLegacy: false,
|
hasLegacy: false,
|
||||||
|
legacyKeys: [],
|
||||||
},
|
},
|
||||||
agentDir: {
|
agentDir: {
|
||||||
legacyDir: "/tmp/state/agent",
|
legacyDir: "/tmp/state/agent",
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
|||||||
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
|
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
|
||||||
targetAgentId: "main",
|
targetAgentId: "main",
|
||||||
targetMainKey: "main",
|
targetMainKey: "main",
|
||||||
|
targetScope: undefined,
|
||||||
stateDir: "/tmp/state",
|
stateDir: "/tmp/state",
|
||||||
oauthDir: "/tmp/oauth",
|
oauthDir: "/tmp/oauth",
|
||||||
sessions: {
|
sessions: {
|
||||||
@@ -302,6 +303,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
|||||||
targetDir: "/tmp/state/agents/main/sessions",
|
targetDir: "/tmp/state/agents/main/sessions",
|
||||||
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
||||||
hasLegacy: false,
|
hasLegacy: false,
|
||||||
|
legacyKeys: [],
|
||||||
},
|
},
|
||||||
agentDir: {
|
agentDir: {
|
||||||
legacyDir: "/tmp/state/agent",
|
legacyDir: "/tmp/state/agent",
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
|||||||
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
|
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
|
||||||
targetAgentId: "main",
|
targetAgentId: "main",
|
||||||
targetMainKey: "main",
|
targetMainKey: "main",
|
||||||
|
targetScope: undefined,
|
||||||
stateDir: "/tmp/state",
|
stateDir: "/tmp/state",
|
||||||
oauthDir: "/tmp/oauth",
|
oauthDir: "/tmp/oauth",
|
||||||
sessions: {
|
sessions: {
|
||||||
@@ -302,6 +303,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
|||||||
targetDir: "/tmp/state/agents/main/sessions",
|
targetDir: "/tmp/state/agents/main/sessions",
|
||||||
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
||||||
hasLegacy: false,
|
hasLegacy: false,
|
||||||
|
legacyKeys: [],
|
||||||
},
|
},
|
||||||
agentDir: {
|
agentDir: {
|
||||||
legacyDir: "/tmp/state/agent",
|
legacyDir: "/tmp/state/agent",
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
|||||||
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
|
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
|
||||||
targetAgentId: "main",
|
targetAgentId: "main",
|
||||||
targetMainKey: "main",
|
targetMainKey: "main",
|
||||||
|
targetScope: undefined,
|
||||||
stateDir: "/tmp/state",
|
stateDir: "/tmp/state",
|
||||||
oauthDir: "/tmp/oauth",
|
oauthDir: "/tmp/oauth",
|
||||||
sessions: {
|
sessions: {
|
||||||
@@ -302,6 +303,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
|||||||
targetDir: "/tmp/state/agents/main/sessions",
|
targetDir: "/tmp/state/agents/main/sessions",
|
||||||
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
||||||
hasLegacy: false,
|
hasLegacy: false,
|
||||||
|
legacyKeys: [],
|
||||||
},
|
},
|
||||||
agentDir: {
|
agentDir: {
|
||||||
legacyDir: "/tmp/state/agent",
|
legacyDir: "/tmp/state/agent",
|
||||||
|
|||||||
+157
-20
@@ -6,6 +6,7 @@ import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
|||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
|
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
|
||||||
import type { SessionEntry } from "../config/sessions.js";
|
import type { SessionEntry } from "../config/sessions.js";
|
||||||
|
import type { SessionScope } from "../config/sessions/types.js";
|
||||||
import { saveSessionStore } from "../config/sessions.js";
|
import { saveSessionStore } from "../config/sessions.js";
|
||||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
DEFAULT_MAIN_KEY,
|
DEFAULT_MAIN_KEY,
|
||||||
normalizeAgentId,
|
normalizeAgentId,
|
||||||
} from "../routing/session-key.js";
|
} from "../routing/session-key.js";
|
||||||
|
import { canonicalizeMainSessionAlias } from "../config/sessions/main-session.js";
|
||||||
import {
|
import {
|
||||||
ensureDir,
|
ensureDir,
|
||||||
existsDir,
|
existsDir,
|
||||||
@@ -27,6 +29,7 @@ import {
|
|||||||
export type LegacyStateDetection = {
|
export type LegacyStateDetection = {
|
||||||
targetAgentId: string;
|
targetAgentId: string;
|
||||||
targetMainKey: string;
|
targetMainKey: string;
|
||||||
|
targetScope?: SessionScope;
|
||||||
stateDir: string;
|
stateDir: string;
|
||||||
oauthDir: string;
|
oauthDir: string;
|
||||||
sessions: {
|
sessions: {
|
||||||
@@ -35,6 +38,7 @@ export type LegacyStateDetection = {
|
|||||||
targetDir: string;
|
targetDir: string;
|
||||||
targetStorePath: string;
|
targetStorePath: string;
|
||||||
hasLegacy: boolean;
|
hasLegacy: boolean;
|
||||||
|
legacyKeys: string[];
|
||||||
};
|
};
|
||||||
agentDir: {
|
agentDir: {
|
||||||
legacyDir: string;
|
legacyDir: string;
|
||||||
@@ -72,34 +76,49 @@ function isLegacyGroupKey(key: string): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSessionKeyForAgent(key: string, agentId: string): string {
|
function canonicalizeSessionKeyForAgent(params: {
|
||||||
const raw = key.trim();
|
key: string;
|
||||||
|
agentId: string;
|
||||||
|
mainKey: string;
|
||||||
|
scope?: SessionScope;
|
||||||
|
}): string {
|
||||||
|
const agentId = normalizeAgentId(params.agentId);
|
||||||
|
const raw = params.key.trim();
|
||||||
if (!raw) return raw;
|
if (!raw) return raw;
|
||||||
|
if (raw === "global" || raw === "unknown") return raw;
|
||||||
|
|
||||||
|
const canonicalMain = canonicalizeMainSessionAlias({
|
||||||
|
cfg: { session: { scope: params.scope, mainKey: params.mainKey } },
|
||||||
|
agentId,
|
||||||
|
sessionKey: raw,
|
||||||
|
});
|
||||||
|
if (canonicalMain !== raw) return canonicalMain;
|
||||||
|
|
||||||
if (raw.startsWith("agent:")) return raw;
|
if (raw.startsWith("agent:")) return raw;
|
||||||
if (raw.toLowerCase().startsWith("subagent:")) {
|
if (raw.toLowerCase().startsWith("subagent:")) {
|
||||||
const rest = raw.slice("subagent:".length);
|
const rest = raw.slice("subagent:".length);
|
||||||
return `agent:${normalizeAgentId(agentId)}:subagent:${rest}`;
|
return `agent:${agentId}:subagent:${rest}`;
|
||||||
}
|
}
|
||||||
if (raw.startsWith("group:")) {
|
if (raw.startsWith("group:")) {
|
||||||
const id = raw.slice("group:".length).trim();
|
const id = raw.slice("group:".length).trim();
|
||||||
if (!id) return raw;
|
if (!id) return raw;
|
||||||
const channel = id.toLowerCase().includes("@g.us") ? "whatsapp" : "unknown";
|
const channel = id.toLowerCase().includes("@g.us") ? "whatsapp" : "unknown";
|
||||||
return `agent:${normalizeAgentId(agentId)}:${channel}:group:${id}`;
|
return `agent:${agentId}:${channel}:group:${id}`;
|
||||||
}
|
}
|
||||||
if (!raw.includes(":") && raw.toLowerCase().includes("@g.us")) {
|
if (!raw.includes(":") && raw.toLowerCase().includes("@g.us")) {
|
||||||
return `agent:${normalizeAgentId(agentId)}:whatsapp:group:${raw}`;
|
return `agent:${agentId}:whatsapp:group:${raw}`;
|
||||||
}
|
}
|
||||||
if (raw.toLowerCase().startsWith("whatsapp:") && raw.toLowerCase().includes("@g.us")) {
|
if (raw.toLowerCase().startsWith("whatsapp:") && raw.toLowerCase().includes("@g.us")) {
|
||||||
const remainder = raw.slice("whatsapp:".length).trim();
|
const remainder = raw.slice("whatsapp:".length).trim();
|
||||||
const cleaned = remainder.replace(/^group:/i, "").trim();
|
const cleaned = remainder.replace(/^group:/i, "").trim();
|
||||||
if (cleaned && !isSurfaceGroupKey(raw)) {
|
if (cleaned && !isSurfaceGroupKey(raw)) {
|
||||||
return `agent:${normalizeAgentId(agentId)}:whatsapp:group:${cleaned}`;
|
return `agent:${agentId}:whatsapp:group:${cleaned}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isSurfaceGroupKey(raw)) {
|
if (isSurfaceGroupKey(raw)) {
|
||||||
return `agent:${normalizeAgentId(agentId)}:${raw}`;
|
return `agent:${agentId}:${raw}`;
|
||||||
}
|
}
|
||||||
return raw;
|
return `agent:${agentId}:${raw}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickLatestLegacyDirectEntry(
|
function pickLatestLegacyDirectEntry(
|
||||||
@@ -140,6 +159,91 @@ function normalizeSessionEntry(entry: SessionEntryLike): SessionEntry | null {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveUpdatedAt(entry: SessionEntryLike): number {
|
||||||
|
return typeof entry.updatedAt === "number" && Number.isFinite(entry.updatedAt)
|
||||||
|
? entry.updatedAt
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeSessionEntry(params: {
|
||||||
|
existing: SessionEntryLike | undefined;
|
||||||
|
incoming: SessionEntryLike;
|
||||||
|
preferIncomingOnTie?: boolean;
|
||||||
|
}): SessionEntryLike {
|
||||||
|
if (!params.existing) return params.incoming;
|
||||||
|
const existingUpdated = resolveUpdatedAt(params.existing);
|
||||||
|
const incomingUpdated = resolveUpdatedAt(params.incoming);
|
||||||
|
if (incomingUpdated > existingUpdated) return params.incoming;
|
||||||
|
if (incomingUpdated < existingUpdated) return params.existing;
|
||||||
|
return params.preferIncomingOnTie ? params.incoming : params.existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalizeSessionStore(params: {
|
||||||
|
store: Record<string, SessionEntryLike>;
|
||||||
|
agentId: string;
|
||||||
|
mainKey: string;
|
||||||
|
scope?: SessionScope;
|
||||||
|
}): { store: Record<string, SessionEntryLike>; legacyKeys: string[] } {
|
||||||
|
const canonical: Record<string, SessionEntryLike> = {};
|
||||||
|
const meta = new Map<string, { isCanonical: boolean; updatedAt: number }>();
|
||||||
|
const legacyKeys: string[] = [];
|
||||||
|
|
||||||
|
for (const [key, entry] of Object.entries(params.store)) {
|
||||||
|
if (!entry || typeof entry !== "object") continue;
|
||||||
|
const canonicalKey = canonicalizeSessionKeyForAgent({
|
||||||
|
key,
|
||||||
|
agentId: params.agentId,
|
||||||
|
mainKey: params.mainKey,
|
||||||
|
scope: params.scope,
|
||||||
|
});
|
||||||
|
const isCanonical = canonicalKey === key;
|
||||||
|
if (!isCanonical) legacyKeys.push(key);
|
||||||
|
const existing = canonical[canonicalKey];
|
||||||
|
if (!existing) {
|
||||||
|
canonical[canonicalKey] = entry;
|
||||||
|
meta.set(canonicalKey, { isCanonical, updatedAt: resolveUpdatedAt(entry) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingMeta = meta.get(canonicalKey);
|
||||||
|
const incomingUpdated = resolveUpdatedAt(entry);
|
||||||
|
const existingUpdated = existingMeta?.updatedAt ?? resolveUpdatedAt(existing);
|
||||||
|
if (incomingUpdated > existingUpdated) {
|
||||||
|
canonical[canonicalKey] = entry;
|
||||||
|
meta.set(canonicalKey, { isCanonical, updatedAt: incomingUpdated });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (incomingUpdated < existingUpdated) continue;
|
||||||
|
if (existingMeta?.isCanonical && !isCanonical) continue;
|
||||||
|
if (!existingMeta?.isCanonical && isCanonical) {
|
||||||
|
canonical[canonicalKey] = entry;
|
||||||
|
meta.set(canonicalKey, { isCanonical, updatedAt: incomingUpdated });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { store: canonical, legacyKeys };
|
||||||
|
}
|
||||||
|
|
||||||
|
function listLegacySessionKeys(params: {
|
||||||
|
store: Record<string, SessionEntryLike>;
|
||||||
|
agentId: string;
|
||||||
|
mainKey: string;
|
||||||
|
scope?: SessionScope;
|
||||||
|
}): string[] {
|
||||||
|
const legacy: string[] = [];
|
||||||
|
for (const key of Object.keys(params.store)) {
|
||||||
|
const canonical = canonicalizeSessionKeyForAgent({
|
||||||
|
key,
|
||||||
|
agentId: params.agentId,
|
||||||
|
mainKey: params.mainKey,
|
||||||
|
scope: params.scope,
|
||||||
|
});
|
||||||
|
if (canonical !== key) legacy.push(key);
|
||||||
|
}
|
||||||
|
return legacy;
|
||||||
|
}
|
||||||
|
|
||||||
function emptyDirOrMissing(dir: string): boolean {
|
function emptyDirOrMissing(dir: string): boolean {
|
||||||
if (!existsDir(dir)) return true;
|
if (!existsDir(dir)) return true;
|
||||||
return safeReadDir(dir).length === 0;
|
return safeReadDir(dir).length === 0;
|
||||||
@@ -179,6 +283,7 @@ export async function detectLegacyStateMigrations(params: {
|
|||||||
typeof rawMainKey === "string" && rawMainKey.trim().length > 0
|
typeof rawMainKey === "string" && rawMainKey.trim().length > 0
|
||||||
? rawMainKey.trim()
|
? rawMainKey.trim()
|
||||||
: DEFAULT_MAIN_KEY;
|
: DEFAULT_MAIN_KEY;
|
||||||
|
const targetScope = params.cfg.session?.scope;
|
||||||
|
|
||||||
const sessionsLegacyDir = path.join(stateDir, "sessions");
|
const sessionsLegacyDir = path.join(stateDir, "sessions");
|
||||||
const sessionsLegacyStorePath = path.join(sessionsLegacyDir, "sessions.json");
|
const sessionsLegacyStorePath = path.join(sessionsLegacyDir, "sessions.json");
|
||||||
@@ -189,6 +294,18 @@ export async function detectLegacyStateMigrations(params: {
|
|||||||
fileExists(sessionsLegacyStorePath) ||
|
fileExists(sessionsLegacyStorePath) ||
|
||||||
legacySessionEntries.some((e) => e.isFile() && e.name.endsWith(".jsonl"));
|
legacySessionEntries.some((e) => e.isFile() && e.name.endsWith(".jsonl"));
|
||||||
|
|
||||||
|
const targetSessionParsed = fileExists(sessionsTargetStorePath)
|
||||||
|
? readSessionStoreJson5(sessionsTargetStorePath)
|
||||||
|
: { store: {}, ok: true };
|
||||||
|
const legacyKeys = targetSessionParsed.ok
|
||||||
|
? listLegacySessionKeys({
|
||||||
|
store: targetSessionParsed.store,
|
||||||
|
agentId: targetAgentId,
|
||||||
|
mainKey: targetMainKey,
|
||||||
|
scope: targetScope,
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
const legacyAgentDir = path.join(stateDir, "agent");
|
const legacyAgentDir = path.join(stateDir, "agent");
|
||||||
const targetAgentDir = path.join(stateDir, "agents", targetAgentId, "agent");
|
const targetAgentDir = path.join(stateDir, "agents", targetAgentId, "agent");
|
||||||
const hasLegacyAgentDir = existsDir(legacyAgentDir);
|
const hasLegacyAgentDir = existsDir(legacyAgentDir);
|
||||||
@@ -202,6 +319,9 @@ export async function detectLegacyStateMigrations(params: {
|
|||||||
if (hasLegacySessions) {
|
if (hasLegacySessions) {
|
||||||
preview.push(`- Sessions: ${sessionsLegacyDir} → ${sessionsTargetDir}`);
|
preview.push(`- Sessions: ${sessionsLegacyDir} → ${sessionsTargetDir}`);
|
||||||
}
|
}
|
||||||
|
if (legacyKeys.length > 0) {
|
||||||
|
preview.push(`- Sessions: canonicalize legacy keys in ${sessionsTargetStorePath}`);
|
||||||
|
}
|
||||||
if (hasLegacyAgentDir) {
|
if (hasLegacyAgentDir) {
|
||||||
preview.push(`- Agent dir: ${legacyAgentDir} → ${targetAgentDir}`);
|
preview.push(`- Agent dir: ${legacyAgentDir} → ${targetAgentDir}`);
|
||||||
}
|
}
|
||||||
@@ -212,6 +332,7 @@ export async function detectLegacyStateMigrations(params: {
|
|||||||
return {
|
return {
|
||||||
targetAgentId,
|
targetAgentId,
|
||||||
targetMainKey,
|
targetMainKey,
|
||||||
|
targetScope,
|
||||||
stateDir,
|
stateDir,
|
||||||
oauthDir,
|
oauthDir,
|
||||||
sessions: {
|
sessions: {
|
||||||
@@ -219,7 +340,8 @@ export async function detectLegacyStateMigrations(params: {
|
|||||||
legacyStorePath: sessionsLegacyStorePath,
|
legacyStorePath: sessionsLegacyStorePath,
|
||||||
targetDir: sessionsTargetDir,
|
targetDir: sessionsTargetDir,
|
||||||
targetStorePath: sessionsTargetStorePath,
|
targetStorePath: sessionsTargetStorePath,
|
||||||
hasLegacy: hasLegacySessions,
|
hasLegacy: hasLegacySessions || legacyKeys.length > 0,
|
||||||
|
legacyKeys,
|
||||||
},
|
},
|
||||||
agentDir: {
|
agentDir: {
|
||||||
legacyDir: legacyAgentDir,
|
legacyDir: legacyAgentDir,
|
||||||
@@ -254,17 +376,27 @@ async function migrateLegacySessions(
|
|||||||
const legacyStore = legacyParsed.store;
|
const legacyStore = legacyParsed.store;
|
||||||
const targetStore = targetParsed.store;
|
const targetStore = targetParsed.store;
|
||||||
|
|
||||||
const normalizedLegacy: Record<string, SessionEntryLike> = {};
|
const canonicalizedTarget = canonicalizeSessionStore({
|
||||||
for (const [key, entry] of Object.entries(legacyStore)) {
|
store: targetStore,
|
||||||
const nextKey = normalizeSessionKeyForAgent(key, detected.targetAgentId);
|
agentId: detected.targetAgentId,
|
||||||
if (!nextKey) continue;
|
mainKey: detected.targetMainKey,
|
||||||
if (!normalizedLegacy[nextKey]) normalizedLegacy[nextKey] = entry;
|
scope: detected.targetScope,
|
||||||
}
|
});
|
||||||
|
const canonicalizedLegacy = canonicalizeSessionStore({
|
||||||
|
store: legacyStore,
|
||||||
|
agentId: detected.targetAgentId,
|
||||||
|
mainKey: detected.targetMainKey,
|
||||||
|
scope: detected.targetScope,
|
||||||
|
});
|
||||||
|
|
||||||
const merged: Record<string, SessionEntryLike> = {
|
const merged: Record<string, SessionEntryLike> = { ...canonicalizedTarget.store };
|
||||||
...normalizedLegacy,
|
for (const [key, entry] of Object.entries(canonicalizedLegacy.store)) {
|
||||||
...targetStore,
|
merged[key] = mergeSessionEntry({
|
||||||
};
|
existing: merged[key],
|
||||||
|
incoming: entry,
|
||||||
|
preferIncomingOnTie: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const mainKey = buildAgentMainSessionKey({
|
const mainKey = buildAgentMainSessionKey({
|
||||||
agentId: detected.targetAgentId,
|
agentId: detected.targetAgentId,
|
||||||
@@ -285,7 +417,7 @@ async function migrateLegacySessions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
legacyParsed.ok &&
|
(legacyParsed.ok || targetParsed.ok) &&
|
||||||
(Object.keys(legacyStore).length > 0 || Object.keys(targetStore).length > 0)
|
(Object.keys(legacyStore).length > 0 || Object.keys(targetStore).length > 0)
|
||||||
) {
|
) {
|
||||||
const normalized: Record<string, SessionEntry> = {};
|
const normalized: Record<string, SessionEntry> = {};
|
||||||
@@ -296,6 +428,11 @@ async function migrateLegacySessions(
|
|||||||
}
|
}
|
||||||
await saveSessionStore(detected.sessions.targetStorePath, normalized);
|
await saveSessionStore(detected.sessions.targetStorePath, normalized);
|
||||||
changes.push(`Merged sessions store → ${detected.sessions.targetStorePath}`);
|
changes.push(`Merged sessions store → ${detected.sessions.targetStorePath}`);
|
||||||
|
if (canonicalizedTarget.legacyKeys.length > 0) {
|
||||||
|
changes.push(
|
||||||
|
`Canonicalized ${canonicalizedTarget.legacyKeys.length} legacy session key(s)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = safeReadDir(detected.sessions.legacyDir);
|
const entries = safeReadDir(detected.sessions.legacyDir);
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ describe("SearchableSelectList", () => {
|
|||||||
list.handleInput("z");
|
list.handleInput("z");
|
||||||
|
|
||||||
const output = list.render(80);
|
const output = list.render(80);
|
||||||
expect(output.some((line) => line.includes("No matching"))).toBe(true);
|
expect(output.some((line) => line.includes("No matches"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("navigates with arrow keys", () => {
|
it("navigates with arrow keys", () => {
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ export class SearchableSelectList implements Component {
|
|||||||
|
|
||||||
// If no items match filter, show message
|
// If no items match filter, show message
|
||||||
if (this.filteredItems.length === 0) {
|
if (this.filteredItems.length === 0) {
|
||||||
lines.push(this.theme.noMatch(" No matching models"));
|
lines.push(this.theme.noMatch(" No matches"));
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import chalk from "chalk";
|
||||||
|
|
||||||
|
type HighlightTheme = Record<string, (text: string) => string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syntax highlighting theme for code blocks.
|
||||||
|
* Uses chalk functions to style different token types.
|
||||||
|
*/
|
||||||
|
export function createSyntaxTheme(fallback: (text: string) => string): HighlightTheme {
|
||||||
|
return {
|
||||||
|
keyword: chalk.hex("#C586C0"), // purple - if, const, function, etc.
|
||||||
|
built_in: chalk.hex("#4EC9B0"), // teal - console, Math, etc.
|
||||||
|
type: chalk.hex("#4EC9B0"), // teal - types
|
||||||
|
literal: chalk.hex("#569CD6"), // blue - true, false, null
|
||||||
|
number: chalk.hex("#B5CEA8"), // green - numbers
|
||||||
|
string: chalk.hex("#CE9178"), // orange - strings
|
||||||
|
regexp: chalk.hex("#D16969"), // red - regex
|
||||||
|
symbol: chalk.hex("#B5CEA8"), // green - symbols
|
||||||
|
class: chalk.hex("#4EC9B0"), // teal - class names
|
||||||
|
function: chalk.hex("#DCDCAA"), // yellow - function names
|
||||||
|
title: chalk.hex("#DCDCAA"), // yellow - titles/names
|
||||||
|
params: chalk.hex("#9CDCFE"), // light blue - parameters
|
||||||
|
comment: chalk.hex("#6A9955"), // green - comments
|
||||||
|
doctag: chalk.hex("#608B4E"), // darker green - jsdoc tags
|
||||||
|
meta: chalk.hex("#9CDCFE"), // light blue - meta/preprocessor
|
||||||
|
"meta-keyword": chalk.hex("#C586C0"), // purple
|
||||||
|
"meta-string": chalk.hex("#CE9178"), // orange
|
||||||
|
section: chalk.hex("#DCDCAA"), // yellow - sections
|
||||||
|
tag: chalk.hex("#569CD6"), // blue - HTML/XML tags
|
||||||
|
name: chalk.hex("#9CDCFE"), // light blue - tag names
|
||||||
|
attr: chalk.hex("#9CDCFE"), // light blue - attributes
|
||||||
|
attribute: chalk.hex("#9CDCFE"), // light blue - attributes
|
||||||
|
variable: chalk.hex("#9CDCFE"), // light blue - variables
|
||||||
|
bullet: chalk.hex("#D7BA7D"), // gold - list bullets in markdown
|
||||||
|
code: chalk.hex("#CE9178"), // orange - inline code
|
||||||
|
emphasis: chalk.italic, // italic
|
||||||
|
strong: chalk.bold, // bold
|
||||||
|
formula: chalk.hex("#C586C0"), // purple - math
|
||||||
|
link: chalk.hex("#4EC9B0"), // teal - links
|
||||||
|
quote: chalk.hex("#6A9955"), // green - quotes
|
||||||
|
addition: chalk.hex("#B5CEA8"), // green - diff additions
|
||||||
|
deletion: chalk.hex("#F44747"), // red - diff deletions
|
||||||
|
"selector-tag": chalk.hex("#D7BA7D"), // gold - CSS selectors
|
||||||
|
"selector-id": chalk.hex("#D7BA7D"), // gold
|
||||||
|
"selector-class": chalk.hex("#D7BA7D"), // gold
|
||||||
|
"selector-attr": chalk.hex("#D7BA7D"), // gold
|
||||||
|
"selector-pseudo": chalk.hex("#D7BA7D"), // gold
|
||||||
|
"template-tag": chalk.hex("#C586C0"), // purple
|
||||||
|
"template-variable": chalk.hex("#9CDCFE"), // light blue
|
||||||
|
default: fallback, // fallback to code color
|
||||||
|
};
|
||||||
|
}
|
||||||
+2
-46
@@ -7,6 +7,7 @@ import type {
|
|||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { highlight, supportsLanguage } from "cli-highlight";
|
import { highlight, supportsLanguage } from "cli-highlight";
|
||||||
import type { SearchableSelectListTheme } from "../components/searchable-select-list.js";
|
import type { SearchableSelectListTheme } from "../components/searchable-select-list.js";
|
||||||
|
import { createSyntaxTheme } from "./syntax-theme.js";
|
||||||
|
|
||||||
const palette = {
|
const palette = {
|
||||||
text: "#E8E3D5",
|
text: "#E8E3D5",
|
||||||
@@ -35,52 +36,7 @@ const palette = {
|
|||||||
const fg = (hex: string) => (text: string) => chalk.hex(hex)(text);
|
const fg = (hex: string) => (text: string) => chalk.hex(hex)(text);
|
||||||
const bg = (hex: string) => (text: string) => chalk.bgHex(hex)(text);
|
const bg = (hex: string) => (text: string) => chalk.bgHex(hex)(text);
|
||||||
|
|
||||||
/**
|
const syntaxTheme = createSyntaxTheme(fg(palette.code));
|
||||||
* Syntax highlighting theme for code blocks.
|
|
||||||
* Uses chalk functions to style different token types.
|
|
||||||
*/
|
|
||||||
const syntaxTheme = {
|
|
||||||
keyword: chalk.hex("#C586C0"), // purple - if, const, function, etc.
|
|
||||||
built_in: chalk.hex("#4EC9B0"), // teal - console, Math, etc.
|
|
||||||
type: chalk.hex("#4EC9B0"), // teal - types
|
|
||||||
literal: chalk.hex("#569CD6"), // blue - true, false, null
|
|
||||||
number: chalk.hex("#B5CEA8"), // green - numbers
|
|
||||||
string: chalk.hex("#CE9178"), // orange - strings
|
|
||||||
regexp: chalk.hex("#D16969"), // red - regex
|
|
||||||
symbol: chalk.hex("#B5CEA8"), // green - symbols
|
|
||||||
class: chalk.hex("#4EC9B0"), // teal - class names
|
|
||||||
function: chalk.hex("#DCDCAA"), // yellow - function names
|
|
||||||
title: chalk.hex("#DCDCAA"), // yellow - titles/names
|
|
||||||
params: chalk.hex("#9CDCFE"), // light blue - parameters
|
|
||||||
comment: chalk.hex("#6A9955"), // green - comments
|
|
||||||
doctag: chalk.hex("#608B4E"), // darker green - jsdoc tags
|
|
||||||
meta: chalk.hex("#9CDCFE"), // light blue - meta/preprocessor
|
|
||||||
"meta-keyword": chalk.hex("#C586C0"), // purple
|
|
||||||
"meta-string": chalk.hex("#CE9178"), // orange
|
|
||||||
section: chalk.hex("#DCDCAA"), // yellow - sections
|
|
||||||
tag: chalk.hex("#569CD6"), // blue - HTML/XML tags
|
|
||||||
name: chalk.hex("#9CDCFE"), // light blue - tag names
|
|
||||||
attr: chalk.hex("#9CDCFE"), // light blue - attributes
|
|
||||||
attribute: chalk.hex("#9CDCFE"), // light blue - attributes
|
|
||||||
variable: chalk.hex("#9CDCFE"), // light blue - variables
|
|
||||||
bullet: chalk.hex("#D7BA7D"), // gold - list bullets in markdown
|
|
||||||
code: chalk.hex("#CE9178"), // orange - inline code
|
|
||||||
emphasis: chalk.italic, // italic
|
|
||||||
strong: chalk.bold, // bold
|
|
||||||
formula: chalk.hex("#C586C0"), // purple - math
|
|
||||||
link: chalk.hex("#4EC9B0"), // teal - links
|
|
||||||
quote: chalk.hex("#6A9955"), // green - quotes
|
|
||||||
addition: chalk.hex("#B5CEA8"), // green - diff additions
|
|
||||||
deletion: chalk.hex("#F44747"), // red - diff deletions
|
|
||||||
"selector-tag": chalk.hex("#D7BA7D"), // gold - CSS selectors
|
|
||||||
"selector-id": chalk.hex("#D7BA7D"), // gold
|
|
||||||
"selector-class": chalk.hex("#D7BA7D"), // gold
|
|
||||||
"selector-attr": chalk.hex("#D7BA7D"), // gold
|
|
||||||
"selector-pseudo": chalk.hex("#D7BA7D"), // gold
|
|
||||||
"template-tag": chalk.hex("#C586C0"), // purple
|
|
||||||
"template-variable": chalk.hex("#9CDCFE"), // light blue
|
|
||||||
default: fg(palette.code), // fallback to code color
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Highlight code with syntax coloring.
|
* Highlight code with syntax coloring.
|
||||||
|
|||||||
@@ -7,11 +7,7 @@ import {
|
|||||||
import { normalizeAgentId } from "../routing/session-key.js";
|
import { normalizeAgentId } from "../routing/session-key.js";
|
||||||
import { helpText, parseCommand } from "./commands.js";
|
import { helpText, parseCommand } from "./commands.js";
|
||||||
import type { ChatLog } from "./components/chat-log.js";
|
import type { ChatLog } from "./components/chat-log.js";
|
||||||
import {
|
import { createSearchableSelectList, createSettingsList } from "./components/selectors.js";
|
||||||
createSearchableSelectList,
|
|
||||||
createSelectList,
|
|
||||||
createSettingsList,
|
|
||||||
} from "./components/selectors.js";
|
|
||||||
import type { GatewayChatClient } from "./gateway-chat.js";
|
import type { GatewayChatClient } from "./gateway-chat.js";
|
||||||
import { formatStatusSummary } from "./tui-status-summary.js";
|
import { formatStatusSummary } from "./tui-status-summary.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -117,7 +113,7 @@ export function createCommandHandlers(context: CommandHandlerContext) {
|
|||||||
label: agent.name ? `${agent.id} (${agent.name})` : agent.id,
|
label: agent.name ? `${agent.id} (${agent.name})` : agent.id,
|
||||||
description: agent.id === state.agentDefaultId ? "default" : "",
|
description: agent.id === state.agentDefaultId ? "default" : "",
|
||||||
}));
|
}));
|
||||||
const selector = createSelectList(items, 9);
|
const selector = createSearchableSelectList(items, 9);
|
||||||
selector.onSelect = (item) => {
|
selector.onSelect = (item) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
closeOverlay();
|
closeOverlay();
|
||||||
@@ -147,7 +143,7 @@ export function createCommandHandlers(context: CommandHandlerContext) {
|
|||||||
: formatSessionKey(session.key),
|
: formatSessionKey(session.key),
|
||||||
description: session.updatedAt ? new Date(session.updatedAt).toLocaleString() : "",
|
description: session.updatedAt ? new Date(session.updatedAt).toLocaleString() : "",
|
||||||
}));
|
}));
|
||||||
const selector = createSelectList(items, 9);
|
const selector = createSearchableSelectList(items, 9);
|
||||||
selector.onSelect = (item) => {
|
selector.onSelect = (item) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
closeOverlay();
|
closeOverlay();
|
||||||
|
|||||||
Reference in new issue
Block a user