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.
|
||||
|
||||
## 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)
|
||||
- **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).
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Voice wake forwarding tips:
|
||||
@@ -133,19 +132,3 @@
|
||||
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
|
||||
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
|
||||
- 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.
|
||||
|
||||
### 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.
|
||||
<<<<<<< Updated upstream
|
||||
- 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)
|
||||
>>>>>>> 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
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||
"state" : {
|
||||
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
||||
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||
"state" : {
|
||||
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
||||
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -64,17 +64,24 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
|
||||
/** Storage for TwiML content (for notify mode with URL-based TwiML) */
|
||||
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`.
|
||||
*
|
||||
* 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 {
|
||||
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.
|
||||
*
|
||||
@@ -87,7 +94,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
const callIdMatch = webhookUrl.match(/callId=([^&]+)/);
|
||||
if (!callIdMatch) return;
|
||||
|
||||
this.deleteStoredTwiml(callIdMatch[1]);
|
||||
this.clearNotifyState(callIdMatch[1]);
|
||||
}
|
||||
|
||||
constructor(config: TwilioConfig, options: TwilioProviderOptions = {}) {
|
||||
@@ -137,7 +144,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
*/
|
||||
private async apiRequest<T = unknown>(
|
||||
endpoint: string,
|
||||
params: Record<string, string>,
|
||||
params: Record<string, string | string[]>,
|
||||
options?: { allowNotFound?: boolean },
|
||||
): Promise<T> {
|
||||
return await twilioApiRequest<T>({
|
||||
@@ -257,12 +264,12 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
case "no-answer":
|
||||
case "failed":
|
||||
if (callIdOverride) {
|
||||
this.deleteStoredTwiml(callIdOverride);
|
||||
this.clearNotifyState(callIdOverride);
|
||||
}
|
||||
return { ...baseEvent, type: "call.ended", reason: callStatus };
|
||||
case "canceled":
|
||||
if (callIdOverride) {
|
||||
this.deleteStoredTwiml(callIdOverride);
|
||||
this.clearNotifyState(callIdOverride);
|
||||
}
|
||||
return { ...baseEvent, type: "call.ended", reason: "hangup-bot" };
|
||||
default:
|
||||
@@ -286,6 +293,9 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
if (!ctx) return TwilioProvider.EMPTY_TWIML;
|
||||
|
||||
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 direction = params.get("Direction");
|
||||
const callIdFromQuery =
|
||||
@@ -297,13 +307,21 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
|
||||
// Handle initial TwiML request (when Twilio first initiates the call)
|
||||
// Check if we have stored TwiML for this call (notify mode)
|
||||
if (callIdFromQuery) {
|
||||
if (callIdFromQuery && !isStatusCallback) {
|
||||
const storedTwiml = this.twimlStorage.get(callIdFromQuery);
|
||||
if (storedTwiml) {
|
||||
// Clean up after serving (one-time use)
|
||||
this.deleteStoredTwiml(callIdFromQuery);
|
||||
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.)
|
||||
@@ -385,16 +403,18 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
// We now serve it from the webhook endpoint instead of sending inline
|
||||
if (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.
|
||||
// Twilio silently ignores `StatusCallback` when using the inline `Twiml` parameter.
|
||||
const params: Record<string, string> = {
|
||||
const params: Record<string, string | string[]> = {
|
||||
To: input.to,
|
||||
From: input.from,
|
||||
Url: url.toString(), // TwiML serving endpoint
|
||||
StatusCallback: statusUrl.toString(), // Separate status callback endpoint
|
||||
StatusCallbackEvent: "initiated ringing answered completed",
|
||||
StatusCallbackEvent: ["initiated", "ringing", "answered", "completed"],
|
||||
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;
|
||||
authToken: string;
|
||||
endpoint: string;
|
||||
body: Record<string, string>;
|
||||
body: URLSearchParams | Record<string, string | string[]>;
|
||||
allowNotFound?: boolean;
|
||||
}): 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}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`${params.accountSid}:${params.authToken}`).toString("base64")}`,
|
||||
"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) {
|
||||
@@ -26,4 +44,3 @@ export async function twilioApiRequest<T = unknown>(params: {
|
||||
const text = await response.text();
|
||||
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?");
|
||||
}
|
||||
|
||||
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}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(
|
||||
"tailscale",
|
||||
[config.mode, "--bg", "--yes", "--set-path", config.path, localUrl],
|
||||
[config.mode, "--bg", "--yes", "--set-path", path, localUrl],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
|
||||
@@ -247,7 +249,7 @@ export async function startTailscaleTunnel(config: {
|
||||
proc.on("close", (code) => {
|
||||
clearTimeout(timeout);
|
||||
if (code === 0) {
|
||||
const publicUrl = `https://${dnsName}${config.path}`;
|
||||
const publicUrl = `https://${dnsName}${path}`;
|
||||
console.log(
|
||||
`[voice-call] Tailscale ${config.mode} active: ${publicUrl}`,
|
||||
);
|
||||
@@ -256,7 +258,7 @@ export async function startTailscaleTunnel(config: {
|
||||
publicUrl,
|
||||
provider: `tailscale-${config.mode}`,
|
||||
stop: async () => {
|
||||
await stopTailscaleTunnel(config.mode, config.path);
|
||||
await stopTailscaleTunnel(config.mode, path);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -79,7 +79,7 @@ function twilioSignature(params: {
|
||||
let dataToSign = params.url;
|
||||
const sortedParams = Array.from(
|
||||
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) {
|
||||
dataToSign += key + value;
|
||||
@@ -205,4 +205,33 @@ describe("verifyTwilioWebhook", () => {
|
||||
|
||||
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)
|
||||
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) =>
|
||||
a[0].localeCompare(b[0]),
|
||||
a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0,
|
||||
);
|
||||
|
||||
for (const [key, value] of sortedParams) {
|
||||
|
||||
@@ -55,6 +55,7 @@ function run(cmd, args) {
|
||||
cwd: uiDir,
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) process.exit(1);
|
||||
@@ -67,6 +68,7 @@ function runSync(cmd, args, envOverride) {
|
||||
cwd: uiDir,
|
||||
stdio: "inherit",
|
||||
env: envOverride ?? process.env,
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
if (result.signal) process.exit(1);
|
||||
if ((result.status ?? 1) !== 0) process.exit(result.status ?? 1);
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
---
|
||||
name: session-logs
|
||||
description: Search and analyze your own conversation history from session log files using jq.
|
||||
metadata: {"clawdbot":{"emoji":"📜","requires":{"bins":["jq"]}}}
|
||||
description: Search and analyze your own session logs (older/parent conversations) using jq.
|
||||
metadata: {"clawdbot":{"emoji":"📜","requires":{"bins":["jq","rg"]}}}
|
||||
---
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
- **`<session-id>.jsonl`** - Full conversation transcript per session
|
||||
@@ -21,14 +25,14 @@ Each `.jsonl` file contains messages with:
|
||||
- `type`: "session" (metadata) or "message"
|
||||
- `timestamp`: ISO timestamp
|
||||
- `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
|
||||
|
||||
## Common Queries
|
||||
|
||||
### List all sessions by date and size
|
||||
```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)
|
||||
size=$(ls -lh "$f" | awk '{print $5}')
|
||||
echo "$date $size $(basename $f)"
|
||||
@@ -37,19 +41,19 @@ done | sort -r
|
||||
|
||||
### Find sessions from a specific day
|
||||
```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"
|
||||
done
|
||||
```
|
||||
|
||||
### Extract user messages from a session
|
||||
```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
|
||||
```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
|
||||
@@ -59,7 +63,7 @@ jq -s '[.[] | .message.usage.cost.total // 0] | add' <session>.jsonl
|
||||
|
||||
### Daily cost summary
|
||||
```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)
|
||||
cost=$(jq -s '[.[] | .message.usage.cost.total // 0] | add' "$f")
|
||||
echo "$date $cost"
|
||||
@@ -84,7 +88,7 @@ jq -r '.message.content[]? | select(.type == "toolCall") | .name' <session>.json
|
||||
|
||||
### Search across ALL sessions for a phrase
|
||||
```bash
|
||||
grep -l "phrase" ~/.clawdbot/agents/main/sessions/*.jsonl
|
||||
rg -l "phrase" ~/.clawdbot/agents/<agentId>/sessions/*.jsonl
|
||||
```
|
||||
|
||||
## Tips
|
||||
@@ -93,3 +97,9 @@ grep -l "phrase" ~/.clawdbot/agents/main/sessions/*.jsonl
|
||||
- Large sessions can be several MB - use `head`/`tail` for sampling
|
||||
- The `sessions.json` index maps chat providers (discord, whatsapp, etc.) to session IDs
|
||||
- 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: [],
|
||||
contextFiles,
|
||||
modelDisplay,
|
||||
agentId: sessionAgentId,
|
||||
});
|
||||
|
||||
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 { CliBackendConfig } from "../../config/types.js";
|
||||
import { runExec } from "../../process/exec.js";
|
||||
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
|
||||
import type { EmbeddedContextFile } from "../pi-embedded-helpers.js";
|
||||
import { buildSystemPromptParams } from "../system-prompt-params.js";
|
||||
import { buildAgentSystemPrompt } from "../system-prompt.js";
|
||||
|
||||
const CLI_RUN_QUEUE = new Map<string, Promise<unknown>>();
|
||||
@@ -172,10 +172,19 @@ export function buildSystemPrompt(params: {
|
||||
tools: AgentTool[];
|
||||
contextFiles?: EmbeddedContextFile[];
|
||||
modelDisplay: string;
|
||||
agentId?: string;
|
||||
}) {
|
||||
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
|
||||
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
|
||||
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
|
||||
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
runtime: {
|
||||
host: "clawdbot",
|
||||
os: `${os.type()} ${os.release()}`,
|
||||
arch: os.arch(),
|
||||
node: process.version,
|
||||
model: params.modelDisplay,
|
||||
},
|
||||
});
|
||||
return buildAgentSystemPrompt({
|
||||
workspaceDir: params.workspaceDir,
|
||||
defaultThinkLevel: params.defaultThinkLevel,
|
||||
@@ -184,13 +193,7 @@ export function buildSystemPrompt(params: {
|
||||
reasoningTagHint: false,
|
||||
heartbeatPrompt: params.heartbeatPrompt,
|
||||
docsPath: params.docsPath,
|
||||
runtimeInfo: {
|
||||
host: "clawdbot",
|
||||
os: `${os.type()} ${os.release()}`,
|
||||
arch: os.arch(),
|
||||
node: process.version,
|
||||
model: params.modelDisplay,
|
||||
},
|
||||
runtimeInfo,
|
||||
toolNames: params.tools.map((tool) => tool.name),
|
||||
modelAliasLines: buildModelAliasLines(params.config),
|
||||
userTimezone,
|
||||
|
||||
@@ -64,7 +64,7 @@ import { prewarmSessionFile, trackSessionManagerAccess } from "../session-manage
|
||||
import { prepareSessionManagerForRun } from "../session-manager-init.js";
|
||||
import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "../system-prompt.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 { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js";
|
||||
import { isTimeoutError } from "../../failover-error.js";
|
||||
@@ -196,25 +196,25 @@ export async function runEmbeddedAttempt(
|
||||
return level ? { level, channel: "Telegram" } : 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({
|
||||
sessionKey: params.sessionKey,
|
||||
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 promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
|
||||
const docsPath = await resolveClawdbotDocsPath({
|
||||
|
||||
@@ -23,6 +23,7 @@ export function buildEmbeddedSystemPrompt(params: {
|
||||
/** Controls which hardcoded sections to include. Defaults to "full". */
|
||||
promptMode?: PromptMode;
|
||||
runtimeInfo: {
|
||||
agentId?: string;
|
||||
host: string;
|
||||
os: 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 { buildAgentSystemPrompt } from "./system-prompt.js";
|
||||
import { buildAgentSystemPrompt, buildRuntimeLine } from "./system-prompt.js";
|
||||
|
||||
describe("buildAgentSystemPrompt", () => {
|
||||
it("includes owner numbers when provided", () => {
|
||||
@@ -252,6 +252,22 @@ describe("buildAgentSystemPrompt", () => {
|
||||
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", () => {
|
||||
const prompt = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/clawd",
|
||||
@@ -263,6 +279,31 @@ describe("buildAgentSystemPrompt", () => {
|
||||
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", () => {
|
||||
const prompt = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/clawd",
|
||||
|
||||
+35
-21
@@ -149,6 +149,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
/** Controls which hardcoded sections to include. Defaults to "full". */
|
||||
promptMode?: PromptMode;
|
||||
runtimeInfo?: {
|
||||
agentId?: string;
|
||||
host?: string;
|
||||
os?: string;
|
||||
arch?: string;
|
||||
@@ -546,27 +547,40 @@ export function buildAgentSystemPrompt(params: {
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
"## 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.`,
|
||||
);
|
||||
lines.push("## Runtime", buildRuntimeLine(runtimeInfo, runtimeChannel, runtimeCapabilities, params.defaultThinkLevel), `Reasoning: ${reasoningLevel} (hidden unless on/stream). Toggle /reasoning; /status shows Reasoning when enabled.`);
|
||||
|
||||
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 { createClawdbotCodingTools } from "../../agents/pi-tools.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 { buildAgentSystemPrompt } from "../../agents/system-prompt.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 { resolveBootstrapContextForRun } from "../../agents/bootstrap-files.js";
|
||||
import type { SessionSystemPromptReport } from "../../config/sessions/types.js";
|
||||
@@ -87,13 +89,21 @@ async function resolveContextReport(
|
||||
})();
|
||||
const toolSummaries = buildToolSummaryMap(tools);
|
||||
const toolNames = tools.map((t) => t.name);
|
||||
const runtimeInfo = {
|
||||
host: "unknown",
|
||||
os: "unknown",
|
||||
arch: "unknown",
|
||||
node: process.version,
|
||||
model: `${params.provider}/${params.model}`,
|
||||
};
|
||||
const { sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: params.sessionKey,
|
||||
config: params.cfg,
|
||||
});
|
||||
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
|
||||
config: params.cfg,
|
||||
agentId: sessionAgentId,
|
||||
runtime: {
|
||||
host: "unknown",
|
||||
os: "unknown",
|
||||
arch: "unknown",
|
||||
node: process.version,
|
||||
model: `${params.provider}/${params.model}`,
|
||||
},
|
||||
});
|
||||
const sandboxInfo = sandboxRuntime.sandboxed
|
||||
? {
|
||||
enabled: true,
|
||||
@@ -116,8 +126,9 @@ async function resolveContextReport(
|
||||
toolNames,
|
||||
toolSummaries,
|
||||
modelAliasLines: [],
|
||||
userTimezone: "",
|
||||
userTime: "",
|
||||
userTimezone,
|
||||
userTime,
|
||||
userTimeFormat,
|
||||
contextFiles: injectedFiles,
|
||||
skillsPrompt,
|
||||
heartbeatPrompt: undefined,
|
||||
|
||||
@@ -73,6 +73,12 @@ describe("cli program (smoke)", () => {
|
||||
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 () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["tui"], { from: "user" });
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { Command } from "commander";
|
||||
import { registerBrowserCli } from "../browser-cli.js";
|
||||
import { registerConfigCli } from "../config-cli.js";
|
||||
import { createProgramContext } from "./context.js";
|
||||
import { registerProgramCommands } from "./command-registry.js";
|
||||
import { configureProgramHelp } from "./help.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() {
|
||||
const program = new Command();
|
||||
@@ -21,18 +12,7 @@ export function buildProgram() {
|
||||
configureProgramHelp(program, ctx);
|
||||
registerPreActionHooks(program, ctx.programVersion);
|
||||
|
||||
registerSetupCommand(program);
|
||||
registerOnboardCommand(program);
|
||||
registerConfigureCommand(program);
|
||||
registerConfigCli(program);
|
||||
registerMaintenanceCommands(program);
|
||||
registerMessageCommands(program, ctx);
|
||||
registerAgentCommands(program, {
|
||||
agentChannelOptions: ctx.agentChannelOptions,
|
||||
});
|
||||
registerSubCliCommands(program, argv);
|
||||
registerStatusHealthSessionsCommands(program);
|
||||
registerBrowserCli(program);
|
||||
registerProgramCommands(program, ctx, argv);
|
||||
|
||||
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 { 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 { isTruthyEnvValue } from "../infra/env.js";
|
||||
import { emitCliBanner } from "./banner.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import {
|
||||
getCommandPath,
|
||||
getFlagValue,
|
||||
getPositiveIntFlagValue,
|
||||
getVerboseFlag,
|
||||
hasFlag,
|
||||
hasHelpOrVersion,
|
||||
} from "./argv.js";
|
||||
import { getCommandPath, hasHelpOrVersion } from "./argv.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: {
|
||||
argv: string[];
|
||||
@@ -36,65 +24,9 @@ export async function tryRouteCli(argv: string[]): Promise<boolean> {
|
||||
if (hasHelpOrVersion(argv)) return false;
|
||||
|
||||
const path = getCommandPath(argv, 2);
|
||||
const [primary, secondary] = path;
|
||||
if (!primary) return false;
|
||||
if (primary === "health") {
|
||||
await prepareRoutedCommand({ argv, commandPath: path, loadPlugins: true });
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
if (!path[0]) return false;
|
||||
const route = findRoutedCommand(path);
|
||||
if (!route) return false;
|
||||
await prepareRoutedCommand({ argv, commandPath: path, loadPlugins: route.loadPlugins });
|
||||
return route.run(argv);
|
||||
}
|
||||
@@ -68,6 +68,10 @@ describe("doctor legacy state migrations", () => {
|
||||
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
|
||||
) as Record<string, { sessionId: string }>;
|
||||
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:unknown:group:abc"]?.sessionId).toBe("d");
|
||||
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: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({
|
||||
targetAgentId: "main",
|
||||
targetMainKey: "main",
|
||||
targetScope: undefined,
|
||||
stateDir: "/tmp/state",
|
||||
oauthDir: "/tmp/oauth",
|
||||
sessions: {
|
||||
@@ -303,6 +304,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
||||
targetDir: "/tmp/state/agents/main/sessions",
|
||||
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
||||
hasLegacy: false,
|
||||
legacyKeys: [],
|
||||
},
|
||||
agentDir: {
|
||||
legacyDir: "/tmp/state/agent",
|
||||
|
||||
@@ -294,6 +294,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
||||
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
|
||||
targetAgentId: "main",
|
||||
targetMainKey: "main",
|
||||
targetScope: undefined,
|
||||
stateDir: "/tmp/state",
|
||||
oauthDir: "/tmp/oauth",
|
||||
sessions: {
|
||||
@@ -302,6 +303,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
||||
targetDir: "/tmp/state/agents/main/sessions",
|
||||
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
||||
hasLegacy: false,
|
||||
legacyKeys: [],
|
||||
},
|
||||
agentDir: {
|
||||
legacyDir: "/tmp/state/agent",
|
||||
|
||||
@@ -294,6 +294,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
||||
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
|
||||
targetAgentId: "main",
|
||||
targetMainKey: "main",
|
||||
targetScope: undefined,
|
||||
stateDir: "/tmp/state",
|
||||
oauthDir: "/tmp/oauth",
|
||||
sessions: {
|
||||
@@ -302,6 +303,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
||||
targetDir: "/tmp/state/agents/main/sessions",
|
||||
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
||||
hasLegacy: false,
|
||||
legacyKeys: [],
|
||||
},
|
||||
agentDir: {
|
||||
legacyDir: "/tmp/state/agent",
|
||||
|
||||
@@ -294,6 +294,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
||||
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
|
||||
targetAgentId: "main",
|
||||
targetMainKey: "main",
|
||||
targetScope: undefined,
|
||||
stateDir: "/tmp/state",
|
||||
oauthDir: "/tmp/oauth",
|
||||
sessions: {
|
||||
@@ -302,6 +303,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
||||
targetDir: "/tmp/state/agents/main/sessions",
|
||||
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
||||
hasLegacy: false,
|
||||
legacyKeys: [],
|
||||
},
|
||||
agentDir: {
|
||||
legacyDir: "/tmp/state/agent",
|
||||
|
||||
@@ -294,6 +294,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
||||
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
|
||||
targetAgentId: "main",
|
||||
targetMainKey: "main",
|
||||
targetScope: undefined,
|
||||
stateDir: "/tmp/state",
|
||||
oauthDir: "/tmp/oauth",
|
||||
sessions: {
|
||||
@@ -302,6 +303,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
||||
targetDir: "/tmp/state/agents/main/sessions",
|
||||
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
||||
hasLegacy: false,
|
||||
legacyKeys: [],
|
||||
},
|
||||
agentDir: {
|
||||
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 { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import type { SessionScope } from "../config/sessions/types.js";
|
||||
import { saveSessionStore } from "../config/sessions.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import {
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
DEFAULT_MAIN_KEY,
|
||||
normalizeAgentId,
|
||||
} from "../routing/session-key.js";
|
||||
import { canonicalizeMainSessionAlias } from "../config/sessions/main-session.js";
|
||||
import {
|
||||
ensureDir,
|
||||
existsDir,
|
||||
@@ -27,6 +29,7 @@ import {
|
||||
export type LegacyStateDetection = {
|
||||
targetAgentId: string;
|
||||
targetMainKey: string;
|
||||
targetScope?: SessionScope;
|
||||
stateDir: string;
|
||||
oauthDir: string;
|
||||
sessions: {
|
||||
@@ -35,6 +38,7 @@ export type LegacyStateDetection = {
|
||||
targetDir: string;
|
||||
targetStorePath: string;
|
||||
hasLegacy: boolean;
|
||||
legacyKeys: string[];
|
||||
};
|
||||
agentDir: {
|
||||
legacyDir: string;
|
||||
@@ -72,34 +76,49 @@ function isLegacyGroupKey(key: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeSessionKeyForAgent(key: string, agentId: string): string {
|
||||
const raw = key.trim();
|
||||
function canonicalizeSessionKeyForAgent(params: {
|
||||
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 === "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.toLowerCase().startsWith("subagent:")) {
|
||||
const rest = raw.slice("subagent:".length);
|
||||
return `agent:${normalizeAgentId(agentId)}:subagent:${rest}`;
|
||||
return `agent:${agentId}:subagent:${rest}`;
|
||||
}
|
||||
if (raw.startsWith("group:")) {
|
||||
const id = raw.slice("group:".length).trim();
|
||||
if (!id) return raw;
|
||||
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")) {
|
||||
return `agent:${normalizeAgentId(agentId)}:whatsapp:group:${raw}`;
|
||||
return `agent:${agentId}:whatsapp:group:${raw}`;
|
||||
}
|
||||
if (raw.toLowerCase().startsWith("whatsapp:") && raw.toLowerCase().includes("@g.us")) {
|
||||
const remainder = raw.slice("whatsapp:".length).trim();
|
||||
const cleaned = remainder.replace(/^group:/i, "").trim();
|
||||
if (cleaned && !isSurfaceGroupKey(raw)) {
|
||||
return `agent:${normalizeAgentId(agentId)}:whatsapp:group:${cleaned}`;
|
||||
return `agent:${agentId}:whatsapp:group:${cleaned}`;
|
||||
}
|
||||
}
|
||||
if (isSurfaceGroupKey(raw)) {
|
||||
return `agent:${normalizeAgentId(agentId)}:${raw}`;
|
||||
return `agent:${agentId}:${raw}`;
|
||||
}
|
||||
return raw;
|
||||
return `agent:${agentId}:${raw}`;
|
||||
}
|
||||
|
||||
function pickLatestLegacyDirectEntry(
|
||||
@@ -140,6 +159,91 @@ function normalizeSessionEntry(entry: SessionEntryLike): SessionEntry | null {
|
||||
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 {
|
||||
if (!existsDir(dir)) return true;
|
||||
return safeReadDir(dir).length === 0;
|
||||
@@ -179,6 +283,7 @@ export async function detectLegacyStateMigrations(params: {
|
||||
typeof rawMainKey === "string" && rawMainKey.trim().length > 0
|
||||
? rawMainKey.trim()
|
||||
: DEFAULT_MAIN_KEY;
|
||||
const targetScope = params.cfg.session?.scope;
|
||||
|
||||
const sessionsLegacyDir = path.join(stateDir, "sessions");
|
||||
const sessionsLegacyStorePath = path.join(sessionsLegacyDir, "sessions.json");
|
||||
@@ -189,6 +294,18 @@ export async function detectLegacyStateMigrations(params: {
|
||||
fileExists(sessionsLegacyStorePath) ||
|
||||
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 targetAgentDir = path.join(stateDir, "agents", targetAgentId, "agent");
|
||||
const hasLegacyAgentDir = existsDir(legacyAgentDir);
|
||||
@@ -202,6 +319,9 @@ export async function detectLegacyStateMigrations(params: {
|
||||
if (hasLegacySessions) {
|
||||
preview.push(`- Sessions: ${sessionsLegacyDir} → ${sessionsTargetDir}`);
|
||||
}
|
||||
if (legacyKeys.length > 0) {
|
||||
preview.push(`- Sessions: canonicalize legacy keys in ${sessionsTargetStorePath}`);
|
||||
}
|
||||
if (hasLegacyAgentDir) {
|
||||
preview.push(`- Agent dir: ${legacyAgentDir} → ${targetAgentDir}`);
|
||||
}
|
||||
@@ -212,6 +332,7 @@ export async function detectLegacyStateMigrations(params: {
|
||||
return {
|
||||
targetAgentId,
|
||||
targetMainKey,
|
||||
targetScope,
|
||||
stateDir,
|
||||
oauthDir,
|
||||
sessions: {
|
||||
@@ -219,7 +340,8 @@ export async function detectLegacyStateMigrations(params: {
|
||||
legacyStorePath: sessionsLegacyStorePath,
|
||||
targetDir: sessionsTargetDir,
|
||||
targetStorePath: sessionsTargetStorePath,
|
||||
hasLegacy: hasLegacySessions,
|
||||
hasLegacy: hasLegacySessions || legacyKeys.length > 0,
|
||||
legacyKeys,
|
||||
},
|
||||
agentDir: {
|
||||
legacyDir: legacyAgentDir,
|
||||
@@ -254,17 +376,27 @@ async function migrateLegacySessions(
|
||||
const legacyStore = legacyParsed.store;
|
||||
const targetStore = targetParsed.store;
|
||||
|
||||
const normalizedLegacy: Record<string, SessionEntryLike> = {};
|
||||
for (const [key, entry] of Object.entries(legacyStore)) {
|
||||
const nextKey = normalizeSessionKeyForAgent(key, detected.targetAgentId);
|
||||
if (!nextKey) continue;
|
||||
if (!normalizedLegacy[nextKey]) normalizedLegacy[nextKey] = entry;
|
||||
}
|
||||
const canonicalizedTarget = canonicalizeSessionStore({
|
||||
store: targetStore,
|
||||
agentId: detected.targetAgentId,
|
||||
mainKey: detected.targetMainKey,
|
||||
scope: detected.targetScope,
|
||||
});
|
||||
const canonicalizedLegacy = canonicalizeSessionStore({
|
||||
store: legacyStore,
|
||||
agentId: detected.targetAgentId,
|
||||
mainKey: detected.targetMainKey,
|
||||
scope: detected.targetScope,
|
||||
});
|
||||
|
||||
const merged: Record<string, SessionEntryLike> = {
|
||||
...normalizedLegacy,
|
||||
...targetStore,
|
||||
};
|
||||
const merged: Record<string, SessionEntryLike> = { ...canonicalizedTarget.store };
|
||||
for (const [key, entry] of Object.entries(canonicalizedLegacy.store)) {
|
||||
merged[key] = mergeSessionEntry({
|
||||
existing: merged[key],
|
||||
incoming: entry,
|
||||
preferIncomingOnTie: false,
|
||||
});
|
||||
}
|
||||
|
||||
const mainKey = buildAgentMainSessionKey({
|
||||
agentId: detected.targetAgentId,
|
||||
@@ -285,7 +417,7 @@ async function migrateLegacySessions(
|
||||
}
|
||||
|
||||
if (
|
||||
legacyParsed.ok &&
|
||||
(legacyParsed.ok || targetParsed.ok) &&
|
||||
(Object.keys(legacyStore).length > 0 || Object.keys(targetStore).length > 0)
|
||||
) {
|
||||
const normalized: Record<string, SessionEntry> = {};
|
||||
@@ -296,6 +428,11 @@ async function migrateLegacySessions(
|
||||
}
|
||||
await saveSessionStore(detected.sessions.targetStorePath, normalized);
|
||||
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);
|
||||
|
||||
@@ -158,7 +158,7 @@ describe("SearchableSelectList", () => {
|
||||
list.handleInput("z");
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -190,7 +190,7 @@ export class SearchableSelectList implements Component {
|
||||
|
||||
// If no items match filter, show message
|
||||
if (this.filteredItems.length === 0) {
|
||||
lines.push(this.theme.noMatch(" No matching models"));
|
||||
lines.push(this.theme.noMatch(" No matches"));
|
||||
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 { highlight, supportsLanguage } from "cli-highlight";
|
||||
import type { SearchableSelectListTheme } from "../components/searchable-select-list.js";
|
||||
import { createSyntaxTheme } from "./syntax-theme.js";
|
||||
|
||||
const palette = {
|
||||
text: "#E8E3D5",
|
||||
@@ -35,52 +36,7 @@ const palette = {
|
||||
const fg = (hex: string) => (text: string) => chalk.hex(hex)(text);
|
||||
const bg = (hex: string) => (text: string) => chalk.bgHex(hex)(text);
|
||||
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
const syntaxTheme = createSyntaxTheme(fg(palette.code));
|
||||
|
||||
/**
|
||||
* Highlight code with syntax coloring.
|
||||
|
||||
@@ -7,11 +7,7 @@ import {
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { helpText, parseCommand } from "./commands.js";
|
||||
import type { ChatLog } from "./components/chat-log.js";
|
||||
import {
|
||||
createSearchableSelectList,
|
||||
createSelectList,
|
||||
createSettingsList,
|
||||
} from "./components/selectors.js";
|
||||
import { createSearchableSelectList, createSettingsList } from "./components/selectors.js";
|
||||
import type { GatewayChatClient } from "./gateway-chat.js";
|
||||
import { formatStatusSummary } from "./tui-status-summary.js";
|
||||
import type {
|
||||
@@ -117,7 +113,7 @@ export function createCommandHandlers(context: CommandHandlerContext) {
|
||||
label: agent.name ? `${agent.id} (${agent.name})` : agent.id,
|
||||
description: agent.id === state.agentDefaultId ? "default" : "",
|
||||
}));
|
||||
const selector = createSelectList(items, 9);
|
||||
const selector = createSearchableSelectList(items, 9);
|
||||
selector.onSelect = (item) => {
|
||||
void (async () => {
|
||||
closeOverlay();
|
||||
@@ -147,7 +143,7 @@ export function createCommandHandlers(context: CommandHandlerContext) {
|
||||
: formatSessionKey(session.key),
|
||||
description: session.updatedAt ? new Date(session.updatedAt).toLocaleString() : "",
|
||||
}));
|
||||
const selector = createSelectList(items, 9);
|
||||
const selector = createSearchableSelectList(items, 9);
|
||||
selector.onSelect = (item) => {
|
||||
void (async () => {
|
||||
closeOverlay();
|
||||
|
||||
Reference in new issue
Block a user