From fece42ce0aca55e093a758cfa775e46171065776 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 17 Dec 2025 11:29:04 +0100 Subject: [PATCH] feat: embed pi agent runtime --- package.json | 8 +- src/agents/agents.test.ts | 128 --- src/agents/index.test.ts | 12 - src/agents/index.ts | 12 - src/agents/pi-embedded.ts | 507 +++++++++++ src/agents/pi-oauth.ts | 112 +++ src/agents/pi-path.test.ts | 34 - src/agents/pi-path.ts | 73 -- src/agents/pi.test.ts | 26 - src/agents/pi.ts | 238 ------ src/agents/system-prompt.ts | 84 ++ src/agents/types.ts | 52 -- src/agents/workspace.test.ts | 6 +- src/agents/workspace.ts | 126 ++- src/auto-reply/command-reply.test.ts | 548 ------------ src/auto-reply/command-reply.ts | 1081 ------------------------ src/auto-reply/reply.directive.test.ts | 114 +-- src/auto-reply/reply.triggers.test.ts | 271 +++--- src/auto-reply/reply.ts | 490 ++++------- src/auto-reply/status.test.ts | 123 +-- src/auto-reply/status.ts | 108 +-- src/cli/program.ts | 22 +- src/commands/agent.test.ts | 217 ++--- src/commands/agent.ts | 308 +++---- src/commands/health.ts | 2 +- src/commands/sessions.test.ts | 4 +- src/commands/sessions.ts | 10 +- src/commands/setup.ts | 81 ++ src/commands/status.ts | 6 +- src/config/config.test.ts | 148 ++-- src/config/config.ts | 159 ++-- src/config/sessions.ts | 20 +- src/cron/isolated-agent.test.ts | 256 +++--- src/cron/isolated-agent.ts | 139 +-- src/gateway/server.test.ts | 27 +- src/gateway/server.ts | 21 +- src/process/tau-rpc.test.ts | 84 -- src/process/tau-rpc.ts | 276 ------ src/telegram/bot.ts | 2 +- src/web/auto-reply.test.ts | 120 +-- src/web/auto-reply.ts | 26 +- src/web/session.ts | 4 +- 42 files changed, 2076 insertions(+), 4009 deletions(-) delete mode 100644 src/agents/agents.test.ts delete mode 100644 src/agents/index.test.ts delete mode 100644 src/agents/index.ts create mode 100644 src/agents/pi-embedded.ts create mode 100644 src/agents/pi-oauth.ts delete mode 100644 src/agents/pi-path.test.ts delete mode 100644 src/agents/pi-path.ts delete mode 100644 src/agents/pi.test.ts delete mode 100644 src/agents/pi.ts create mode 100644 src/agents/system-prompt.ts delete mode 100644 src/agents/types.ts delete mode 100644 src/auto-reply/command-reply.test.ts delete mode 100644 src/auto-reply/command-reply.ts create mode 100644 src/commands/setup.ts delete mode 100644 src/process/tau-rpc.test.ts delete mode 100644 src/process/tau-rpc.ts diff --git a/package.json b/package.json index 681834fef..ba8e5128a 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "protocol:gen": "tsx scripts/protocol-gen.ts", "protocol:gen:swift": "tsx scripts/protocol-gen-swift.ts", "protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdisProtocol/GatewayModels.swift", - "webchat:bundle": "rolldown -c apps/macos/Sources/Clawdis/Resources/WebChat/rolldown.config.mjs" + "webchat:bundle": "rolldown -c apps/macos/Sources/Clawdis/Resources/WebChat/rolldown.config.mjs", + "canvas:a2ui:bundle": "pnpm -s exec tsc -p vendor/a2ui/renderers/lit/tsconfig.json && rolldown -c apps/macos/Sources/Clawdis/Resources/CanvasA2UI/rolldown.config.mjs" }, "keywords": [], "author": "", @@ -61,9 +62,12 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.8", + "@lit-labs/signals": "^0.1.3", + "@lit/context": "^1.1.6", "@mariozechner/mini-lit": "0.2.1", "@types/body-parser": "^1.19.6", "@types/express": "^5.0.6", + "@types/markdown-it": "^14.1.2", "@types/node": "^25.0.2", "@types/qrcode-terminal": "^0.12.2", "@types/ws": "^8.18.1", @@ -72,10 +76,12 @@ "jszip": "^3.10.1", "lit": "^3.3.1", "lucide": "^0.561.0", + "markdown-it": "^14.1.0", "ollama": "^0.6.3", "playwright-core": "1.57.0", "quicktype-core": "^23.2.6", "rolldown": "1.0.0-beta.54", + "signal-utils": "^0.21.1", "tsx": "^4.21.0", "typescript": "^5.9.3", "vitest": "^4.0.15" diff --git a/src/agents/agents.test.ts b/src/agents/agents.test.ts deleted file mode 100644 index 1f8dbd62f..000000000 --- a/src/agents/agents.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { piSpec } from "./pi.js"; - -describe("pi agent helpers", () => { - it("buildArgs injects print/format flags and identity once", () => { - const argv = ["pi", "hi"]; - const built = piSpec.buildArgs({ - argv, - bodyIndex: 1, - isNewSession: true, - sessionId: "sess", - provider: "anthropic", - model: "claude-opus-4-5", - sendSystemOnce: false, - systemSent: false, - identityPrefix: "IDENT", - format: "json", - }); - expect(built).toContain("-p"); - expect(built).toContain("--mode"); - expect(built).toContain("json"); - expect(built).toContain("--provider"); - expect(built).toContain("anthropic"); - expect(built).toContain("--model"); - expect(built).toContain("claude-opus-4-5"); - expect(built.at(-1)).toContain("IDENT"); - - const builtNoIdentity = piSpec.buildArgs({ - argv, - bodyIndex: 1, - isNewSession: false, - sessionId: "sess", - provider: "anthropic", - model: "claude-opus-4-5", - sendSystemOnce: true, - systemSent: true, - identityPrefix: "IDENT", - format: "json", - }); - expect(builtNoIdentity.at(-1)).toBe("hi"); - }); - - it("injects provider/model for pi invocations only and avoids duplicates", () => { - const base = piSpec.buildArgs({ - argv: ["pi", "hello"], - bodyIndex: 1, - isNewSession: true, - sendSystemOnce: false, - systemSent: false, - format: "json", - }); - expect(base.filter((a) => a === "--provider").length).toBe(1); - expect(base).toContain("anthropic"); - expect(base.filter((a) => a === "--model").length).toBe(1); - expect(base).toContain("claude-opus-4-5"); - - const already = piSpec.buildArgs({ - argv: [ - "pi", - "--provider", - "anthropic", - "--model", - "claude-opus-4-5", - "hi", - ], - bodyIndex: 5, - isNewSession: true, - sendSystemOnce: false, - systemSent: false, - format: "json", - }); - expect(already.filter((a) => a === "--provider").length).toBe(1); - expect(already.filter((a) => a === "--model").length).toBe(1); - - const nonPi = piSpec.buildArgs({ - argv: ["echo", "hi"], - bodyIndex: 1, - isNewSession: true, - sendSystemOnce: false, - systemSent: false, - format: "json", - }); - expect(nonPi).not.toContain("--provider"); - expect(nonPi).not.toContain("--model"); - }); - - it("parses final assistant message and preserves usage meta", () => { - const stdout = [ - '{"type":"message_start","message":{"role":"assistant"}}', - '{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"hello world"}],"usage":{"input":10,"output":5,"cacheRead":100,"cacheWrite":20,"totalTokens":135},"model":"pi-1","provider":"inflection","stopReason":"end"}}', - ].join("\n"); - const parsed = piSpec.parseOutput(stdout); - expect(parsed.texts?.[0]).toBe("hello world"); - expect(parsed.meta?.provider).toBe("inflection"); - expect((parsed.meta?.usage as { output?: number })?.output).toBe(5); - expect((parsed.meta?.usage as { cacheRead?: number })?.cacheRead).toBe(100); - expect((parsed.meta?.usage as { cacheWrite?: number })?.cacheWrite).toBe( - 20, - ); - expect((parsed.meta?.usage as { total?: number })?.total).toBe(135); - }); - - it("piSpec carries tool names when present", () => { - const stdout = - '{"type":"message_end","message":{"role":"tool_result","name":"bash","details":{"command":"ls -la"},"content":[{"type":"text","text":"ls output"}]}}'; - const parsed = piSpec.parseOutput(stdout); - const tool = parsed.toolResults?.[0] as { - text?: string; - toolName?: string; - meta?: string; - }; - expect(tool?.text).toBe("ls output"); - expect(tool?.toolName).toBe("bash"); - expect(tool?.meta).toBe("ls -la"); - }); - - it("keeps usage meta even when assistant message has no text", () => { - const stdout = [ - '{"type":"message_start","message":{"role":"assistant"}}', - '{"type":"message_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"hmm"}],"usage":{"input":10,"output":5},"model":"pi-1","provider":"inflection","stopReason":"end"}}', - ].join("\n"); - const parsed = piSpec.parseOutput(stdout); - expect(parsed.texts?.length ?? 0).toBe(0); - expect((parsed.meta?.usage as { input?: number })?.input).toBe(10); - expect(parsed.meta?.model).toBe("pi-1"); - }); -}); diff --git a/src/agents/index.test.ts b/src/agents/index.test.ts deleted file mode 100644 index 40f228335..000000000 --- a/src/agents/index.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { getAgentSpec } from "./index.js"; - -describe("agents index", () => { - it("returns a spec for pi", () => { - const spec = getAgentSpec("pi"); - expect(spec).toBeTruthy(); - expect(spec.kind).toBe("pi"); - expect(typeof spec.parseOutput).toBe("function"); - }); -}); diff --git a/src/agents/index.ts b/src/agents/index.ts deleted file mode 100644 index cda1b33b9..000000000 --- a/src/agents/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { piSpec } from "./pi.js"; -import type { AgentKind, AgentSpec } from "./types.js"; - -const specs: Record = { - pi: piSpec, -}; - -export function getAgentSpec(kind: AgentKind): AgentSpec { - return specs[kind]; -} - -export type { AgentKind, AgentMeta, AgentParseResult } from "./types.js"; diff --git a/src/agents/pi-embedded.ts b/src/agents/pi-embedded.ts new file mode 100644 index 000000000..8a7c42ee9 --- /dev/null +++ b/src/agents/pi-embedded.ts @@ -0,0 +1,507 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { + Agent, + type AgentEvent, + type AppMessage, + ProviderTransport, + type ThinkingLevel, +} from "@mariozechner/pi-agent-core"; +import { + type Api, + type AssistantMessage, + getApiKey, + getModels, + getProviders, + type KnownProvider, + type Model, +} from "@mariozechner/pi-ai"; +import { + AgentSession, + codingTools, + messageTransformer, + SessionManager, + SettingsManager, +} from "@mariozechner/pi-coding-agent"; +import type { ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js"; +import { + createToolDebouncer, + formatToolAggregate, +} from "../auto-reply/tool-meta.js"; +import { emitAgentEvent } from "../infra/agent-events.js"; +import { splitMediaFromOutput } from "../media/parse.js"; +import { enqueueCommand } from "../process/command-queue.js"; +import { resolveUserPath } from "../utils.js"; +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; +import { getAnthropicOAuthToken } from "./pi-oauth.js"; +import { buildAgentSystemPrompt } from "./system-prompt.js"; +import { loadWorkspaceBootstrapFiles } from "./workspace.js"; + +export type EmbeddedPiAgentMeta = { + sessionId: string; + provider: string; + model: string; + usage?: { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + total?: number; + }; +}; + +export type EmbeddedPiRunMeta = { + durationMs: number; + agentMeta?: EmbeddedPiAgentMeta; + aborted?: boolean; +}; + +export type EmbeddedPiRunResult = { + payloads?: Array<{ + text?: string; + mediaUrl?: string; + mediaUrls?: string[]; + }>; + meta: EmbeddedPiRunMeta; +}; + +function mapThinkingLevel(level?: ThinkLevel): ThinkingLevel { + // pi-agent-core supports "xhigh" too; Clawdis doesn't surface it for now. + if (!level) return "off"; + return level; +} + +function isKnownProvider(provider: string): provider is KnownProvider { + return getProviders().includes(provider as KnownProvider); +} + +function resolveModel( + provider: string, + modelId: string, +): Model | undefined { + if (!isKnownProvider(provider)) return undefined; + const models = getModels(provider); + const model = models.find((m) => m.id === modelId); + return model as Model | undefined; +} + +function extractAssistantText(msg: AssistantMessage): string { + const isTextBlock = ( + block: unknown, + ): block is { type: "text"; text: string } => { + if (!block || typeof block !== "object") return false; + const rec = block as Record; + return rec.type === "text" && typeof rec.text === "string"; + }; + + const blocks = Array.isArray(msg.content) + ? msg.content + .filter(isTextBlock) + .map((c) => c.text.trim()) + .filter(Boolean) + : []; + return blocks.join("\n").trim(); +} + +function inferToolMetaFromArgs( + toolName: string, + args: unknown, +): string | undefined { + if (!args || typeof args !== "object") return undefined; + const record = args as Record; + + const p = typeof record.path === "string" ? record.path : undefined; + const command = + typeof record.command === "string" ? record.command : undefined; + + if (toolName === "read" && p) { + const offset = + typeof record.offset === "number" ? record.offset : undefined; + const limit = typeof record.limit === "number" ? record.limit : undefined; + if (offset !== undefined && limit !== undefined) { + return `${p}:${offset}-${offset + limit}`; + } + return p; + } + if ((toolName === "edit" || toolName === "write") && p) return p; + if (toolName === "bash" && command) return command; + return p ?? command; +} + +async function ensureSessionHeader(params: { + sessionFile: string; + sessionId: string; + cwd: string; + provider: string; + modelId: string; + thinkingLevel: ThinkingLevel; +}) { + const file = params.sessionFile; + try { + await fs.stat(file); + return; + } catch { + // create + } + await fs.mkdir(path.dirname(file), { recursive: true }); + const entry = { + type: "session", + id: params.sessionId, + timestamp: new Date().toISOString(), + cwd: params.cwd, + provider: params.provider, + modelId: params.modelId, + thinkingLevel: params.thinkingLevel, + }; + await fs.writeFile(file, `${JSON.stringify(entry)}\n`, "utf-8"); +} + +async function getApiKeyForProvider( + provider: string, +): Promise { + if (provider === "anthropic") { + const oauthToken = await getAnthropicOAuthToken(); + if (oauthToken) return oauthToken; + const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN; + if (oauthEnv?.trim()) return oauthEnv.trim(); + } + return getApiKey(provider) ?? undefined; +} + +export async function runEmbeddedPiAgent(params: { + sessionId: string; + sessionFile: string; + workspaceDir: string; + prompt: string; + provider?: string; + model?: string; + thinkLevel?: ThinkLevel; + verboseLevel?: VerboseLevel; + timeoutMs: number; + runId: string; + onPartialReply?: (payload: { + text?: string; + mediaUrls?: string[]; + }) => void | Promise; + onAgentEvent?: (evt: { + stream: string; + data: Record; + }) => void; + enqueue?: typeof enqueueCommand; +}): Promise { + const enqueue = params.enqueue ?? enqueueCommand; + return enqueue(async () => { + const started = Date.now(); + const resolvedWorkspace = resolveUserPath(params.workspaceDir); + const prevCwd = process.cwd(); + + const provider = + (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER; + const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL; + const model = resolveModel(provider, modelId); + if (!model) { + throw new Error(`Unknown model: ${provider}/${modelId}`); + } + + const thinkingLevel = mapThinkingLevel(params.thinkLevel); + + await fs.mkdir(resolvedWorkspace, { recursive: true }); + await ensureSessionHeader({ + sessionFile: params.sessionFile, + sessionId: params.sessionId, + cwd: resolvedWorkspace, + provider, + modelId, + thinkingLevel, + }); + + process.chdir(resolvedWorkspace); + try { + const bootstrapFiles = + await loadWorkspaceBootstrapFiles(resolvedWorkspace); + const systemPrompt = buildAgentSystemPrompt({ + workspaceDir: resolvedWorkspace, + bootstrapFiles: bootstrapFiles.map((f) => ({ + name: f.name, + path: f.path, + content: f.content, + missing: f.missing, + })), + defaultThinkLevel: params.thinkLevel, + }); + + const sessionManager = new SessionManager(false, params.sessionFile); + const settingsManager = new SettingsManager(); + + const agent = new Agent({ + initialState: { + systemPrompt, + model, + thinkingLevel, + tools: codingTools, + }, + messageTransformer, + queueMode: settingsManager.getQueueMode(), + transport: new ProviderTransport({ + getApiKey: async (providerName) => { + const key = await getApiKeyForProvider(providerName); + if (!key) { + throw new Error( + `No API key found for provider "${providerName}"`, + ); + } + return key; + }, + }), + }); + + // Resume messages from the transcript if present. + const prior = sessionManager.loadSession().messages; + if (prior.length > 0) { + agent.replaceMessages(prior); + } + + const session = new AgentSession({ + agent, + sessionManager, + settingsManager, + }); + + const assistantTexts: string[] = []; + const toolDebouncer = createToolDebouncer((toolName, metas) => { + if (!params.onPartialReply) return; + const text = formatToolAggregate(toolName, metas); + const { text: cleanedText, mediaUrls } = splitMediaFromOutput(text); + void params.onPartialReply({ + text: cleanedText, + mediaUrls: mediaUrls?.length ? mediaUrls : undefined, + }); + }); + + const toolMetas: Array<{ toolName?: string; meta?: string }> = []; + const toolMetaById = new Map(); + let deltaBuffer = ""; + let lastStreamedAssistant: string | undefined; + let aborted = false; + + const unsubscribe = session.subscribe( + (evt: AgentEvent | { type: string; [k: string]: unknown }) => { + if (evt.type === "tool_execution_start") { + const toolName = String( + (evt as AgentEvent & { toolName: string }).toolName, + ); + const toolCallId = String( + (evt as AgentEvent & { toolCallId: string }).toolCallId, + ); + const args = (evt as AgentEvent & { args: unknown }).args; + const meta = inferToolMetaFromArgs(toolName, args); + toolMetaById.set(toolCallId, meta); + + emitAgentEvent({ + runId: params.runId, + stream: "tool", + data: { + phase: "start", + name: toolName, + toolCallId, + args: args as Record, + }, + }); + params.onAgentEvent?.({ + stream: "tool", + data: { phase: "start", name: toolName, toolCallId }, + }); + } + + if (evt.type === "tool_execution_end") { + const toolName = String( + (evt as AgentEvent & { toolName: string }).toolName, + ); + const toolCallId = String( + (evt as AgentEvent & { toolCallId: string }).toolCallId, + ); + const isError = Boolean( + (evt as AgentEvent & { isError: boolean }).isError, + ); + const meta = toolMetaById.get(toolCallId); + toolMetas.push({ toolName, meta }); + toolDebouncer.push(toolName, meta); + + emitAgentEvent({ + runId: params.runId, + stream: "tool", + data: { + phase: "result", + name: toolName, + toolCallId, + meta, + isError, + }, + }); + params.onAgentEvent?.({ + stream: "tool", + data: { + phase: "result", + name: toolName, + toolCallId, + meta, + isError, + }, + }); + } + + if (evt.type === "message_update") { + const msg = (evt as AgentEvent & { message: AppMessage }).message; + if (msg?.role === "assistant") { + const assistantEvent = ( + evt as AgentEvent & { assistantMessageEvent?: unknown } + ).assistantMessageEvent; + const assistantRecord = + assistantEvent && typeof assistantEvent === "object" + ? (assistantEvent as Record) + : undefined; + const evtType = + typeof assistantRecord?.type === "string" + ? assistantRecord.type + : ""; + if ( + evtType === "text_delta" || + evtType === "text_start" || + evtType === "text_end" + ) { + const chunk = + typeof assistantRecord?.delta === "string" + ? assistantRecord.delta + : typeof assistantRecord?.content === "string" + ? assistantRecord.content + : ""; + if (chunk) { + deltaBuffer += chunk; + const next = deltaBuffer.trim(); + if ( + next && + next !== lastStreamedAssistant && + params.onPartialReply + ) { + lastStreamedAssistant = next; + const { text: cleanedText, mediaUrls } = + splitMediaFromOutput(next); + void params.onPartialReply({ + text: cleanedText, + mediaUrls: mediaUrls?.length ? mediaUrls : undefined, + }); + } + } + } + } + } + + if (evt.type === "message_end") { + const msg = (evt as AgentEvent & { message: AppMessage }).message; + if (msg?.role === "assistant") { + const text = extractAssistantText(msg as AssistantMessage); + if (text) assistantTexts.push(text); + deltaBuffer = ""; + } + } + + if (evt.type === "agent_end") { + toolDebouncer.flush(); + } + }, + ); + + const abortTimer = setTimeout( + () => { + aborted = true; + void session.abort(); + }, + Math.max(1, params.timeoutMs), + ); + + let messagesSnapshot: AppMessage[] = []; + let sessionIdUsed = session.sessionId; + try { + await session.prompt(params.prompt); + messagesSnapshot = session.messages.slice(); + sessionIdUsed = session.sessionId; + } finally { + clearTimeout(abortTimer); + unsubscribe(); + toolDebouncer.flush(); + session.dispose(); + } + + const lastAssistant = messagesSnapshot + .slice() + .reverse() + .find((m) => (m as AppMessage)?.role === "assistant") as + | AssistantMessage + | undefined; + + const usage = lastAssistant?.usage; + const agentMeta: EmbeddedPiAgentMeta = { + sessionId: sessionIdUsed, + provider: lastAssistant?.provider ?? provider, + model: lastAssistant?.model ?? model.id, + usage: usage + ? { + input: usage.input, + output: usage.output, + cacheRead: usage.cacheRead, + cacheWrite: usage.cacheWrite, + total: usage.totalTokens, + } + : undefined, + }; + + const replyItems: Array<{ text: string; media?: string[] }> = []; + + const inlineToolResults = + params.verboseLevel === "on" && + !params.onPartialReply && + toolMetas.length > 0; + if (inlineToolResults) { + for (const { toolName, meta } of toolMetas) { + const agg = formatToolAggregate(toolName, meta ? [meta] : []); + const { text: cleanedText, mediaUrls } = splitMediaFromOutput(agg); + if (cleanedText) + replyItems.push({ text: cleanedText, media: mediaUrls }); + } + } + + for (const text of assistantTexts.length + ? assistantTexts + : lastAssistant + ? [extractAssistantText(lastAssistant)] + : []) { + const { text: cleanedText, mediaUrls } = splitMediaFromOutput(text); + if (!cleanedText && (!mediaUrls || mediaUrls.length === 0)) continue; + replyItems.push({ text: cleanedText, media: mediaUrls }); + } + + const payloads = replyItems + .map((item) => ({ + text: item.text?.trim() ? item.text.trim() : undefined, + mediaUrls: item.media?.length ? item.media : undefined, + mediaUrl: item.media?.[0], + })) + .filter( + (p) => + p.text || p.mediaUrl || (p.mediaUrls && p.mediaUrls.length > 0), + ); + + return { + payloads: payloads.length ? payloads : undefined, + meta: { + durationMs: Date.now() - started, + agentMeta, + aborted, + }, + }; + } finally { + process.chdir(prevCwd); + } + }); +} diff --git a/src/agents/pi-oauth.ts b/src/agents/pi-oauth.ts new file mode 100644 index 000000000..b4e96f1af --- /dev/null +++ b/src/agents/pi-oauth.ts @@ -0,0 +1,112 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +const PI_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR"; + +type OAuthCredentials = { + type: "oauth"; + refresh: string; + access: string; + /** Unix ms timestamp (already includes buffer) */ + expires: number; +}; + +type OAuthStorageFormat = Record; + +const ANTHROPIC_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; +const ANTHROPIC_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"; + +function getPiAgentDir(): string { + const override = process.env[PI_AGENT_DIR_ENV]; + if (override?.trim()) return override.trim(); + return path.join(os.homedir(), ".pi", "agent"); +} + +function getPiOAuthPath(): string { + return path.join(getPiAgentDir(), "oauth.json"); +} + +async function loadOAuthStorage(): Promise { + const filePath = getPiOAuthPath(); + try { + const raw = await fs.readFile(filePath, "utf-8"); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") { + return parsed as OAuthStorageFormat; + } + } catch { + // missing/invalid: treat as empty + } + return {}; +} + +async function saveOAuthStorage(storage: OAuthStorageFormat): Promise { + const filePath = getPiOAuthPath(); + await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 }); + await fs.writeFile(filePath, JSON.stringify(storage, null, 2), { + encoding: "utf-8", + mode: 0o600, + }); + try { + await fs.chmod(filePath, 0o600); + } catch { + // best effort (windows / restricted fs) + } +} + +async function refreshAnthropicToken( + refreshToken: string, +): Promise { + const tokenResponse = await fetch(ANTHROPIC_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + grant_type: "refresh_token", + client_id: ANTHROPIC_CLIENT_ID, + refresh_token: refreshToken, + }), + }); + + if (!tokenResponse.ok) { + const error = await tokenResponse.text(); + throw new Error(`Anthropic OAuth token refresh failed: ${error}`); + } + + const tokenData = (await tokenResponse.json()) as { + refresh_token: string; + access_token: string; + expires_in: number; + }; + + // 5 min buffer + const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000; + return { + type: "oauth", + refresh: tokenData.refresh_token, + access: tokenData.access_token, + expires: expiresAt, + }; +} + +export async function getAnthropicOAuthToken(): Promise { + const storage = await loadOAuthStorage(); + const creds = storage.anthropic; + if (!creds) return null; + + // If expired, attempt refresh; on failure, remove creds. + if (Date.now() >= creds.expires) { + try { + const refreshed = await refreshAnthropicToken(creds.refresh); + storage.anthropic = refreshed; + await saveOAuthStorage(storage); + return refreshed.access; + } catch { + delete storage.anthropic; + await saveOAuthStorage(storage); + return null; + } + } + + return creds.access; +} diff --git a/src/agents/pi-path.test.ts b/src/agents/pi-path.test.ts deleted file mode 100644 index a98ef893d..000000000 --- a/src/agents/pi-path.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; - -import { describe, expect, it, vi } from "vitest"; - -import { resolveBundledPiBinary } from "./pi-path.js"; - -describe("pi-path", () => { - it("resolves to a bundled binary path when available", () => { - const resolved = resolveBundledPiBinary(); - expect(resolved === null || typeof resolved === "string").toBe(true); - if (typeof resolved === "string") { - expect(resolved).toMatch(/pi-coding-agent/); - expect(resolved).toMatch(/dist\/pi|dist\/cli\.js|bin\/tau-dev\.mjs/); - } - }); - - it("prefers dist/pi when present (branch coverage)", () => { - const original = fs.existsSync.bind(fs); - const spy = vi.spyOn(fs, "existsSync").mockImplementation((p) => { - const s = String(p); - if (s.endsWith(path.join("dist", "pi"))) return true; - return original(p); - }); - try { - const resolved = resolveBundledPiBinary(); - expect(resolved).not.toBeNull(); - expect(typeof resolved).toBe("string"); - expect(resolved).toMatch(/dist\/pi$/); - } finally { - spy.mockRestore(); - } - }); -}); diff --git a/src/agents/pi-path.ts b/src/agents/pi-path.ts deleted file mode 100644 index fd6a38b9b..000000000 --- a/src/agents/pi-path.ts +++ /dev/null @@ -1,73 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -// Resolve the bundled pi/tau binary path from the installed dependency. -export function resolveBundledPiBinary(): string | null { - const candidatePkgDirs: string[] = []; - - // Preferred: ESM resolution to the package entry, then walk up to package.json. - try { - const resolved = (import.meta as { resolve?: (s: string) => string }) - .resolve; - const entryUrl = resolved?.("@mariozechner/pi-coding-agent"); - if (typeof entryUrl === "string" && entryUrl.startsWith("file:")) { - const entryPath = fileURLToPath(entryUrl); - let dir = path.dirname(entryPath); - for (let i = 0; i < 12; i += 1) { - const pkgJson = path.join(dir, "package.json"); - if (fs.existsSync(pkgJson)) { - candidatePkgDirs.push(dir); - break; - } - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; - } - } - } catch { - // ignore; we'll try filesystem fallbacks below - } - - // Fallback: walk up from this module's directory to find node_modules. - try { - let dir = path.dirname(fileURLToPath(import.meta.url)); - for (let i = 0; i < 12; i += 1) { - candidatePkgDirs.push( - path.join(dir, "node_modules", "@mariozechner", "pi-coding-agent"), - ); - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; - } - } catch { - // ignore - } - - // Fallback: assume CWD is project root. - candidatePkgDirs.push( - path.resolve( - process.cwd(), - "node_modules", - "@mariozechner", - "pi-coding-agent", - ), - ); - - for (const pkgDir of candidatePkgDirs) { - try { - if (!fs.existsSync(pkgDir)) continue; - const binCandidates = [ - path.join(pkgDir, "dist", "pi"), - path.join(pkgDir, "dist", "cli.js"), - path.join(pkgDir, "bin", "tau-dev.mjs"), - ]; - for (const candidate of binCandidates) { - if (fs.existsSync(candidate)) return candidate; - } - } catch { - // ignore this candidate - } - } - return null; -} diff --git a/src/agents/pi.test.ts b/src/agents/pi.test.ts deleted file mode 100644 index 79f87d228..000000000 --- a/src/agents/pi.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { piSpec } from "./pi.js"; - -describe("piSpec.isInvocation", () => { - it("detects pi binary", () => { - expect(piSpec.isInvocation(["/usr/local/bin/pi"])).toBe(true); - }); - - it("detects tau binary", () => { - expect(piSpec.isInvocation(["/opt/tau"])).toBe(true); - }); - - it("detects node entry pointing at coding-agent cli", () => { - expect( - piSpec.isInvocation([ - "node", - "/Users/me/Projects/pi-mono/packages/coding-agent/dist/cli.js", - ]), - ).toBe(true); - }); - - it("rejects unrelated node scripts", () => { - expect(piSpec.isInvocation(["node", "/tmp/script.js"])).toBe(false); - }); -}); diff --git a/src/agents/pi.ts b/src/agents/pi.ts deleted file mode 100644 index 48750199c..000000000 --- a/src/agents/pi.ts +++ /dev/null @@ -1,238 +0,0 @@ -import path from "node:path"; - -import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; -import type { - AgentMeta, - AgentParseResult, - AgentSpec, - AgentToolResult, -} from "./types.js"; -import { normalizeUsage, type UsageLike } from "./usage.js"; - -type PiAssistantMessage = { - role?: string; - content?: Array<{ type?: string; text?: string }>; - usage?: UsageLike; - model?: string; - provider?: string; - stopReason?: string; - errorMessage?: string; - name?: string; - toolName?: string; - tool_call_id?: string; - toolCallId?: string; - details?: Record; - arguments?: Record; -}; - -function inferToolName(msg: PiAssistantMessage): string | undefined { - const candidates = [msg.toolName, msg.name, msg.toolCallId, msg.tool_call_id] - .map((c) => (typeof c === "string" ? c.trim() : "")) - .filter(Boolean); - if (candidates.length) return candidates[0]; - - if (msg.role?.includes(":")) { - const suffix = msg.role.split(":").slice(1).join(":").trim(); - if (suffix) return suffix; - } - - return undefined; -} - -function deriveToolMeta(msg: PiAssistantMessage): string | undefined { - const details = msg.details ?? msg.arguments; - const pathVal = - details && typeof details.path === "string" ? details.path : undefined; - const offset = - details && typeof details.offset === "number" ? details.offset : undefined; - const limit = - details && typeof details.limit === "number" ? details.limit : undefined; - const command = - details && typeof details.command === "string" - ? details.command - : undefined; - - if (pathVal) { - if (offset !== undefined && limit !== undefined) { - return `${pathVal}:${offset}-${offset + limit}`; - } - return pathVal; - } - if (command) return command; - return undefined; -} - -function parsePiJson(raw: string): AgentParseResult { - const lines = raw.split(/\n+/).filter((l) => l.trim().startsWith("{")); - - // Collect only completed assistant messages (skip streaming updates/toolcalls). - const texts: string[] = []; - const toolResults: AgentToolResult[] = []; - let lastAssistant: PiAssistantMessage | undefined; - let lastPushed: string | undefined; - - const pickText = (msg?: PiAssistantMessage) => - msg?.content - ?.filter((c) => c?.type === "text" && typeof c.text === "string") - .map((c) => c.text) - .join("\n") - .trim(); - - const handleAssistant = (msg?: PiAssistantMessage) => { - if (!msg) return; - lastAssistant = msg; - const text = pickText(msg); - const fallbackError = - !text && typeof msg.errorMessage === "string" - ? `Warning: ${msg.errorMessage}` - : undefined; - const chosen = (text || fallbackError)?.trim(); - if (chosen && chosen !== lastPushed) { - texts.push(chosen); - lastPushed = chosen; - } - }; - - const handleToolResult = (msg?: PiAssistantMessage) => { - if (!msg || !msg.content) return; - const toolText = pickText(msg); - if (!toolText) return; - toolResults.push({ - text: toolText, - toolName: inferToolName(msg), - meta: deriveToolMeta(msg), - }); - }; - - for (const line of lines) { - try { - const ev = JSON.parse(line) as { - type?: string; - message?: PiAssistantMessage; - toolResults?: PiAssistantMessage[]; - messages?: PiAssistantMessage[]; - }; - - // Turn-level assistant + tool results - if (ev.type === "turn_end") { - handleAssistant(ev.message); - if (Array.isArray(ev.toolResults)) { - for (const tr of ev.toolResults) handleToolResult(tr); - } - } - - // Agent-level summary of all messages - if (ev.type === "agent_end" && Array.isArray(ev.messages)) { - for (const msg of ev.messages) { - const role = msg?.role ?? ""; - if (role === "assistant") handleAssistant(msg); - else if (role.toLowerCase().includes("tool")) handleToolResult(msg); - } - } - - const role = ev.message?.role ?? ""; - const isAssistantMessage = - (ev.type === "message" || - ev.type === "message_end" || - ev.type === "message_start") && - role === "assistant"; - const isToolResult = - (ev.type === "message" || - ev.type === "message_end" || - ev.type === "message_start") && - typeof role === "string" && - role.toLowerCase().includes("tool"); - - if (isAssistantMessage) handleAssistant(ev.message); - if (isToolResult) handleToolResult(ev.message); - } catch { - // ignore malformed lines - } - } - - const meta: AgentMeta | undefined = lastAssistant - ? { - model: lastAssistant.model, - provider: lastAssistant.provider, - stopReason: lastAssistant.stopReason, - usage: normalizeUsage(lastAssistant.usage), - } - : undefined; - - return { - texts, - toolResults: toolResults.length ? toolResults : undefined, - meta, - }; -} - -function isPiInvocation(argv: string[]): boolean { - if (argv.length === 0) return false; - const base = path.basename(argv[0]).replace(/\.(m?js)$/i, ""); - if (base === "pi" || base === "tau") return true; - - // Also handle node entrypoints like `node /.../pi-mono/packages/coding-agent/dist/cli.js` - if (base === "node" && argv.length > 1) { - const second = argv[1]?.toString().toLowerCase(); - return ( - second.includes("pi-mono") && - second.includes("packages") && - second.includes("coding-agent") && - (second.endsWith("cli.js") || second.includes("/dist/cli")) - ); - } - - return false; -} - -export const piSpec: AgentSpec = { - kind: "pi", - isInvocation: isPiInvocation, - buildArgs: (ctx) => { - const argv = [...ctx.argv]; - if (!isPiInvocation(argv)) return argv; - let bodyPos = ctx.bodyIndex; - const modeIdx = argv.indexOf("--mode"); - const modeVal = - modeIdx >= 0 ? argv[modeIdx + 1]?.toString().toLowerCase() : undefined; - const isRpcMode = modeVal === "rpc"; - - const desiredProvider = (ctx.provider ?? DEFAULT_PROVIDER).trim(); - const desiredModel = (ctx.model ?? DEFAULT_MODEL).trim(); - const hasFlag = (flag: string) => - argv.includes(flag) || argv.some((a) => a.startsWith(`${flag}=`)); - - if (desiredProvider && !hasFlag("--provider")) { - argv.splice(bodyPos, 0, "--provider", desiredProvider); - bodyPos += 2; - } - if (desiredModel && !hasFlag("--model")) { - argv.splice(bodyPos, 0, "--model", desiredModel); - bodyPos += 2; - } - - // Non-interactive print + JSON - if (!isRpcMode && !argv.includes("-p") && !argv.includes("--print")) { - argv.splice(bodyPos, 0, "-p"); - bodyPos += 1; - } - if ( - ctx.format === "json" && - !argv.includes("--mode") && - !argv.some((a) => a === "--mode") - ) { - argv.splice(bodyPos, 0, "--mode", "json"); - bodyPos += 2; - } - // Session defaults - // Identity prefix optional; Pi usually doesn't need it, but allow injection - if (!(ctx.sendSystemOnce && ctx.systemSent) && argv[bodyPos]) { - const existingBody = argv[bodyPos]; - argv[bodyPos] = [ctx.identityPrefix, existingBody] - .filter(Boolean) - .join("\n\n"); - } - return argv; - }, - parseOutput: parsePiJson, -}; diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts new file mode 100644 index 000000000..a2efc18d6 --- /dev/null +++ b/src/agents/system-prompt.ts @@ -0,0 +1,84 @@ +import type { ThinkLevel } from "../auto-reply/thinking.js"; + +type BootstrapFile = { + name: "AGENTS.md" | "SOUL.md" | "TOOLS.md"; + path: string; + content?: string; + missing: boolean; +}; + +function formatBootstrapFile(file: BootstrapFile): string { + if (file.missing) { + return `## ${file.name}\n\n[MISSING] Expected at: ${file.path}`; + } + return `## ${file.name}\n\n${file.content ?? ""}`.trimEnd(); +} + +function describeBuiltInTools(): string { + // Keep this short and stable; TOOLS.md is for user-editable external tool notes. + return [ + "- read: read file contents", + "- bash: run shell commands", + "- edit: apply precise in-file replacements", + "- write: create/overwrite files", + ].join("\n"); +} + +function formatDateTime(now: Date): string { + return now.toLocaleString("en-US", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + timeZoneName: "short", + }); +} + +export function buildAgentSystemPrompt(params: { + workspaceDir: string; + bootstrapFiles: BootstrapFile[]; + now?: Date; + defaultThinkLevel?: ThinkLevel; +}) { + const now = params.now ?? new Date(); + const boot = params.bootstrapFiles.map(formatBootstrapFile).join("\n\n"); + + const thinkHint = + params.defaultThinkLevel && params.defaultThinkLevel !== "off" + ? `Default thinking level: ${params.defaultThinkLevel}.` + : "Default thinking level: off."; + + return [ + "You are Clawd, a personal assistant running inside Clawdis.", + "", + "## Built-in Tools (internal)", + "These tools are always available. TOOLS.md does not control tool availability; it is user guidance for how to use external tools.", + describeBuiltInTools(), + "", + "## Workspace", + `Your working directory is: ${params.workspaceDir}`, + "Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.", + "", + "## Workspace Files (injected)", + "These user-editable files are loaded by Clawdis and included here directly (no separate read step):", + boot, + "", + "## Messaging Safety", + "Never send streaming/partial replies to external messaging surfaces; only final replies should be delivered there.", + "", + "## Heartbeats", + 'If you receive a heartbeat poll (a user message containing just "HEARTBEAT"), and there is nothing that needs attention, reply exactly:', + "HEARTBEAT_OK", + 'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.', + "", + "## Runtime", + `Current date and time: ${formatDateTime(now)}`, + `Current working directory: ${params.workspaceDir}`, + thinkHint, + ] + .filter(Boolean) + .join("\n"); +} diff --git a/src/agents/types.ts b/src/agents/types.ts deleted file mode 100644 index a474ba3a4..000000000 --- a/src/agents/types.ts +++ /dev/null @@ -1,52 +0,0 @@ -export type AgentKind = "pi"; - -export type AgentMeta = { - model?: string; - provider?: string; - stopReason?: string; - sessionId?: string; - usage?: { - input?: number; - output?: number; - cacheRead?: number; - cacheWrite?: number; - total?: number; - }; - extra?: Record; -}; - -export type AgentToolResult = { - text: string; - toolName?: string; - meta?: string; -}; - -export type AgentParseResult = { - // Plural to support agents that emit multiple assistant turns per prompt. - texts?: string[]; - mediaUrls?: string[]; - toolResults?: Array; - meta?: AgentMeta; -}; - -export type BuildArgsContext = { - argv: string[]; - bodyIndex: number; // index of prompt/body argument in argv - isNewSession: boolean; - sessionId?: string; - provider?: string; - model?: string; - sendSystemOnce: boolean; - systemSent: boolean; - identityPrefix?: string; - format?: "text" | "json"; - sessionArgNew?: string[]; - sessionArgResume?: string[]; -}; - -export interface AgentSpec { - kind: AgentKind; - isInvocation: (argv: string[]) => boolean; - buildArgs: (ctx: BuildArgsContext) => string[]; - parseOutput: (rawStdout: string) => AgentParseResult; -} diff --git a/src/agents/workspace.test.ts b/src/agents/workspace.test.ts index f4ab1fa7f..4f1e33b8b 100644 --- a/src/agents/workspace.test.ts +++ b/src/agents/workspace.test.ts @@ -5,12 +5,12 @@ import { describe, expect, it } from "vitest"; import { ensureAgentWorkspace } from "./workspace.js"; describe("ensureAgentWorkspace", () => { - it("creates directory and AGENTS.md when missing", async () => { + it("creates directory and bootstrap files when missing", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-ws-")); const nested = path.join(dir, "nested"); const result = await ensureAgentWorkspace({ dir: nested, - ensureAgentsFile: true, + ensureBootstrapFiles: true, }); expect(result.dir).toBe(path.resolve(nested)); expect(result.agentsPath).toBe( @@ -26,7 +26,7 @@ describe("ensureAgentWorkspace", () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-ws-")); const agentsPath = path.join(dir, "AGENTS.md"); await fs.writeFile(agentsPath, "custom", "utf-8"); - await ensureAgentWorkspace({ dir, ensureAgentsFile: true }); + await ensureAgentWorkspace({ dir, ensureBootstrapFiles: true }); expect(await fs.readFile(agentsPath, "utf-8")).toBe("custom"); }); }); diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index 2650eb37e..ccf672641 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -1,10 +1,13 @@ import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; -import { CONFIG_DIR, resolveUserPath } from "../utils.js"; +import { resolveUserPath } from "../utils.js"; -export const DEFAULT_AGENT_WORKSPACE_DIR = path.join(CONFIG_DIR, "workspace"); +export const DEFAULT_AGENT_WORKSPACE_DIR = path.join(os.homedir(), "clawd"); export const DEFAULT_AGENTS_FILENAME = "AGENTS.md"; +export const DEFAULT_SOUL_FILENAME = "SOUL.md"; +export const DEFAULT_TOOLS_FILENAME = "TOOLS.md"; const DEFAULT_AGENTS_TEMPLATE = `# AGENTS.md — Clawdis Workspace @@ -20,21 +23,47 @@ This folder is the assistant’s working directory. - Customize this file with additional instructions for your assistant. `; -export async function ensureAgentWorkspace(params?: { - dir?: string; - ensureAgentsFile?: boolean; -}): Promise<{ dir: string; agentsPath?: string }> { - const rawDir = params?.dir?.trim() - ? params.dir.trim() - : DEFAULT_AGENT_WORKSPACE_DIR; - const dir = resolveUserPath(rawDir); - await fs.mkdir(dir, { recursive: true }); +const DEFAULT_SOUL_TEMPLATE = `# SOUL.md — Persona & Boundaries - if (!params?.ensureAgentsFile) return { dir }; +Describe who the assistant is, tone, and boundaries. - const agentsPath = path.join(dir, DEFAULT_AGENTS_FILENAME); +- Keep replies concise and direct. +- Ask clarifying questions when needed. +- Never send streaming/partial replies to external messaging surfaces. +`; + +const DEFAULT_TOOLS_TEMPLATE = `# TOOLS.md — User Tool Notes (editable) + +This file is for *your* notes about external tools and conventions. +It does not define which tools exist; Clawdis provides built-in tools internally. + +## Examples + +### imsg +- Send an iMessage/SMS: describe who/what, confirm before sending. +- Prefer short messages; avoid sending secrets. + +### sag +- Text-to-speech: specify voice, target speaker/room, and whether to stream. + +Add whatever else you want the assistant to know about your local toolchain. +`; + +export type WorkspaceBootstrapFileName = + | typeof DEFAULT_AGENTS_FILENAME + | typeof DEFAULT_SOUL_FILENAME + | typeof DEFAULT_TOOLS_FILENAME; + +export type WorkspaceBootstrapFile = { + name: WorkspaceBootstrapFileName; + path: string; + content?: string; + missing: boolean; +}; + +async function writeFileIfMissing(filePath: string, content: string) { try { - await fs.writeFile(agentsPath, DEFAULT_AGENTS_TEMPLATE, { + await fs.writeFile(filePath, content, { encoding: "utf-8", flag: "wx", }); @@ -42,5 +71,72 @@ export async function ensureAgentWorkspace(params?: { const anyErr = err as { code?: string }; if (anyErr.code !== "EEXIST") throw err; } - return { dir, agentsPath }; +} + +export async function ensureAgentWorkspace(params?: { + dir?: string; + ensureBootstrapFiles?: boolean; +}): Promise<{ + dir: string; + agentsPath?: string; + soulPath?: string; + toolsPath?: string; +}> { + const rawDir = params?.dir?.trim() + ? params.dir.trim() + : DEFAULT_AGENT_WORKSPACE_DIR; + const dir = resolveUserPath(rawDir); + await fs.mkdir(dir, { recursive: true }); + + if (!params?.ensureBootstrapFiles) return { dir }; + + const agentsPath = path.join(dir, DEFAULT_AGENTS_FILENAME); + const soulPath = path.join(dir, DEFAULT_SOUL_FILENAME); + const toolsPath = path.join(dir, DEFAULT_TOOLS_FILENAME); + + await writeFileIfMissing(agentsPath, DEFAULT_AGENTS_TEMPLATE); + await writeFileIfMissing(soulPath, DEFAULT_SOUL_TEMPLATE); + await writeFileIfMissing(toolsPath, DEFAULT_TOOLS_TEMPLATE); + + return { dir, agentsPath, soulPath, toolsPath }; +} + +export async function loadWorkspaceBootstrapFiles( + dir: string, +): Promise { + const resolvedDir = resolveUserPath(dir); + + const entries: Array<{ + name: WorkspaceBootstrapFileName; + filePath: string; + }> = [ + { + name: DEFAULT_AGENTS_FILENAME, + filePath: path.join(resolvedDir, DEFAULT_AGENTS_FILENAME), + }, + { + name: DEFAULT_SOUL_FILENAME, + filePath: path.join(resolvedDir, DEFAULT_SOUL_FILENAME), + }, + { + name: DEFAULT_TOOLS_FILENAME, + filePath: path.join(resolvedDir, DEFAULT_TOOLS_FILENAME), + }, + ]; + + const result: WorkspaceBootstrapFile[] = []; + for (const entry of entries) { + try { + const content = await fs.readFile(entry.filePath, "utf-8"); + result.push({ + name: entry.name, + path: entry.filePath, + content, + missing: false, + }); + } catch { + result.push({ name: entry.name, path: entry.filePath, missing: true }); + } + } + return result; } diff --git a/src/auto-reply/command-reply.test.ts b/src/auto-reply/command-reply.test.ts deleted file mode 100644 index 1e66cf6b5..000000000 --- a/src/auto-reply/command-reply.test.ts +++ /dev/null @@ -1,548 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - -import { afterEach, describe, expect, it, vi } from "vitest"; - -import * as tauRpc from "../process/tau-rpc.js"; -import { runCommandReply } from "./command-reply.js"; - -const noopTemplateCtx = { - Body: "hello", - BodyStripped: "hello", - SessionId: "sess", - IsNewSession: "true", -}; - -const enqueueImmediate = vi.fn( - async ( - task: () => Promise, - opts?: { onWait?: (ms: number, ahead: number) => void }, - ) => { - opts?.onWait?.(25, 2); - return task(); - }, -); - -function mockPiRpc(result: { - stdout: string; - stderr?: string; - code: number; - signal?: NodeJS.Signals | null; - killed?: boolean; -}) { - return vi - .spyOn(tauRpc, "runPiRpc") - .mockResolvedValue({ killed: false, signal: null, ...result }); -} - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("runCommandReply (pi)", () => { - it("injects pi flags and forwards prompt via RPC", async () => { - const rpcMock = mockPiRpc({ - stdout: - '{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"ok"}]}}', - stderr: "", - code: 0, - }); - - const { payloads } = await runCommandReply({ - reply: { - mode: "command", - command: ["pi", "{{Body}}"], - agent: { kind: "pi", format: "json" }, - }, - templatingCtx: noopTemplateCtx, - sendSystemOnce: false, - isNewSession: true, - isFirstTurnInSession: true, - systemSent: false, - timeoutMs: 1000, - timeoutSeconds: 1, - enqueue: enqueueImmediate, - thinkLevel: "medium", - }); - - const payload = payloads?.[0]; - expect(payload?.text).toBe("ok"); - - const call = rpcMock.mock.calls[0]?.[0]; - expect(call?.prompt).toBe("hello"); - expect(call?.argv).toContain("-p"); - expect(call?.argv).toContain("--mode"); - expect(call?.argv).toContain("rpc"); - expect(call?.argv).toContain("--thinking"); - expect(call?.argv).toContain("medium"); - }); - - it("sends the body via RPC even when the command omits {{Body}}", async () => { - const rpcMock = mockPiRpc({ - stdout: - '{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"ok"}]}}', - stderr: "", - code: 0, - }); - - await runCommandReply({ - reply: { - mode: "command", - command: ["pi", "--mode", "rpc", "--session", "/tmp/demo.jsonl"], - agent: { kind: "pi" }, - }, - templatingCtx: noopTemplateCtx, - sendSystemOnce: false, - isNewSession: true, - isFirstTurnInSession: true, - systemSent: false, - timeoutMs: 1000, - timeoutSeconds: 1, - enqueue: enqueueImmediate, - }); - - const call = rpcMock.mock.calls[0]?.[0]; - expect(call?.prompt).toBe("hello"); - expect( - (call?.argv ?? []).some((arg: string) => arg.includes("hello")), - ).toBe(false); - }); - - it("does not echo the user's prompt when the agent returns no assistant text", async () => { - const rpcMock = mockPiRpc({ - stdout: [ - '{"type":"agent_start"}', - '{"type":"turn_start"}', - '{"type":"message_start","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}', - '{"type":"message_end","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}', - // assistant emits nothing useful - '{"type":"agent_end"}', - ].join("\n"), - stderr: "", - code: 0, - }); - - const { payloads } = await runCommandReply({ - reply: { - mode: "command", - command: ["pi", "{{Body}}"], - agent: { kind: "pi" }, - }, - templatingCtx: { - ...noopTemplateCtx, - Body: "hello", - BodyStripped: "hello", - }, - sendSystemOnce: false, - isNewSession: true, - isFirstTurnInSession: true, - systemSent: false, - timeoutMs: 1000, - timeoutSeconds: 1, - enqueue: enqueueImmediate, - }); - - expect(rpcMock).toHaveBeenCalledOnce(); - expect(payloads?.length).toBe(1); - expect(payloads?.[0]?.text).toMatch(/no output/i); - expect(payloads?.[0]?.text).not.toContain("hello"); - }); - - it("does not echo the prompt even when the fallback text matches after stripping prefixes", async () => { - const rpcMock = mockPiRpc({ - stdout: [ - '{"type":"agent_start"}', - '{"type":"turn_start"}', - '{"type":"message_start","message":{"role":"user","content":[{"type":"text","text":"[Dec 5 22:52] https://example.com"}]}}', - '{"type":"message_end","message":{"role":"user","content":[{"type":"text","text":"[Dec 5 22:52] https://example.com"}]}}', - // No assistant content - '{"type":"agent_end"}', - ].join("\n"), - stderr: "", - code: 0, - }); - - const { payloads } = await runCommandReply({ - reply: { - mode: "command", - command: ["pi", "{{Body}}"], - agent: { kind: "pi" }, - }, - templatingCtx: { - ...noopTemplateCtx, - Body: "[Dec 5 22:52] https://example.com", - BodyStripped: "[Dec 5 22:52] https://example.com", - }, - sendSystemOnce: false, - isNewSession: true, - isFirstTurnInSession: true, - systemSent: false, - timeoutMs: 1000, - timeoutSeconds: 1, - enqueue: enqueueImmediate, - }); - - expect(rpcMock).toHaveBeenCalledOnce(); - expect(payloads?.length).toBe(1); - expect(payloads?.[0]?.text).toMatch(/no output/i); - expect(payloads?.[0]?.text).not.toContain("example.com"); - }); - - it("forwards tool events even when verbose is off", async () => { - const events: Array<{ stream: string; data: Record }> = []; - - vi.spyOn(tauRpc, "runPiRpc").mockImplementation( - async (opts: Parameters[0]) => { - opts.onEvent?.( - JSON.stringify({ - type: "tool_execution_start", - toolName: "bash", - toolCallId: "call-1", - args: { cmd: "echo 1" }, - }), - ); - opts.onEvent?.( - JSON.stringify({ - type: "message", - message: { - role: "tool_result", - toolCallId: "call-1", - content: [{ type: "text", text: "ok" }], - }, - }), - ); - return { - stdout: - '{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"done"}]}}', - stderr: "", - code: 0, - killed: false, - signal: null, - }; - }, - ); - - await runCommandReply({ - reply: { - mode: "command", - command: ["pi", "{{Body}}"], - agent: { kind: "pi" }, - }, - templatingCtx: noopTemplateCtx, - sendSystemOnce: false, - isNewSession: true, - isFirstTurnInSession: true, - systemSent: false, - timeoutMs: 1000, - timeoutSeconds: 1, - enqueue: enqueueImmediate, - onAgentEvent: (evt) => events.push(evt), - }); - - expect(events).toContainEqual({ - stream: "tool", - data: expect.objectContaining({ - phase: "start", - name: "bash", - toolCallId: "call-1", - }), - }); - expect(events).toContainEqual({ - stream: "tool", - data: expect.objectContaining({ phase: "result", toolCallId: "call-1" }), - }); - }); - - it("adds session args and --continue when resuming", async () => { - const rpcMock = mockPiRpc({ - stdout: - '{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"ok"}]}}', - stderr: "", - code: 0, - }); - - await runCommandReply({ - reply: { - mode: "command", - command: ["pi", "{{Body}}"], - agent: { kind: "pi" }, - session: {}, - }, - templatingCtx: { ...noopTemplateCtx, SessionId: "abc" }, - sendSystemOnce: true, - isNewSession: false, - isFirstTurnInSession: false, - systemSent: true, - timeoutMs: 1000, - timeoutSeconds: 1, - enqueue: enqueueImmediate, - }); - - const argv = rpcMock.mock.calls[0]?.[0]?.argv ?? []; - expect(argv).toContain("--session"); - expect(argv.some((a) => a.includes("abc"))).toBe(true); - expect(argv).toContain("--continue"); - }); - - it("returns timeout text with partial snippet", async () => { - vi.spyOn(tauRpc, "runPiRpc").mockRejectedValue({ - stdout: "partial output here", - killed: true, - signal: "SIGKILL", - }); - - const { payloads, meta } = await runCommandReply({ - reply: { - mode: "command", - command: ["pi", "hi"], - agent: { kind: "pi" }, - }, - templatingCtx: noopTemplateCtx, - sendSystemOnce: false, - isNewSession: true, - isFirstTurnInSession: true, - systemSent: false, - timeoutMs: 10, - timeoutSeconds: 1, - enqueue: enqueueImmediate, - }); - - const payload = payloads?.[0]; - expect(payload?.text).toContain("Command timed out after 1s"); - expect(payload?.text).toContain("partial output"); - expect(meta.killed).toBe(true); - }); - - it("collapses rpc deltas instead of emitting raw JSON spam", async () => { - mockPiRpc({ - stdout: [ - '{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"Hello"}}', - '{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":" world"}}', - ].join("\n"), - stderr: "", - code: 0, - }); - - const { payloads } = await runCommandReply({ - reply: { - mode: "command", - command: ["pi", "{{Body}}"], - agent: { kind: "pi" }, - }, - templatingCtx: noopTemplateCtx, - sendSystemOnce: false, - isNewSession: true, - isFirstTurnInSession: true, - systemSent: false, - timeoutMs: 1000, - timeoutSeconds: 1, - enqueue: enqueueImmediate, - }); - - expect(payloads?.[0]?.text).toBe("Hello world"); - }); - - it("falls back to assistant text when parseOutput yields nothing", async () => { - mockPiRpc({ - stdout: [ - '{"type":"agent_start"}', - '{"type":"turn_start"}', - '{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"Acknowledged."}]}}', - ].join("\n"), - stderr: "", - code: 0, - }); - // Force parser to return nothing so we exercise fallback. - const parseSpy = vi - .spyOn((await import("../agents/pi.js")).piSpec, "parseOutput") - .mockReturnValue({ texts: [], toolResults: [], meta: undefined }); - - const { payloads } = await runCommandReply({ - reply: { - mode: "command", - command: ["pi", "{{Body}}"], - agent: { kind: "pi" }, - }, - templatingCtx: noopTemplateCtx, - sendSystemOnce: false, - isNewSession: true, - isFirstTurnInSession: true, - systemSent: false, - timeoutMs: 1000, - timeoutSeconds: 1, - enqueue: enqueueImmediate, - }); - - parseSpy.mockRestore(); - expect(payloads?.[0]?.text).toBe("Acknowledged."); - }); - - it("parses assistant text from agent_end messages", async () => { - mockPiRpc({ - stdout: JSON.stringify({ - type: "agent_end", - messages: [ - { - role: "assistant", - content: [{ type: "text", text: "from agent_end" }], - model: "pi-1", - provider: "inflection", - usage: { - input: 1, - output: 1, - cacheRead: 0, - cacheWrite: 0, - total: 2, - }, - stopReason: "stop", - }, - ], - }), - stderr: "", - code: 0, - }); - - const { payloads } = await runCommandReply({ - reply: { - mode: "command", - command: ["pi", "{{Body}}"], - agent: { kind: "pi" }, - }, - templatingCtx: noopTemplateCtx, - sendSystemOnce: false, - isNewSession: true, - isFirstTurnInSession: true, - systemSent: false, - timeoutMs: 1000, - timeoutSeconds: 1, - enqueue: enqueueImmediate, - }); - - expect(payloads?.[0]?.text).toBe("from agent_end"); - }); - - it("does not leak JSON protocol frames when assistant emits no text", async () => { - mockPiRpc({ - stdout: [ - '{"type":"message_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"hmm"}],"usage":{"input":10,"output":5}}}', - ].join("\n"), - stderr: "", - code: 0, - }); - - const { payloads } = await runCommandReply({ - reply: { - mode: "command", - command: ["pi", "{{Body}}"], - agent: { kind: "pi" }, - }, - templatingCtx: noopTemplateCtx, - sendSystemOnce: false, - isNewSession: true, - isFirstTurnInSession: true, - systemSent: false, - timeoutMs: 1000, - timeoutSeconds: 1, - enqueue: enqueueImmediate, - }); - - expect(payloads?.[0]?.text).toMatch(/produced no output/i); - expect(payloads?.[0]?.text).not.toContain("message_end"); - expect(payloads?.[0]?.text).not.toContain('"type"'); - }); - - it("does not stream tool results when verbose is off", async () => { - const onPartial = vi.fn(); - mockPiRpc({ - stdout: [ - '{"type":"tool_execution_start","toolName":"bash","args":{"command":"ls"}}', - '{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"done"}]}}', - ].join("\n"), - stderr: "", - code: 0, - }); - - await runCommandReply({ - reply: { - mode: "command", - command: ["pi", "{{Body}}"], - agent: { kind: "pi" }, - }, - templatingCtx: noopTemplateCtx, - sendSystemOnce: false, - isNewSession: true, - isFirstTurnInSession: true, - systemSent: false, - timeoutMs: 1000, - timeoutSeconds: 1, - enqueue: enqueueImmediate, - onPartialReply: onPartial, - verboseLevel: "off", - }); - - expect(onPartial).not.toHaveBeenCalled(); - }); - - it("parses MEDIA tokens and respects mediaMaxMb for local files", async () => { - const tmp = path.join(os.tmpdir(), `clawdis-test-${Date.now()}.bin`); - const bigBuffer = Buffer.alloc(2 * 1024 * 1024, 1); - await fs.writeFile(tmp, bigBuffer); - - mockPiRpc({ - stdout: `hi\nMEDIA:${tmp}\nMEDIA:https://example.com/img.jpg`, - stderr: "", - code: 0, - }); - - const { payloads } = await runCommandReply({ - reply: { - mode: "command", - command: ["pi", "hi"], - mediaMaxMb: 1, - agent: { kind: "pi" }, - }, - templatingCtx: noopTemplateCtx, - sendSystemOnce: false, - isNewSession: true, - isFirstTurnInSession: true, - systemSent: false, - timeoutMs: 1000, - timeoutSeconds: 1, - enqueue: enqueueImmediate, - }); - - const payload = payloads?.[0]; - expect(payload?.mediaUrls).toEqual(["https://example.com/img.jpg"]); - await fs.unlink(tmp); - }); - - it("captures queue wait metrics and agent meta", async () => { - mockPiRpc({ - stdout: - '{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input":10,"output":5}}}', - stderr: "", - code: 0, - }); - - const { meta } = await runCommandReply({ - reply: { - mode: "command", - command: ["pi", "{{Body}}"], - agent: { kind: "pi" }, - }, - templatingCtx: noopTemplateCtx, - sendSystemOnce: false, - isNewSession: true, - isFirstTurnInSession: true, - systemSent: false, - timeoutMs: 100, - timeoutSeconds: 1, - enqueue: enqueueImmediate, - }); - - expect(meta.queuedMs).toBe(25); - expect(meta.queuedAhead).toBe(2); - expect((meta.agentMeta?.usage as { output?: number })?.output).toBe(5); - }); -}); diff --git a/src/auto-reply/command-reply.ts b/src/auto-reply/command-reply.ts deleted file mode 100644 index c39f5ed85..000000000 --- a/src/auto-reply/command-reply.ts +++ /dev/null @@ -1,1081 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import type { - AgentEvent, - AssistantMessage, - Message, -} from "@mariozechner/pi-ai"; -import { piSpec } from "../agents/pi.js"; -import type { AgentMeta, AgentToolResult } from "../agents/types.js"; -import type { ClawdisConfig } from "../config/config.js"; -import { isVerbose, logVerbose } from "../globals.js"; -import { emitAgentEvent } from "../infra/agent-events.js"; -import { logError } from "../logger.js"; -import { getChildLogger } from "../logging.js"; -import { splitMediaFromOutput } from "../media/parse.js"; -import { enqueueCommand } from "../process/command-queue.js"; -import { runPiRpc } from "../process/tau-rpc.js"; -import { resolveUserPath } from "../utils.js"; -import { applyTemplate, type TemplateContext } from "./templating.js"; -import { - formatToolAggregate, - shortenMeta, - shortenPath, - TOOL_RESULT_DEBOUNCE_MS, - TOOL_RESULT_FLUSH_COUNT, -} from "./tool-meta.js"; -import type { ReplyPayload } from "./types.js"; - -function stripStructuralPrefixes(text: string): string { - return text - .replace(/\[[^\]]+\]\s*/g, "") - .replace(/^[ \t]*[A-Za-z0-9+()\-_. ]+:\s*/gm, "") - .replace(/\s+/g, " ") - .trim(); -} - -function stripRpcNoise(raw: string): string { - // Drop rpc streaming scaffolding (toolcall deltas, audio buffer events) before parsing. - const lines = raw.split(/\n+/); - const kept: string[] = []; - for (const line of lines) { - try { - const evt = JSON.parse(line); - const type = evt?.type; - const msg = evt?.message ?? evt?.assistantMessageEvent; - const msgType = msg?.type; - const role = msg?.role; - - // Drop early lifecycle frames; we only want final assistant/tool outputs. - if (type === "message_start") continue; - - // RPC streaming emits one message_update per delta; skip them to avoid flooding fallbacks. - if (type === "message_update") continue; - - // Ignore toolcall delta chatter and input buffer append events. - if (msgType === "toolcall_delta") continue; - if (type === "input_audio_buffer.append") continue; - - // Preserve agent_end so piSpec.parseOutput can extract the final message set. - if (type === "agent_end") { - kept.push(line); - continue; - } - - // Keep only assistant/tool messages; drop agent_start/turn_start/user/etc. - const isAssistant = role === "assistant"; - const isToolRole = - typeof role === "string" && role.toLowerCase().includes("tool"); - if (!isAssistant && !isToolRole) continue; - - // Ignore assistant messages that have no text content unless they carry usage (final message_end often does). - if (msg?.role === "assistant" && Array.isArray(msg?.content)) { - const hasText = msg.content.some( - (c: unknown) => (c as { type?: string })?.type === "text", - ); - const hasUsage = - typeof msg?.usage === "object" && - (msg.usage?.input != null || msg.usage?.output != null); - if (!hasText && !hasUsage) continue; - } - } catch { - // not JSON; keep as-is - } - if (line.trim()) kept.push(line); - } - return kept.join("\n"); -} - -function extractRpcAssistantText(raw: string): string | undefined { - if (!raw.trim()) return undefined; - let deltaBuffer = ""; - let lastAssistant: string | undefined; - for (const line of raw.split(/\n+/)) { - try { - const evt = JSON.parse(line) as { - type?: string; - message?: { - role?: string; - content?: Array<{ type?: string; text?: string }>; - }; - assistantMessageEvent?: { - type?: string; - delta?: string; - content?: string; - }; - }; - if ( - evt.type === "message_end" && - evt.message?.role === "assistant" && - Array.isArray(evt.message.content) - ) { - const text = evt.message.content - .filter((c) => c?.type === "text" && typeof c.text === "string") - .map((c) => c.text as string) - .join("\n") - .trim(); - if (text) { - lastAssistant = text; - deltaBuffer = ""; - } - } - if (evt.type === "message_update" && evt.assistantMessageEvent) { - const evtType = evt.assistantMessageEvent.type; - if ( - evtType === "text_delta" || - evtType === "text_end" || - evtType === "text_start" - ) { - const chunk = - typeof evt.assistantMessageEvent.delta === "string" - ? evt.assistantMessageEvent.delta - : typeof evt.assistantMessageEvent.content === "string" - ? evt.assistantMessageEvent.content - : ""; - if (chunk) { - deltaBuffer += chunk; - lastAssistant = deltaBuffer; - } - } - } - } catch { - // ignore malformed/non-JSON lines - } - } - return lastAssistant?.trim() || undefined; -} - -function extractNonJsonText(raw: string): string | undefined { - const kept: string[] = []; - for (const line of raw.split(/\n+/)) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - JSON.parse(trimmed); - // JSON protocol frame → never surface directly. - } catch { - kept.push(line); - } - } - const text = kept.join("\n").trim(); - return text ? text : undefined; -} - -type CommandReplyConfig = NonNullable["reply"] & { - mode: "command"; -}; - -type EnqueueCommandFn = typeof enqueueCommand; - -type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high"; - -type CommandReplyParams = { - reply: CommandReplyConfig; - templatingCtx: TemplateContext; - sendSystemOnce: boolean; - isNewSession: boolean; - isFirstTurnInSession: boolean; - systemSent: boolean; - timeoutMs: number; - timeoutSeconds: number; - enqueue?: EnqueueCommandFn; - thinkLevel?: ThinkLevel; - verboseLevel?: "off" | "on"; - onPartialReply?: (payload: ReplyPayload) => Promise | void; - runId?: string; - onAgentEvent?: (evt: { - stream: string; - data: Record; - }) => void; -}; - -export type CommandReplyMeta = { - durationMs: number; - queuedMs?: number; - queuedAhead?: number; - exitCode?: number | null; - signal?: string | null; - killed?: boolean; - agentMeta?: AgentMeta; -}; - -export type CommandReplyResult = { - payloads?: ReplyPayload[]; - meta: CommandReplyMeta; -}; - -type ToolMessageLike = { - name?: string; - toolName?: string; - tool_call_id?: string; - toolCallId?: string; - role?: string; - details?: Record; - arguments?: Record; - content?: unknown; -}; - -function inferToolName(message?: ToolMessageLike): string | undefined { - if (!message) return undefined; - const candidates = [ - message.toolName, - message.name, - message.toolCallId, - message.tool_call_id, - ] - .map((c) => (typeof c === "string" ? c.trim() : "")) - .filter(Boolean); - if (candidates.length) return candidates[0]; - - if (message.role?.includes(":")) { - const suffix = message.role.split(":").slice(1).join(":").trim(); - if (suffix) return suffix; - } - return undefined; -} - -function inferToolMeta(message?: ToolMessageLike): string | undefined { - if (!message) return undefined; - // Special handling for edit tool: surface change kind + path + summary. - if ( - (message.toolName ?? message.name)?.toLowerCase?.() === "edit" || - message.role === "tool_result:edit" - ) { - const details = message.details ?? message.arguments; - const diff = - details && typeof details.diff === "string" ? details.diff : undefined; - - // Count added/removed lines to infer change kind. - let added = 0; - let removed = 0; - if (diff) { - for (const line of diff.split("\n")) { - const trimmed = line.trimStart(); - if (trimmed.startsWith("+++")) continue; - if (trimmed.startsWith("---")) continue; - if (trimmed.startsWith("+")) added += 1; - else if (trimmed.startsWith("-")) removed += 1; - } - } - let changeKind = "edit"; - if (added > 0 && removed > 0) changeKind = "insert+replace"; - else if (added > 0) changeKind = "insert"; - else if (removed > 0) changeKind = "delete"; - - // Try to extract a file path from content text or details.path. - const contentText = (() => { - const raw = (message as { content?: unknown })?.content; - if (!Array.isArray(raw)) return undefined; - const texts = raw - .map((c) => - typeof c === "string" - ? c - : typeof (c as { text?: unknown }).text === "string" - ? ((c as { text?: string }).text ?? "") - : "", - ) - .filter(Boolean); - return texts.join(" "); - })(); - - const pathFromDetails = - details && typeof details.path === "string" ? details.path : undefined; - const pathFromContent = - contentText?.match(/\s(?:in|at)\s+(\S+)/)?.[1] ?? undefined; - const pathVal = pathFromDetails ?? pathFromContent; - const shortPath = pathVal ? shortenMeta(pathVal) : undefined; - - // Pick a short summary from the first added line in the diff. - const summary = (() => { - if (!diff) return undefined; - const addedLine = diff - .split("\n") - .map((l) => l.trimStart()) - .find((l) => l.startsWith("+") && !l.startsWith("+++")); - if (!addedLine) return undefined; - const cleaned = addedLine.replace(/^\+\s*\d*\s*/, "").trim(); - if (!cleaned) return undefined; - const markdownStripped = cleaned.replace(/^[#>*-]\s*/, ""); - if (cleaned.startsWith("#")) { - return `Add ${markdownStripped}`; - } - return markdownStripped; - })(); - - const parts: string[] = [`→ ${changeKind}`]; - if (shortPath) parts.push(`@ ${shortPath}`); - if (summary) parts.push(`| ${summary}`); - return parts.join(" "); - } - - const details = message.details ?? message.arguments; - const pathVal = - details && typeof details.path === "string" ? details.path : undefined; - const offset = - details && typeof details.offset === "number" ? details.offset : undefined; - const limit = - details && typeof details.limit === "number" ? details.limit : undefined; - const command = - details && typeof details.command === "string" - ? details.command - : undefined; - - const formatPath = shortenPath; - - if (pathVal) { - const displayPath = formatPath(pathVal); - if (offset !== undefined && limit !== undefined) { - return `${displayPath}:${offset}-${offset + limit}`; - } - return displayPath; - } - if (command) return command; - return undefined; -} - -function normalizeToolResults( - toolResults?: Array, -): AgentToolResult[] { - if (!toolResults) return []; - return toolResults - .map((tr) => (typeof tr === "string" ? { text: tr } : tr)) - .map((tr) => ({ - text: (tr.text ?? "").trim(), - toolName: tr.toolName?.trim() || undefined, - meta: tr.meta ? shortenMeta(tr.meta) : undefined, - })) - .filter((tr) => tr.text.length > 0); -} - -export async function runCommandReply( - params: CommandReplyParams, -): Promise { - const logger = getChildLogger({ module: "command-reply" }); - const verboseLog = (msg: string) => { - logger.debug(msg); - if (isVerbose()) logVerbose(msg); - }; - - const { - reply, - templatingCtx, - sendSystemOnce, - isNewSession, - isFirstTurnInSession, - systemSent, - timeoutMs, - timeoutSeconds, - enqueue = enqueueCommand, - thinkLevel, - verboseLevel, - onPartialReply, - } = params; - - const resolvedCwd = - typeof reply.cwd === "string" && reply.cwd.trim() - ? resolveUserPath(reply.cwd) - : undefined; - if (resolvedCwd) { - try { - await fs.mkdir(resolvedCwd, { recursive: true }); - } catch (err) { - throw new Error( - `Failed to create reply.cwd directory (${resolvedCwd}): ${String(err)}`, - ); - } - } - - if (!reply.command?.length) { - throw new Error("reply.command is required for mode=command"); - } - const agentCfg = reply.agent ?? { kind: "pi" }; - const agent = piSpec; - const agentKind = "pi"; - const rawCommand = reply.command; - const hasBodyTemplate = rawCommand.some((part) => - /\{\{Body(Stripped)?\}\}/.test(part), - ); - let argv = rawCommand.map((part) => applyTemplate(part, templatingCtx)); - const templatePrefix = - reply.template && (!sendSystemOnce || isFirstTurnInSession || !systemSent) - ? applyTemplate(reply.template, templatingCtx) - : ""; - let prefixOffset = 0; - if (templatePrefix && argv.length > 0) { - argv = [argv[0], templatePrefix, ...argv.slice(1)]; - prefixOffset = 1; - } - - // Extract (or synthesize) the prompt body so RPC mode works even when the - // command array omits {{Body}} (common for tau --mode rpc configs). - let bodyArg: string | undefined; - if (hasBodyTemplate) { - const idx = rawCommand.findIndex((part) => - /\{\{Body(Stripped)?\}\}/.test(part), - ); - const templatedIdx = idx >= 0 ? idx + prefixOffset : -1; - if (templatedIdx >= 0 && templatedIdx < argv.length) { - bodyArg = argv.splice(templatedIdx, 1)[0]; - } - } - if (!bodyArg) { - bodyArg = templatingCtx.Body ?? templatingCtx.BodyStripped ?? ""; - } - - // Default body index is last arg after we append it below. - let bodyIndex = Math.max(argv.length, 0); - - const bodyMarker = `__clawdis_body__${Math.random().toString(36).slice(2)}`; - let sessionArgList: string[] = []; - let insertSessionBeforeBody = true; - - // Session args prepared (templated) and injected generically - if (reply.session) { - const defaultSessionDir = path.join(os.homedir(), ".clawdis", "sessions"); - const sessionPath = path.join(defaultSessionDir, "{{SessionId}}.jsonl"); - const defaultSessionArgs = { - newArgs: ["--session", sessionPath], - resumeArgs: ["--session", sessionPath], - }; - const defaultNew = defaultSessionArgs.newArgs; - const defaultResume = defaultSessionArgs.resumeArgs; - sessionArgList = ( - isNewSession - ? (reply.session.sessionArgNew ?? defaultNew) - : (reply.session.sessionArgResume ?? defaultResume) - ).map((p) => applyTemplate(p, templatingCtx)); - - // If we are writing session files, ensure the directory exists. - const sessionFlagIndex = sessionArgList.indexOf("--session"); - const sessionPathArg = - sessionFlagIndex >= 0 ? sessionArgList[sessionFlagIndex + 1] : undefined; - if (sessionPathArg && !sessionPathArg.includes("://")) { - const dir = path.dirname(sessionPathArg); - try { - await fs.mkdir(dir, { recursive: true }); - } catch { - // best-effort - } - } - - // Tau (pi agent) needs --continue to reload prior messages when resuming. - // Without it, pi starts from a blank state even though we pass the session file path. - if (!isNewSession && !sessionArgList.includes("--continue")) { - sessionArgList.push("--continue"); - } - - insertSessionBeforeBody = reply.session.sessionArgBeforeBody ?? true; - } - - if (insertSessionBeforeBody && sessionArgList.length) { - argv = [...argv, ...sessionArgList]; - } - - argv = [...argv, `${bodyMarker}${bodyArg}`]; - bodyIndex = argv.length - 1; - - if (!insertSessionBeforeBody && sessionArgList.length) { - argv = [...argv, ...sessionArgList]; - } - - if (thinkLevel && thinkLevel !== "off") { - const hasThinkingFlag = argv.some( - (p, i) => - p === "--thinking" || - (i > 0 && argv[i - 1] === "--thinking") || - p.startsWith("--thinking="), - ); - if (!hasThinkingFlag) { - argv.splice(bodyIndex, 0, "--thinking", thinkLevel); - bodyIndex += 2; - } - } - const builtArgv = agent.buildArgs({ - argv, - bodyIndex, - isNewSession, - sessionId: templatingCtx.SessionId, - provider: agentCfg.provider, - model: agentCfg.model, - sendSystemOnce, - systemSent, - identityPrefix: agentCfg.identityPrefix, - format: agentCfg.format, - }); - - const promptIndex = builtArgv.findIndex( - (arg) => typeof arg === "string" && arg.includes(bodyMarker), - ); - const promptArg: string = - promptIndex >= 0 - ? (builtArgv[promptIndex] as string).replace(bodyMarker, "") - : ((builtArgv[builtArgv.length - 1] as string | undefined) ?? ""); - - const finalArgv = builtArgv.map((arg, idx) => { - if (idx === promptIndex && typeof arg === "string") return promptArg; - return typeof arg === "string" ? arg.replace(bodyMarker, "") : arg; - }); - - // Drive pi via RPC stdin so auto-compaction and streaming run server-side. - let rpcArgv = finalArgv; - const bodyIdx = - promptIndex >= 0 ? promptIndex : Math.max(finalArgv.length - 1, 0); - rpcArgv = finalArgv.filter((_, idx) => idx !== bodyIdx); - const modeIdx = rpcArgv.indexOf("--mode"); - if (modeIdx >= 0 && rpcArgv[modeIdx + 1]) { - rpcArgv[modeIdx + 1] = "rpc"; - } else { - rpcArgv.push("--mode", "rpc"); - } - - logVerbose( - `Running command auto-reply: ${rpcArgv.join(" ")}${resolvedCwd ? ` (cwd: ${resolvedCwd})` : ""}`, - ); - logger.info( - { - agent: agentKind, - sessionId: templatingCtx.SessionId, - newSession: isNewSession, - cwd: resolvedCwd, - command: rpcArgv.slice(0, -1), // omit body to reduce noise - }, - "command auto-reply start", - ); - - const started = Date.now(); - let queuedMs: number | undefined; - let queuedAhead: number | undefined; - try { - let pendingToolName: string | undefined; - let pendingMetas: string[] = []; - let pendingTimer: NodeJS.Timeout | null = null; - let streamedAny = false; - const toolMetaById = new Map(); - const flushPendingTool = () => { - if (!onPartialReply) return; - if (!pendingToolName && pendingMetas.length === 0) return; - const text = formatToolAggregate(pendingToolName, pendingMetas); - const { text: cleanedText, mediaUrls: mediaFound } = - splitMediaFromOutput(text); - void onPartialReply({ - text: cleanedText, - mediaUrls: mediaFound?.length ? mediaFound : undefined, - } as ReplyPayload); - streamedAny = true; - pendingToolName = undefined; - pendingMetas = []; - if (pendingTimer) { - clearTimeout(pendingTimer); - pendingTimer = null; - } - }; - let lastStreamedAssistant: string | undefined; - const streamAssistantFinal = (msg?: AssistantMessage) => { - if (!onPartialReply || msg?.role !== "assistant") return; - const textBlocks = Array.isArray(msg.content) - ? (msg.content as Array<{ type?: string; text?: string }>) - .filter((c) => c?.type === "text" && typeof c.text === "string") - .map((c) => (c.text ?? "").trim()) - .filter(Boolean) - : []; - if (textBlocks.length === 0) return; - const combined = textBlocks.join("\n").trim(); - if (!combined || combined === lastStreamedAssistant) return; - lastStreamedAssistant = combined; - const { text: cleanedText, mediaUrls: mediaFound } = - splitMediaFromOutput(combined); - void onPartialReply({ - text: cleanedText, - mediaUrls: mediaFound?.length ? mediaFound : undefined, - } as ReplyPayload); - streamedAny = true; - }; - - const run = async () => { - const runId = params.runId ?? crypto.randomUUID(); - let body = promptArg ?? ""; - if (!body || !body.trim()) { - body = templatingCtx.Body ?? templatingCtx.BodyStripped ?? ""; - } - - const rpcPromptIndex = - promptIndex >= 0 ? promptIndex : finalArgv.length - 1; - logVerbose( - `pi rpc prompt (${body.length} chars): ${body.slice(0, 200).replace(/\n/g, "\\n")}`, - ); - // Build rpc args without the prompt body; force --mode rpc. - const rpcArgvForRun = (() => { - const copy = [...finalArgv]; - copy.splice(rpcPromptIndex, 1); - const modeIdx = copy.indexOf("--mode"); - if (modeIdx >= 0 && copy[modeIdx + 1]) { - copy.splice(modeIdx, 2, "--mode", "rpc"); - } else if (!copy.includes("--mode")) { - copy.splice(copy.length - 1, 0, "--mode", "rpc"); - } - return copy; - })(); - type RpcStreamEvent = - | AgentEvent - // Tau sometimes emits a bare "message" frame; treat it like message_end for parsing. - | { type: "message"; message: Message } - | { type: "message_end"; message: Message }; - - const rpcResult = await runPiRpc({ - argv: rpcArgvForRun, - cwd: resolvedCwd, - prompt: body, - timeoutMs, - onEvent: (line: string) => { - let ev: RpcStreamEvent; - try { - ev = JSON.parse(line) as RpcStreamEvent; - } catch { - return; - } - - // Forward tool lifecycle events to the agent bus. - if (ev.type === "tool_execution_start") { - emitAgentEvent({ - runId, - stream: "tool", - data: { - phase: "start", - name: ev.toolName, - toolCallId: ev.toolCallId, - args: ev.args, - }, - }); - params.onAgentEvent?.({ - stream: "tool", - data: { - phase: "start", - name: ev.toolName, - toolCallId: ev.toolCallId, - }, - }); - } - - if ( - "message" in ev && - ev.message && - (ev.type === "message" || ev.type === "message_end") - ) { - const msg = ev.message as Message & { - toolCallId?: string; - tool_call_id?: string; - }; - const role = (msg.role ?? "") as string; - const isToolResult = - role === "toolResult" || role === "tool_result"; - if (isToolResult && Array.isArray(msg.content)) { - const toolName = inferToolName(msg); - const toolCallId = msg.toolCallId ?? msg.tool_call_id; - const meta = - inferToolMeta(msg) ?? - (toolCallId ? toolMetaById.get(toolCallId) : undefined); - - emitAgentEvent({ - runId, - stream: "tool", - data: { - phase: "result", - name: toolName, - toolCallId, - meta, - }, - }); - params.onAgentEvent?.({ - stream: "tool", - data: { - phase: "result", - name: toolName, - toolCallId, - meta, - }, - }); - - if (pendingToolName && toolName && toolName !== pendingToolName) { - flushPendingTool(); - } - if (!pendingToolName) pendingToolName = toolName; - if (meta) pendingMetas.push(meta); - if ( - TOOL_RESULT_FLUSH_COUNT > 0 && - pendingMetas.length >= TOOL_RESULT_FLUSH_COUNT - ) { - flushPendingTool(); - return; - } - if (pendingTimer) clearTimeout(pendingTimer); - pendingTimer = setTimeout( - flushPendingTool, - TOOL_RESULT_DEBOUNCE_MS, - ); - return; - } - - if (msg.role === "assistant") { - streamAssistantFinal(msg as AssistantMessage); - } - } - - if ( - ev.type === "message_end" && - "message" in ev && - ev.message && - ev.message.role === "assistant" - ) { - streamAssistantFinal(ev.message as AssistantMessage); - const text = extractRpcAssistantText(line); - if (text) { - params.onAgentEvent?.({ - stream: "assistant", - data: { text }, - }); - } - } - - // Preserve existing partial reply hook when provided. - if ( - onPartialReply && - "message" in ev && - ev.message?.role === "assistant" - ) { - // Let the existing logic reuse the already-parsed message. - try { - streamAssistantFinal(ev.message as AssistantMessage); - } catch { - /* ignore */ - } - } - }, - }); - flushPendingTool(); - return rpcResult; - }; - - const { stdout, stderr, code, signal, killed } = await enqueue(run, { - onWait: (waitMs, ahead) => { - queuedMs = waitMs; - queuedAhead = ahead; - if (isVerbose()) { - logVerbose( - `Command auto-reply queued for ${waitMs}ms (${queuedAhead} ahead)`, - ); - } - }, - }); - const stdoutUsed = stdout; - const stderrUsed = stderr; - const codeUsed = code; - const signalUsed = signal; - const killedUsed = killed; - const rpcAssistantText = extractRpcAssistantText(stdoutUsed); - const rawStdout = stdoutUsed.trim(); - let mediaFromCommand: string[] | undefined; - const trimmed = stripRpcNoise(rawStdout); - if (stderrUsed?.trim()) { - logVerbose(`Command auto-reply stderr: ${stderrUsed.trim()}`); - } - - const logFailure = () => { - const truncate = (s?: string) => - s ? (s.length > 4000 ? `${s.slice(0, 4000)}…` : s) : undefined; - logger.warn( - { - code: codeUsed, - signal: signalUsed, - killed: killedUsed, - argv: finalArgv, - cwd: resolvedCwd, - stdout: truncate(rawStdout), - stderr: truncate(stderrUsed), - }, - "command auto-reply failed", - ); - }; - - const parsed = trimmed ? agent.parseOutput(trimmed) : undefined; - - // Collect assistant texts and tool results from parseOutput (tau RPC can emit many). - const parsedTexts = - parsed?.texts?.map((t) => t.trim()).filter(Boolean) ?? []; - const parsedToolResults = normalizeToolResults(parsed?.toolResults); - const hasParsedContent = - parsedTexts.length > 0 || parsedToolResults.length > 0; - - type ReplyItem = { text: string; media?: string[] }; - const replyItems: ReplyItem[] = []; - - const includeToolResultsInline = - verboseLevel === "on" && !onPartialReply && parsedToolResults.length > 0; - - if (includeToolResultsInline) { - const aggregated = parsedToolResults.reduce< - { toolName?: string; metas: string[]; previews: string[] }[] - >((acc, tr) => { - const last = acc.at(-1); - if (last && last.toolName === tr.toolName) { - if (tr.meta) last.metas.push(tr.meta); - if (tr.text) last.previews.push(tr.text); - } else { - acc.push({ - toolName: tr.toolName, - metas: tr.meta ? [tr.meta] : [], - previews: tr.text ? [tr.text] : [], - }); - } - return acc; - }, []); - - const emojiForTool = (tool?: string) => { - const t = (tool ?? "").toLowerCase(); - if (t === "bash" || t === "shell") return "💻"; - if (t === "read") return "📄"; - if (t === "write") return "✍️"; - if (t === "edit") return "📝"; - if (t === "attach") return "📎"; - return "🛠️"; - }; - - const stripToolPrefix = (text: string) => - text.replace(/^\[🛠️ [^\]]+\]\s*/, ""); - - const formatPreview = (texts: string[]) => { - const joined = texts.join(" ").trim(); - if (!joined) return ""; - const clipped = - joined.length > 120 ? `${joined.slice(0, 117)}…` : joined; - return ` — “${clipped}”`; - }; - - for (const tr of aggregated) { - const prefix = formatToolAggregate(tr.toolName, tr.metas); - const preview = formatPreview(tr.previews); - const decorated = `${emojiForTool(tr.toolName)} ${stripToolPrefix(prefix)}${preview}`; - const { text: cleanedText, mediaUrls: mediaFound } = - splitMediaFromOutput(decorated); - replyItems.push({ - text: cleanedText, - media: mediaFound?.length ? mediaFound : undefined, - }); - } - } - - for (const t of parsedTexts) { - const { text: cleanedText, mediaUrls: mediaFound } = - splitMediaFromOutput(t); - replyItems.push({ - text: cleanedText, - media: mediaFound?.length ? mediaFound : undefined, - }); - } - - // If parser gave nothing, fall back to best-effort assistant text (from RPC deltas), - // or any non-JSON stdout the child may have emitted (e.g. MEDIA tokens). - // Never fall back to raw stdout JSON protocol frames. - const fallbackText = rpcAssistantText ?? extractNonJsonText(rawStdout); - const normalize = (s?: string) => - stripStructuralPrefixes((s ?? "").trim()).toLowerCase(); - const bodyNorm = normalize( - templatingCtx.Body ?? templatingCtx.BodyStripped, - ); - const fallbackNorm = normalize(fallbackText); - const promptEcho = - fallbackText && - (fallbackText === (templatingCtx.Body ?? "") || - fallbackText === (templatingCtx.BodyStripped ?? "") || - (bodyNorm.length > 0 && bodyNorm === fallbackNorm)); - const safeFallbackText = promptEcho ? undefined : fallbackText; - - if (replyItems.length === 0 && safeFallbackText && !hasParsedContent) { - const { text: cleanedText, mediaUrls: mediaFound } = - splitMediaFromOutput(safeFallbackText); - if (cleanedText || mediaFound?.length) { - replyItems.push({ - text: cleanedText, - media: mediaFound?.length ? mediaFound : undefined, - }); - } - } - - // No content at all → fallback notice. - if (replyItems.length === 0) { - const meta = parsed?.meta?.extra?.summary ?? undefined; - replyItems.push({ - text: `(command produced no output${meta ? `; ${meta}` : ""})`, - }); - verboseLog("No text/media produced; injecting fallback notice to user"); - logFailure(); - } - - verboseLog( - `Command auto-reply stdout produced ${replyItems.length} message(s)`, - ); - const elapsed = Date.now() - started; - verboseLog(`Command auto-reply finished in ${elapsed}ms`); - logger.info( - { durationMs: elapsed, agent: agentKind, cwd: resolvedCwd }, - "command auto-reply finished", - ); - if ((codeUsed ?? 0) !== 0) { - logFailure(); - console.error( - `Command auto-reply exited with code ${codeUsed ?? "unknown"} (signal: ${signalUsed ?? "none"})`, - ); - // Include any partial output or stderr in error message - const summarySource = rpcAssistantText ?? trimmed; - const partialOut = summarySource - ? `\n\nOutput: ${summarySource.slice(0, 500)}${summarySource.length > 500 ? "..." : ""}` - : ""; - const errorText = `⚠️ Command exited with code ${codeUsed ?? "unknown"}${signalUsed ? ` (${signalUsed})` : ""}${partialOut}`; - return { - payloads: [{ text: errorText }], - meta: { - durationMs: Date.now() - started, - queuedMs, - queuedAhead, - exitCode: codeUsed, - signal: signalUsed, - killed: killedUsed, - agentMeta: parsed?.meta, - }, - }; - } - if (killedUsed && !signalUsed) { - console.error( - `Command auto-reply process killed before completion (exit code ${codeUsed ?? "unknown"})`, - ); - const errorText = `⚠️ Command was killed before completion (exit code ${codeUsed ?? "unknown"})`; - return { - payloads: [{ text: errorText }], - meta: { - durationMs: Date.now() - started, - queuedMs, - queuedAhead, - exitCode: codeUsed, - signal: signalUsed, - killed: killedUsed, - agentMeta: parsed?.meta, - }, - }; - } - const meta: CommandReplyMeta = { - durationMs: Date.now() - started, - queuedMs, - queuedAhead, - exitCode: codeUsed, - signal: signalUsed, - killed: killedUsed, - agentMeta: parsed?.meta, - }; - - const payloads: ReplyPayload[] = []; - - // Build each reply item sequentially (delivery handled by caller). - for (const item of replyItems) { - let mediaUrls = - item.media ?? - mediaFromCommand ?? - (reply.mediaUrl ? [reply.mediaUrl] : undefined); - - // If mediaMaxMb is set, skip local media paths larger than the cap. - if (mediaUrls?.length && reply.mediaMaxMb) { - const maxBytes = reply.mediaMaxMb * 1024 * 1024; - const filtered: string[] = []; - for (const url of mediaUrls) { - if (/^https?:\/\//i.test(url)) { - filtered.push(url); - continue; - } - const abs = path.isAbsolute(url) ? url : path.resolve(url); - try { - const stats = await fs.stat(abs); - if (stats.size <= maxBytes) { - filtered.push(url); - } else if (isVerbose()) { - logVerbose( - `Skipping media ${url} (${(stats.size / (1024 * 1024)).toFixed(2)}MB) over cap ${reply.mediaMaxMb}MB`, - ); - } - } catch { - filtered.push(url); - } - } - mediaUrls = filtered; - } - - const payload = - item.text || mediaUrls?.length - ? { - text: item.text || undefined, - mediaUrl: mediaUrls?.[0], - mediaUrls, - } - : undefined; - - if (payload) payloads.push(payload); - } - - verboseLog(`Command auto-reply meta: ${JSON.stringify(meta)}`); - return { payloads: streamedAny && onPartialReply ? [] : payloads, meta }; - } catch (err) { - const elapsed = Date.now() - started; - logger.info( - { durationMs: elapsed, agent: agentKind, cwd: resolvedCwd }, - "command auto-reply failed", - ); - const anyErr = err as { killed?: boolean; signal?: string }; - const timeoutHit = anyErr.killed === true || anyErr.signal === "SIGKILL"; - const errorObj = err as { stdout?: string; stderr?: string }; - if (errorObj.stderr?.trim()) { - verboseLog(`Command auto-reply stderr: ${errorObj.stderr.trim()}`); - } - if (timeoutHit) { - console.error( - `Command auto-reply timed out after ${elapsed}ms (limit ${timeoutMs}ms)`, - ); - const baseMsg = - "Command timed out after " + - `${timeoutSeconds}s${resolvedCwd ? ` (cwd: ${resolvedCwd})` : ""}. Try a shorter prompt or split the request.`; - const partial = - extractRpcAssistantText(errorObj.stdout ?? "") || - extractNonJsonText(errorObj.stdout ?? ""); - const partialSnippet = - partial && partial.length > 800 - ? `${partial.slice(0, 800)}...` - : partial; - const text = partialSnippet - ? `${baseMsg}\n\nPartial output before timeout:\n${partialSnippet}` - : baseMsg; - return { - payloads: [{ text }], - meta: { - durationMs: elapsed, - queuedMs, - queuedAhead, - exitCode: undefined, - signal: anyErr.signal, - killed: anyErr.killed, - }, - }; - } - logError(`Command auto-reply failed after ${elapsed}ms: ${String(err)}`); - // Send error message to user so they know the command failed - const errMsg = err instanceof Error ? err.message : String(err); - const errorText = `⚠️ Command failed: ${errMsg}`; - return { - payloads: [{ text: errorText }], - meta: { - durationMs: elapsed, - queuedMs, - queuedAhead, - exitCode: undefined, - signal: anyErr.signal, - killed: anyErr.killed, - }, - }; - } -} diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index 32d0561cd..ab1f186e4 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -1,11 +1,32 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + import { afterEach, describe, expect, it, vi } from "vitest"; -import * as tauRpc from "../process/tau-rpc.js"; + +vi.mock("../agents/pi-embedded.js", () => ({ + runEmbeddedPiAgent: vi.fn(), +})); + +import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { extractThinkDirective, extractVerboseDirective, getReplyFromConfig, } from "./reply.js"; +async function withTempHome(fn: (home: string) => Promise): Promise { + const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-reply-")); + const previousHome = process.env.HOME; + process.env.HOME = base; + try { + return await fn(base); + } finally { + process.env.HOME = previousHome; + await fs.rm(base, { recursive: true, force: true }); + } +} + describe("directive parsing", () => { afterEach(() => { vi.restoreAllMocks(); @@ -44,66 +65,57 @@ describe("directive parsing", () => { }); it("applies inline think and still runs agent content", async () => { - const rpcMock = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({ - stdout: - '{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"done"}]}}', - stderr: "", - code: 0, - signal: null, - killed: false, - }); + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "done" }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); - const res = await getReplyFromConfig( - { - Body: "please sync /think:high now", - From: "+1004", - To: "+2000", - }, - {}, - { - inbound: { - allowFrom: ["*"], - reply: { - mode: "command", - command: ["pi", "{{Body}}"], - agent: { kind: "pi" }, - session: {}, + const res = await getReplyFromConfig( + { + Body: "please sync /think:high now", + From: "+1004", + To: "+2000", + }, + {}, + { + inbound: { + allowFrom: ["*"], + workspace: path.join(home, "clawd"), + agent: { provider: "anthropic", model: "claude-opus-4-5" }, + session: { store: path.join(home, "sessions.json") }, }, }, - }, - ); + ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toBe("done"); - expect(rpcMock).toHaveBeenCalledOnce(); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toBe("done"); + expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); + }); }); it("acks verbose directive immediately with system marker", async () => { - const rpcMock = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({ - stdout: "", - stderr: "", - code: 0, - signal: null, - killed: false, - }); + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); - const res = await getReplyFromConfig( - { Body: "/verbose on", From: "+1222", To: "+1222" }, - {}, - { - inbound: { - reply: { - mode: "command", - command: ["pi", "{{Body}}"], - agent: { kind: "pi" }, - session: {}, + const res = await getReplyFromConfig( + { Body: "/verbose on", From: "+1222", To: "+1222" }, + {}, + { + inbound: { + workspace: path.join(home, "clawd"), + agent: { provider: "anthropic", model: "claude-opus-4-5" }, + session: { store: path.join(home, "sessions.json") }, }, }, - }, - ); + ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toMatch(/^⚙️ Verbose logging enabled\./); - expect(rpcMock).not.toHaveBeenCalled(); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toMatch(/^⚙️ Verbose logging enabled\./); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index 60916ec69..21f8ced3f 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -1,9 +1,13 @@ +import fs from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import * as tauRpc from "../process/tau-rpc.js"; -import * as commandReply from "./command-reply.js"; +vi.mock("../agents/pi-embedded.js", () => ({ + runEmbeddedPiAgent: vi.fn(), +})); + +import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { getReplyFromConfig } from "./reply.js"; const webMocks = vi.hoisted(() => ({ @@ -14,16 +18,29 @@ const webMocks = vi.hoisted(() => ({ vi.mock("../web/session.js", () => webMocks); -const baseCfg = { - inbound: { - allowFrom: ["*"], - reply: { - mode: "command" as const, - command: ["echo", "{{Body}}"], - session: undefined, +async function withTempHome(fn: (home: string) => Promise): Promise { + const base = await fs.mkdtemp(join(tmpdir(), "clawdis-triggers-")); + const previousHome = process.env.HOME; + process.env.HOME = base; + try { + vi.mocked(runEmbeddedPiAgent).mockClear(); + return await fn(base); + } finally { + process.env.HOME = previousHome; + await fs.rm(base, { recursive: true, force: true }); + } +} + +function makeCfg(home: string) { + return { + inbound: { + allowFrom: ["*"], + workspace: join(home, "clawd"), + agent: { provider: "anthropic", model: "claude-opus-4-5" }, + session: { store: join(home, "sessions.json") }, }, - }, -}; + }; +} afterEach(() => { vi.restoreAllMocks(); @@ -31,146 +48,142 @@ afterEach(() => { describe("trigger handling", () => { it("aborts even with timestamp prefix", async () => { - const commandSpy = vi.spyOn(commandReply, "runCommandReply"); - const res = await getReplyFromConfig( - { - Body: "[Dec 5 10:00] stop", - From: "+1000", - To: "+2000", - }, - {}, - baseCfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toBe("⚙️ Agent was aborted."); - expect(commandSpy).not.toHaveBeenCalled(); + await withTempHome(async (home) => { + const res = await getReplyFromConfig( + { + Body: "[Dec 5 10:00] stop", + From: "+1000", + To: "+2000", + }, + {}, + makeCfg(home), + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toBe("⚙️ Agent was aborted."); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); }); it("restarts even with prefix/whitespace", async () => { - const commandSpy = vi.spyOn(commandReply, "runCommandReply"); - const res = await getReplyFromConfig( - { - Body: " [Dec 5] /restart", - From: "+1001", - To: "+2000", - }, - {}, - baseCfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text?.startsWith("⚙️ Restarting" ?? "")).toBe(true); - expect(commandSpy).not.toHaveBeenCalled(); + await withTempHome(async (home) => { + const res = await getReplyFromConfig( + { + Body: " [Dec 5] /restart", + From: "+1001", + To: "+2000", + }, + {}, + makeCfg(home), + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text?.startsWith("⚙️ Restarting" ?? "")).toBe(true); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); }); it("reports status without invoking the agent", async () => { - const commandSpy = vi.spyOn(commandReply, "runCommandReply"); - const res = await getReplyFromConfig( - { - Body: "/status", - From: "+1002", - To: "+2000", - }, - {}, - baseCfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Status"); - expect(commandSpy).not.toHaveBeenCalled(); + await withTempHome(async (home) => { + const res = await getReplyFromConfig( + { + Body: "/status", + From: "+1002", + To: "+2000", + }, + {}, + makeCfg(home), + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Status"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); }); it("acknowledges a bare /new without treating it as empty", async () => { - const commandSpy = vi.spyOn(commandReply, "runCommandReply"); - const res = await getReplyFromConfig( - { - Body: "/new", - From: "+1003", - To: "+2000", - }, - {}, - { - inbound: { - allowFrom: ["*"], - reply: { - mode: "command", - command: ["echo", "{{Body}}"], + await withTempHome(async (home) => { + const res = await getReplyFromConfig( + { + Body: "/new", + From: "+1003", + To: "+2000", + }, + {}, + { + inbound: { + allowFrom: ["*"], + workspace: join(home, "clawd"), + agent: { provider: "anthropic", model: "claude-opus-4-5" }, session: { store: join(tmpdir(), `clawdis-session-test-${Date.now()}.json`), }, }, }, - }, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toMatch(/fresh session/i); - expect(commandSpy).not.toHaveBeenCalled(); + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toMatch(/fresh session/i); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); }); it("ignores think directives that only appear in the context wrapper", async () => { - const rpcMock = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({ - stdout: - '{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"ok"}]}}', - stderr: "", - code: 0, - signal: null, - killed: false, + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + const res = await getReplyFromConfig( + { + Body: [ + "[Chat messages since your last reply - for context]", + "Peter: /thinking high [2025-12-05T21:45:00.000Z]", + "", + "[Current message - respond to this]", + "Give me the status", + ].join("\n"), + From: "+1002", + To: "+2000", + }, + {}, + makeCfg(home), + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toBe("ok"); + expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); + const prompt = + vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? ""; + expect(prompt).toContain("Give me the status"); + expect(prompt).not.toContain("/thinking high"); }); - - const res = await getReplyFromConfig( - { - Body: [ - "[Chat messages since your last reply - for context]", - "Peter: /thinking high [2025-12-05T21:45:00.000Z]", - "", - "[Current message - respond to this]", - "Give me the status", - ].join("\n"), - From: "+1002", - To: "+2000", - }, - {}, - baseCfg, - ); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toBe("ok"); - expect(rpcMock).toHaveBeenCalledOnce(); - const prompt = rpcMock.mock.calls[0]?.[0]?.prompt ?? ""; - expect(prompt).toContain("Give me the status"); - expect(prompt).not.toContain("/thinking high"); }); it("does not emit directive acks for heartbeats with /think", async () => { - const rpcMock = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({ - stdout: - '{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"ok"}]}}', - stderr: "", - code: 0, - signal: null, - killed: false, - }); - - const res = await getReplyFromConfig( - { - Body: "HEARTBEAT /think:high", - From: "+1003", - To: "+1003", - }, - { isHeartbeat: true }, - { - inbound: { - reply: { - mode: "command", - command: ["pi", "{{Body}}"], - agent: { kind: "pi" }, - session: {}, - }, + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, }, - }, - ); + }); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toBe("ok"); - expect(text).not.toMatch(/Thinking level set/i); - expect(rpcMock).toHaveBeenCalledOnce(); + const res = await getReplyFromConfig( + { + Body: "HEARTBEAT /think:high", + From: "+1003", + To: "+1003", + }, + { isHeartbeat: true }, + makeCfg(home), + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toBe("ok"); + expect(text).not.toMatch(/Thinking level set/i); + expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); + }); }); }); diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index e27d30fa7..cbde0231d 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -6,7 +6,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER, } from "../agents/defaults.js"; -import { resolveBundledPiBinary } from "../agents/pi-path.js"; +import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace, @@ -17,25 +17,20 @@ import { DEFAULT_RESET_TRIGGER, loadSessionStore, resolveSessionKey, + resolveSessionTranscriptPath, resolveStorePath, type SessionEntry, saveSessionStore, } from "../config/sessions.js"; -import { isVerbose, logVerbose } from "../globals.js"; +import { logVerbose } from "../globals.js"; import { buildProviderSummary } from "../infra/provider-summary.js"; import { triggerClawdisRestart } from "../infra/restart.js"; import { drainSystemEvents } from "../infra/system-events.js"; import { defaultRuntime } from "../runtime.js"; -import { resolveUserPath } from "../utils.js"; import { resolveHeartbeatSeconds } from "../web/reconnect.js"; import { getWebAuthAgeMs, webAuthExists } from "../web/session.js"; -import { runCommandReply } from "./command-reply.js"; import { buildStatusMessage } from "./status.js"; -import { - applyTemplate, - type MsgContext, - type TemplateContext, -} from "./templating.js"; +import type { MsgContext, TemplateContext } from "./templating.js"; import { normalizeThinkLevel, normalizeVerboseLevel, @@ -51,10 +46,6 @@ const ABORT_TRIGGERS = new Set(["stop", "esc", "abort", "wait", "exit"]); const ABORT_MEMORY = new Map(); const SYSTEM_MARK = "⚙️"; -type ReplyConfig = NonNullable["reply"]; - -type ResolvedReplyConfig = NonNullable; - export function extractThinkDirective(body?: string): { cleaned: string; thinkLevel?: ThinkLevel; @@ -147,63 +138,31 @@ function stripMentions( return result.replace(/\s+/g, " ").trim(); } -function makeDefaultPiReply(): ResolvedReplyConfig { - const piBin = resolveBundledPiBinary() ?? "pi"; - const defaultContext = - lookupContextTokens(DEFAULT_MODEL) ?? DEFAULT_CONTEXT_TOKENS; - return { - mode: "command" as const, - command: [piBin, "--mode", "rpc", "{{BodyStripped}}"], - agent: { - kind: "pi" as const, - provider: DEFAULT_PROVIDER, - model: DEFAULT_MODEL, - contextTokens: defaultContext, - format: "json" as const, - }, - session: { - scope: "per-sender" as const, - resetTriggers: [DEFAULT_RESET_TRIGGER], - idleMinutes: DEFAULT_IDLE_MINUTES, - }, - timeoutSeconds: 600, - }; -} - export async function getReplyFromConfig( ctx: MsgContext, opts?: GetReplyOptions, configOverride?: ClawdisConfig, ): Promise { - // Choose reply from config: static text or external command stdout. const cfg = configOverride ?? loadConfig(); - const workspaceDir = cfg.inbound?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; - const configuredReply = cfg.inbound?.reply as ResolvedReplyConfig | undefined; - const reply: ResolvedReplyConfig = configuredReply - ? { ...configuredReply, cwd: configuredReply.cwd ?? workspaceDir } - : { ...makeDefaultPiReply(), cwd: workspaceDir }; - const identity = cfg.identity; - if (identity?.name?.trim() && reply.session && !reply.session.sessionIntro) { - const name = identity.name.trim(); - const theme = identity.theme?.trim(); - const emoji = identity.emoji?.trim(); - const introParts = [ - `You are ${name}.`, - theme ? `Theme: ${theme}.` : undefined, - emoji ? `Your emoji is ${emoji}.` : undefined, - ].filter(Boolean); - reply.session = { ...reply.session, sessionIntro: introParts.join(" ") }; - } + const workspaceDirRaw = cfg.inbound?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; + const agentCfg = cfg.inbound?.agent; + const sessionCfg = cfg.inbound?.session; - // Bootstrap the workspace (and a starter AGENTS.md) only when we actually run from it. - if (reply.mode === "command" && typeof reply.cwd === "string") { - const resolvedWorkspace = resolveUserPath(workspaceDir); - const resolvedCwd = resolveUserPath(reply.cwd); - if (resolvedCwd === resolvedWorkspace) { - await ensureAgentWorkspace({ dir: workspaceDir, ensureAgentsFile: true }); - } - } - const timeoutSeconds = Math.max(reply.timeoutSeconds ?? 600, 1); + const provider = agentCfg?.provider?.trim() || DEFAULT_PROVIDER; + const model = agentCfg?.model?.trim() || DEFAULT_MODEL; + const contextTokens = + agentCfg?.contextTokens ?? + lookupContextTokens(model) ?? + DEFAULT_CONTEXT_TOKENS; + + // Bootstrap the workspace and the required files (AGENTS.md, SOUL.md, TOOLS.md). + const workspace = await ensureAgentWorkspace({ + dir: workspaceDirRaw, + ensureBootstrapFiles: true, + }); + const workspaceDir = workspace.dir; + + const timeoutSeconds = Math.max(agentCfg?.timeoutSeconds ?? 600, 1); const timeoutMs = timeoutSeconds * 1000; let started = false; const triggerTyping = async () => { @@ -216,11 +175,9 @@ export async function getReplyFromConfig( }; let typingTimer: NodeJS.Timeout | undefined; const typingIntervalMs = - reply?.mode === "command" - ? (reply.typingIntervalSeconds ?? - reply?.session?.typingIntervalSeconds ?? - 8) * 1000 - : 0; + (agentCfg?.typingIntervalSeconds ?? + sessionCfg?.typingIntervalSeconds ?? + 8) * 1000; const cleanupTyping = () => { if (typingTimer) { clearInterval(typingTimer); @@ -250,7 +207,6 @@ export async function getReplyFromConfig( } // Optional session handling (conversation reuse + /new resets) - const sessionCfg = reply?.session; const mainKey = sessionCfg?.mainKey ?? "main"; const resetTriggers = sessionCfg?.resetTriggers?.length ? sessionCfg.resetTriggers @@ -278,65 +234,63 @@ export async function getReplyFromConfig( .trim() .toLowerCase(); - if (sessionCfg) { - const rawBody = ctx.Body ?? ""; - const trimmedBody = rawBody.trim(); - // Timestamp/message prefixes (e.g. "[Dec 4 17:35] ") are added by the - // web inbox before we get here. They prevented reset triggers like "/new" - // from matching, so strip structural wrappers when checking for resets. - const strippedForReset = triggerBodyNormalized; - for (const trigger of resetTriggers) { - if (!trigger) continue; - if (trimmedBody === trigger || strippedForReset === trigger) { - isNewSession = true; - bodyStripped = ""; - break; - } - const triggerPrefix = `${trigger} `; - if ( - trimmedBody.startsWith(triggerPrefix) || - strippedForReset.startsWith(triggerPrefix) - ) { - isNewSession = true; - bodyStripped = strippedForReset.slice(trigger.length).trimStart(); - break; - } - } - - sessionKey = resolveSessionKey(sessionScope, ctx, mainKey); - sessionStore = loadSessionStore(storePath); - const entry = sessionStore[sessionKey]; - const idleMs = idleMinutes * 60_000; - const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs; - - if (!isNewSession && freshEntry) { - sessionId = entry.sessionId; - systemSent = entry.systemSent ?? false; - abortedLastRun = entry.abortedLastRun ?? false; - persistedThinking = entry.thinkingLevel; - persistedVerbose = entry.verboseLevel; - } else { - sessionId = crypto.randomUUID(); + const rawBody = ctx.Body ?? ""; + const trimmedBody = rawBody.trim(); + // Timestamp/message prefixes (e.g. "[Dec 4 17:35] ") are added by the + // web inbox before we get here. They prevented reset triggers like "/new" + // from matching, so strip structural wrappers when checking for resets. + const strippedForReset = triggerBodyNormalized; + for (const trigger of resetTriggers) { + if (!trigger) continue; + if (trimmedBody === trigger || strippedForReset === trigger) { isNewSession = true; - systemSent = false; - abortedLastRun = false; + bodyStripped = ""; + break; + } + const triggerPrefix = `${trigger} `; + if ( + trimmedBody.startsWith(triggerPrefix) || + strippedForReset.startsWith(triggerPrefix) + ) { + isNewSession = true; + bodyStripped = strippedForReset.slice(trigger.length).trimStart(); + break; } - - const baseEntry = !isNewSession && freshEntry ? entry : undefined; - sessionEntry = { - ...baseEntry, - sessionId, - updatedAt: Date.now(), - systemSent, - abortedLastRun, - // Persist previously stored thinking/verbose levels when present. - thinkingLevel: persistedThinking ?? baseEntry?.thinkingLevel, - verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel, - }; - sessionStore[sessionKey] = sessionEntry; - await saveSessionStore(storePath, sessionStore); } + sessionKey = resolveSessionKey(sessionScope, ctx, mainKey); + sessionStore = loadSessionStore(storePath); + const entry = sessionStore[sessionKey]; + const idleMs = idleMinutes * 60_000; + const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs; + + if (!isNewSession && freshEntry) { + sessionId = entry.sessionId; + systemSent = entry.systemSent ?? false; + abortedLastRun = entry.abortedLastRun ?? false; + persistedThinking = entry.thinkingLevel; + persistedVerbose = entry.verboseLevel; + } else { + sessionId = crypto.randomUUID(); + isNewSession = true; + systemSent = false; + abortedLastRun = false; + } + + const baseEntry = !isNewSession && freshEntry ? entry : undefined; + sessionEntry = { + ...baseEntry, + sessionId, + updatedAt: Date.now(), + systemSent, + abortedLastRun, + // Persist previously stored thinking/verbose levels when present. + thinkingLevel: persistedThinking ?? baseEntry?.thinkingLevel, + verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel, + }; + sessionStore[sessionKey] = sessionEntry; + await saveSessionStore(storePath, sessionStore); + const sessionCtx: TemplateContext = { ...ctx, BodyStripped: bodyStripped ?? ctx.Body, @@ -366,12 +320,12 @@ export async function getReplyFromConfig( let resolvedThinkLevel = inlineThink ?? (sessionEntry?.thinkingLevel as ThinkLevel | undefined) ?? - (reply?.thinkingDefault as ThinkLevel | undefined); + (agentCfg?.thinkingDefault as ThinkLevel | undefined); const resolvedVerboseLevel = inlineVerbose ?? (sessionEntry?.verboseLevel as VerboseLevel | undefined) ?? - (reply?.verboseDefault as VerboseLevel | undefined); + (agentCfg?.verboseDefault as VerboseLevel | undefined); const combinedDirectiveOnly = hasThinkDirective && @@ -565,7 +519,14 @@ export async function getReplyFromConfig( const webAuthAgeMs = getWebAuthAgeMs(); const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined); const statusText = buildStatusMessage({ - reply, + agent: { + provider, + model, + contextTokens, + thinkingDefault: agentCfg?.thinkingDefault, + verboseDefault: agentCfg?.verboseDefault, + }, + workspaceDir, sessionEntry, sessionKey, sessionScope, @@ -580,8 +541,7 @@ export async function getReplyFromConfig( return { text: statusText }; } - const abortRequested = - reply?.mode === "command" && isAbortTrigger(rawBodyNormalized); + const abortRequested = isAbortTrigger(rawBodyNormalized); if (abortRequested) { if (sessionEntry && sessionStore && sessionKey) { @@ -598,13 +558,7 @@ export async function getReplyFromConfig( await startTypingLoop(); - // Optional prefix injected before Body for templating/command prompts. - const sendSystemOnce = sessionCfg?.sendSystemOnce === true; const isFirstTurnInSession = isNewSession || !systemSent; - const sessionIntro = - isFirstTurnInSession && sessionCfg?.sessionIntro - ? applyTemplate(sessionCfg.sessionIntro ?? "", sessionCtx) - : ""; const groupIntro = isFirstTurnInSession && sessionCtx.ChatType === "group" ? (() => { @@ -624,9 +578,6 @@ export async function getReplyFromConfig( ); })() : ""; - const bodyPrefix = reply?.bodyPrefix - ? applyTemplate(reply.bodyPrefix ?? "", sessionCtx) - : ""; const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""; const baseBodyTrimmed = baseBody.trim(); const rawBodyTrimmed = (ctx.Body ?? "").trim(); @@ -648,19 +599,10 @@ export async function getReplyFromConfig( text: "I didn't receive any text in your message. Please resend or add a caption.", }; } - const abortedHint = - reply?.mode === "command" && abortedLastRun - ? "Note: The previous agent run was aborted by the user. Resume carefully or ask for clarification." - : ""; + const abortedHint = abortedLastRun + ? "Note: The previous agent run was aborted by the user. Resume carefully or ask for clarification." + : ""; let prefixedBodyBase = baseBody; - if (!sendSystemOnce || isFirstTurnInSession) { - prefixedBodyBase = bodyPrefix - ? `${bodyPrefix}${prefixedBodyBase}` - : prefixedBodyBase; - } - if (sessionIntro) { - prefixedBodyBase = `${sessionIntro}\n\n${prefixedBodyBase}`; - } if (groupIntro) { prefixedBodyBase = `${groupIntro}\n\n${prefixedBodyBase}`; } @@ -711,13 +653,7 @@ export async function getReplyFromConfig( prefixedBodyBase = `${block}\n\n${prefixedBodyBase}`; } } - if ( - sessionCfg && - sendSystemOnce && - isFirstTurnInSession && - sessionStore && - sessionKey - ) { + if (isFirstTurnInSession && sessionStore && sessionKey) { const current = sessionEntry ?? sessionStore[sessionKey] ?? { sessionId: sessionId ?? crypto.randomUUID(), @@ -734,20 +670,17 @@ export async function getReplyFromConfig( systemSent = true; } - const prefixedBody = - transcribedText && reply?.mode === "command" - ? [prefixedBodyBase, `Transcript:\n${transcribedText}`] - .filter(Boolean) - .join("\n\n") - : prefixedBodyBase; + const prefixedBody = transcribedText + ? [prefixedBodyBase, `Transcript:\n${transcribedText}`] + .filter(Boolean) + .join("\n\n") + : prefixedBodyBase; const mediaNote = ctx.MediaPath?.length ? `[media attached: ${ctx.MediaPath}${ctx.MediaType ? ` (${ctx.MediaType})` : ""}${ctx.MediaUrl ? ` | ${ctx.MediaUrl}` : ""}]` : undefined; - // For command prompts we prepend the media note so Pi sees it; text replies stay clean. - const mediaReplyHint = - mediaNote && reply?.mode === "command" - ? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body." - : undefined; + const mediaReplyHint = mediaNote + ? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body." + : undefined; let commandBody = mediaNote ? [mediaNote, mediaReplyHint, prefixedBody ?? ""] .filter(Boolean) @@ -764,169 +697,92 @@ export async function getReplyFromConfig( commandBody = parts.slice(1).join(" ").trim(); } } - const templatingCtx: TemplateContext = { - ...sessionCtx, - Body: commandBody, - BodyStripped: commandBody, - }; - if (!reply) { - logVerbose("No inbound.reply configured; skipping auto-reply"); - cleanupTyping(); - return undefined; - } - if (reply.mode === "text" && reply.text) { - await onReplyStart(); - logVerbose("Using text auto-reply from config"); - const result = { - text: applyTemplate(reply.text ?? "", templatingCtx), - mediaUrl: reply.mediaUrl, - }; - cleanupTyping(); - return result; - } + const sessionIdFinal = sessionId ?? crypto.randomUUID(); + const sessionFile = resolveSessionTranscriptPath(sessionIdFinal); - const isHeartbeat = opts?.isHeartbeat === true; + await onReplyStart(); - if (reply && reply.mode === "command") { - const heartbeatCommand = isHeartbeat - ? (reply as { heartbeatCommand?: string[] }).heartbeatCommand - : undefined; - const commandArgs = heartbeatCommand?.length - ? heartbeatCommand - : reply.command; + try { + const runId = crypto.randomUUID(); + const runResult = await runEmbeddedPiAgent({ + sessionId: sessionIdFinal, + sessionFile, + workspaceDir, + prompt: commandBody, + provider, + model, + thinkLevel: resolvedThinkLevel, + verboseLevel: resolvedVerboseLevel, + timeoutMs, + runId, + onPartialReply: opts?.onPartialReply + ? (payload) => + opts.onPartialReply?.({ + text: payload.text, + mediaUrls: payload.mediaUrls, + }) + : undefined, + }); - if (!commandArgs?.length) { - cleanupTyping(); - return undefined; - } + const payloadArray = runResult.payloads ?? []; + if (payloadArray.length === 0) return undefined; - await onReplyStart(); - const commandReply = { - ...reply, - command: commandArgs, - mode: "command" as const, - }; - try { - const runResult = await runCommandReply({ - reply: commandReply, - templatingCtx, - sendSystemOnce, - isNewSession, - isFirstTurnInSession, - systemSent, - timeoutMs, - timeoutSeconds, - thinkLevel: resolvedThinkLevel, - verboseLevel: resolvedVerboseLevel, - onPartialReply: opts?.onPartialReply, - }); - const payloadArray = runResult.payloads ?? []; - const meta = runResult.meta; - let finalPayloads = payloadArray; - if (!finalPayloads || finalPayloads.length === 0) { - return undefined; - } - if (sessionCfg && sessionStore && sessionKey) { - const returnedSessionId = meta.agentMeta?.sessionId; - // TODO: remove once pi-mono persists stable session ids for custom --session paths. - const allowMetaSessionId = false; - if ( - allowMetaSessionId && - returnedSessionId && - returnedSessionId !== sessionId - ) { - const entry = sessionEntry ?? - sessionStore[sessionKey] ?? { - sessionId: returnedSessionId, - updatedAt: Date.now(), - systemSent, - abortedLastRun, - }; + if (sessionStore && sessionKey) { + const usage = runResult.meta.agentMeta?.usage; + const modelUsed = + runResult.meta.agentMeta?.model ?? agentCfg?.model ?? DEFAULT_MODEL; + const contextTokensUsed = + agentCfg?.contextTokens ?? + lookupContextTokens(modelUsed) ?? + sessionEntry?.contextTokens ?? + DEFAULT_CONTEXT_TOKENS; + + if (usage) { + const entry = sessionEntry ?? sessionStore[sessionKey]; + if (entry) { + const input = usage.input ?? 0; + const output = usage.output ?? 0; + const promptTokens = + input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0); sessionEntry = { ...entry, - sessionId: returnedSessionId, + inputTokens: input, + outputTokens: output, + totalTokens: + promptTokens > 0 ? promptTokens : (usage.total ?? input), + model: modelUsed, + contextTokens: contextTokensUsed ?? entry.contextTokens, updatedAt: Date.now(), }; sessionStore[sessionKey] = sessionEntry; await saveSessionStore(storePath, sessionStore); - sessionId = returnedSessionId; - if (isVerbose()) { - logVerbose( - `Session id updated from agent meta: ${returnedSessionId} (store: ${storePath})`, - ); - } } - - const usage = meta.agentMeta?.usage; - const model = - meta.agentMeta?.model || - reply?.agent?.model || - sessionEntry?.model || - DEFAULT_MODEL; - const contextTokens = - reply?.agent?.contextTokens ?? - lookupContextTokens(model) ?? - sessionEntry?.contextTokens ?? - DEFAULT_CONTEXT_TOKENS; - - if (usage) { - const entry = sessionEntry ?? sessionStore[sessionKey]; - if (entry) { - const input = usage.input ?? 0; - const output = usage.output ?? 0; - const promptTokens = - input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0); - sessionEntry = { - ...entry, - inputTokens: input, - outputTokens: output, - // Track the effective prompt/context size (cached + uncached input). - totalTokens: - promptTokens > 0 ? promptTokens : (usage.total ?? input), - model, - contextTokens: contextTokens ?? entry.contextTokens, - updatedAt: Date.now(), - }; - sessionStore[sessionKey] = sessionEntry; - await saveSessionStore(storePath, sessionStore); - } - } else if (model || contextTokens) { - const entry = sessionEntry ?? sessionStore[sessionKey]; - if (entry) { - sessionEntry = { - ...entry, - model: model ?? entry.model, - contextTokens: contextTokens ?? entry.contextTokens, - }; - sessionStore[sessionKey] = sessionEntry; - await saveSessionStore(storePath, sessionStore); - } + } else if (modelUsed || contextTokensUsed) { + const entry = sessionEntry ?? sessionStore[sessionKey]; + if (entry) { + sessionEntry = { + ...entry, + model: modelUsed ?? entry.model, + contextTokens: contextTokensUsed ?? entry.contextTokens, + }; + sessionStore[sessionKey] = sessionEntry; + await saveSessionStore(storePath, sessionStore); } } - if (meta.agentMeta && isVerbose()) { - logVerbose(`Agent meta: ${JSON.stringify(meta.agentMeta)}`); - } - // If verbose is enabled and this is a new session, prepend a session hint. - const sessionIdHint = - resolvedVerboseLevel === "on" && isNewSession - ? (sessionId ?? - meta.agentMeta?.sessionId ?? - templatingCtx.SessionId ?? - "unknown") - : undefined; - if (sessionIdHint) { - finalPayloads = [ - { text: `🧭 New session: ${sessionIdHint}` }, - ...payloadArray, - ]; - } - return finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads; - } finally { - cleanupTyping(); } - } - cleanupTyping(); - return undefined; + // If verbose is enabled and this is a new session, prepend a session hint. + let finalPayloads = payloadArray; + if (resolvedVerboseLevel === "on" && isNewSession) { + finalPayloads = [ + { text: `🧭 New session: ${sessionIdFinal}` }, + ...payloadArray, + ]; + } + + return finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads; + } finally { + cleanupTyping(); + } } diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 5d2dd9be3..cad20ed1b 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -11,12 +11,7 @@ afterEach(() => { describe("buildStatusMessage", () => { it("summarizes agent readiness and context usage", () => { const text = buildStatusMessage({ - reply: { - mode: "command", - command: ["echo", "{{Body}}"], - agent: { kind: "pi", model: "pi:opus", contextTokens: 32_000 }, - session: { scope: "per-sender" }, - }, + agent: { provider: "anthropic", model: "pi:opus", contextTokens: 32_000 }, sessionEntry: { sessionId: "abc", updatedAt: 0, @@ -37,7 +32,7 @@ describe("buildStatusMessage", () => { }); expect(text).toContain("⚙️ Status"); - expect(text).toContain("Agent: ready"); + expect(text).toContain("Agent: embedded pi"); expect(text).toContain("Context: 16k/32k (50%)"); expect(text).toContain("Session: main"); expect(text).toContain("Web: linked"); @@ -46,71 +41,81 @@ describe("buildStatusMessage", () => { expect(text).toContain("verbose=off"); }); - it("handles missing agent command gracefully", () => { + it("handles missing agent config gracefully", () => { const text = buildStatusMessage({ - reply: { - mode: "command", - command: [], - session: { scope: "per-sender" }, - }, + agent: {}, sessionScope: "per-sender", webLinked: false, }); - expect(text).toContain("Agent: check"); - expect(text).toContain("not set"); + expect(text).toContain("Agent: embedded pi"); expect(text).toContain("Context:"); expect(text).toContain("Web: not linked"); }); - it("prefers cached prompt tokens from the session log", () => { + it("prefers cached prompt tokens from the session log", async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdis-status-")); - const storePath = path.join(dir, "sessions.json"); - const sessionId = "sess-1"; - const logPath = path.join(dir, `${sessionId}.jsonl`); + const previousHome = process.env.HOME; + process.env.HOME = dir; + try { + vi.resetModules(); + const { buildStatusMessage: buildStatusMessageDynamic } = await import( + "./status.js" + ); - fs.writeFileSync( - logPath, - [ - JSON.stringify({ - type: "message", - message: { - role: "assistant", - model: "claude-opus-4-5", - usage: { - input: 1, - output: 2, - cacheRead: 1000, - cacheWrite: 0, - totalTokens: 1003, + const storePath = path.join(dir, ".clawdis", "sessions", "sessions.json"); + const sessionId = "sess-1"; + const logPath = path.join( + dir, + ".clawdis", + "sessions", + `${sessionId}.jsonl`, + ); + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + + fs.writeFileSync( + logPath, + [ + JSON.stringify({ + type: "message", + message: { + role: "assistant", + model: "claude-opus-4-5", + usage: { + input: 1, + output: 2, + cacheRead: 1000, + cacheWrite: 0, + totalTokens: 1003, + }, }, - }, - }), - ].join("\n"), - "utf-8", - ); + }), + ].join("\n"), + "utf-8", + ); - const text = buildStatusMessage({ - reply: { - mode: "command", - command: ["echo", "{{Body}}"], - agent: { kind: "pi", model: "claude-opus-4-5", contextTokens: 32_000 }, - session: { scope: "per-sender" }, - }, - sessionEntry: { - sessionId, - updatedAt: 0, - totalTokens: 3, // would be wrong if cached prompt tokens exist - contextTokens: 32_000, - }, - sessionKey: "main", - sessionScope: "per-sender", - storePath, - webLinked: true, - }); + const text = buildStatusMessageDynamic({ + agent: { + provider: "anthropic", + model: "claude-opus-4-5", + contextTokens: 32_000, + }, + sessionEntry: { + sessionId, + updatedAt: 0, + totalTokens: 3, // would be wrong if cached prompt tokens exist + contextTokens: 32_000, + }, + sessionKey: "main", + sessionScope: "per-sender", + storePath, + webLinked: true, + }); - expect(text).toContain("Context: 1.0k/32k"); - - fs.rmSync(dir, { recursive: true, force: true }); + expect(text).toContain("Context: 1.0k/32k"); + } finally { + process.env.HOME = previousHome; + fs.rmSync(dir, { recursive: true, force: true }); + } }); }); diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index e8203fc87..0bbe9a257 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -1,7 +1,5 @@ -import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; -import path from "node:path"; import { lookupContextTokens } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js"; @@ -11,13 +9,18 @@ import { type UsageLike, } from "../agents/usage.js"; import type { ClawdisConfig } from "../config/config.js"; -import type { SessionEntry, SessionScope } from "../config/sessions.js"; +import { + resolveSessionTranscriptPath, + type SessionEntry, + type SessionScope, +} from "../config/sessions.js"; import type { ThinkLevel, VerboseLevel } from "./thinking.js"; -type ReplyConfig = NonNullable["reply"]; +type AgentConfig = NonNullable["agent"]; type StatusArgs = { - reply: ReplyConfig; + agent: AgentConfig; + workspaceDir?: string; sessionEntry?: SessionEntry; sessionKey?: string; sessionScope?: SessionScope; @@ -30,12 +33,6 @@ type StatusArgs = { heartbeatSeconds?: number; }; -type AgentProbe = { - ok: boolean; - detail: string; - label: string; -}; - const formatAge = (ms?: number | null) => { if (!ms || ms < 0) return "unknown"; const minutes = Math.round(ms / 60_000); @@ -57,49 +54,6 @@ const abbreviatePath = (p?: string) => { return p; }; -const probeAgentCommand = (command?: string[]): AgentProbe => { - const bin = command?.[0]; - if (!bin) { - return { ok: false, detail: "no command configured", label: "not set" }; - } - - const commandLabel = command - .slice(0, 3) - .map((c) => c.replace(/\{\{[^}]+}}/g, "{…}")) - .join(" ") - .concat(command.length > 3 ? " …" : ""); - - const looksLikePath = bin.includes("/") || bin.startsWith("."); - if (looksLikePath) { - const exists = fs.existsSync(bin); - return { - ok: exists, - detail: exists ? "binary found" : "binary missing", - label: commandLabel || bin, - }; - } - - try { - const res = spawnSync("which", [bin], { - encoding: "utf-8", - timeout: 1500, - }); - const found = - res.status === 0 && res.stdout ? res.stdout.split("\n")[0]?.trim() : ""; - return { - ok: Boolean(found), - detail: found || "not in PATH", - label: commandLabel || bin, - }; - } catch (err) { - return { - ok: false, - detail: `probe failed: ${String(err)}`, - label: commandLabel || bin, - }; - } -}; - const formatTokens = ( total: number | null | undefined, contextTokens: number | null, @@ -117,7 +71,6 @@ const formatTokens = ( const readUsageFromSessionLog = ( sessionId?: string, - storePath?: string, ): | { input: number; @@ -127,24 +80,10 @@ const readUsageFromSessionLog = ( model?: string; } | undefined => { - // Prefer the coding-agent session log (pi-mono) if present. - // Path resolution rules (priority): - // 1) Store directory sibling file .jsonl - // 2) PI coding agent dir: ~/.pi/agent/sessions/.jsonl + // Transcripts always live at: ~/.clawdis/sessions/.jsonl if (!sessionId) return undefined; - - const candidatePaths: string[] = []; - - if (storePath) { - const dir = path.dirname(storePath); - candidatePaths.push(path.join(dir, `${sessionId}.jsonl`)); - } - - const piDir = path.join(os.homedir(), ".pi", "agent", "sessions"); - candidatePaths.push(path.join(piDir, `${sessionId}.jsonl`)); - - const logPath = candidatePaths.find((p) => fs.existsSync(p)); - if (!logPath) return undefined; + const logPath = resolveSessionTranscriptPath(sessionId); + if (!fs.existsSync(logPath)) return undefined; try { const lines = fs.readFileSync(logPath, "utf-8").split(/\n+/); @@ -190,10 +129,10 @@ const readUsageFromSessionLog = ( export function buildStatusMessage(args: StatusArgs): string { const now = args.now ?? Date.now(); const entry = args.sessionEntry; - let model = entry?.model ?? args.reply?.agent?.model ?? DEFAULT_MODEL; + let model = entry?.model ?? args.agent?.model ?? DEFAULT_MODEL; let contextTokens = entry?.contextTokens ?? - args.reply?.agent?.contextTokens ?? + args.agent?.contextTokens ?? lookupContextTokens(model) ?? DEFAULT_CONTEXT_TOKENS; @@ -203,7 +142,7 @@ export function buildStatusMessage(args: StatusArgs): string { // Prefer prompt-size tokens from the session transcript when it looks larger // (cached prompt tokens are often missing from agent meta/store). - const logUsage = readUsageFromSessionLog(entry?.sessionId, args.storePath); + const logUsage = readUsageFromSessionLog(entry?.sessionId); if (logUsage) { const candidate = logUsage.promptTokens || logUsage.total; if (!totalTokens || totalTokens === 0 || candidate > totalTokens) { @@ -214,12 +153,10 @@ export function buildStatusMessage(args: StatusArgs): string { contextTokens = lookupContextTokens(logUsage.model) ?? contextTokens; } } - const agentProbe = probeAgentCommand(args.reply?.command); - const thinkLevel = - args.resolvedThink ?? args.reply?.thinkingDefault ?? "auto"; + const thinkLevel = args.resolvedThink ?? args.agent?.thinkingDefault ?? "off"; const verboseLevel = - args.resolvedVerbose ?? args.reply?.verboseDefault ?? "off"; + args.resolvedVerbose ?? args.agent?.verboseDefault ?? "off"; const webLine = (() => { if (args.webLinked === false) { @@ -251,7 +188,17 @@ export function buildStatusMessage(args: StatusArgs): string { const optionsLine = `Options: thinking=${thinkLevel} | verbose=${verboseLevel} (set with /think , /verbose on|off)`; - const agentLine = `Agent: ${agentProbe.ok ? "ready" : "check"} — ${agentProbe.label}${agentProbe.detail ? ` (${agentProbe.detail})` : ""}${model ? ` • model ${model}` : ""}`; + const modelLabel = args.agent?.provider?.trim() + ? `${args.agent.provider}/${args.agent?.model ?? model}` + : model + ? model + : "unknown"; + + const agentLine = `Agent: embedded pi • ${modelLabel}`; + + const workspaceLine = args.workspaceDir + ? `Workspace: ${abbreviatePath(args.workspaceDir)}` + : undefined; const helpersLine = "Shortcuts: /new reset | /restart relink"; @@ -259,6 +206,7 @@ export function buildStatusMessage(args: StatusArgs): string { "⚙️ Status", webLine, agentLine, + workspaceLine, contextLine, sessionLine, optionsLine, diff --git a/src/cli/program.ts b/src/cli/program.ts index 93e5b43ea..1dd590df1 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -20,6 +20,7 @@ import { agentCommand } from "../commands/agent.js"; import { healthCommand } from "../commands/health.js"; import { sendCommand } from "../commands/send.js"; import { sessionsCommand } from "../commands/sessions.js"; +import { setupCommand } from "../commands/setup.js"; import { statusCommand } from "../commands/status.js"; import { danger, info, setVerbose } from "../globals.js"; import { loginWeb, logoutWeb } from "../provider-web.js"; @@ -106,6 +107,25 @@ export function buildProgram() { `\n${chalk.bold.cyan("Examples:")}\n${fmtExamples}\n`, ); + program + .command("setup") + .description("Initialize ~/.clawdis/clawdis.json and the agent workspace") + .option( + "--workspace ", + "Agent workspace directory (default: ~/clawd; stored as inbound.workspace)", + ) + .action(async (opts) => { + try { + await setupCommand( + { workspace: opts.workspace as string | undefined }, + defaultRuntime, + ); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + program .command("login") .description("Link your personal WhatsApp via QR (web provider)") @@ -326,7 +346,7 @@ Examples: clawdis sessions --json # machine-readable output clawdis sessions --store ./tmp/sessions.json -Shows token usage per session when the agent reports it; set inbound.reply.agent.contextTokens to see % of your model window.`, +Shows token usage per session when the agent reports it; set inbound.agent.contextTokens to see % of your model window.`, ) .action(async (opts) => { setVerbose(Boolean(opts.verbose)); diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 3fefa054b..75ac4fa82 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -10,7 +10,12 @@ import { type MockInstance, vi, } from "vitest"; -import * as commandReply from "../auto-reply/command-reply.js"; + +vi.mock("../agents/pi-embedded.js", () => ({ + runEmbeddedPiAgent: vi.fn(), +})); + +import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import type { ClawdisConfig } from "../config/config.js"; import * as configModule from "../config/config.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -24,151 +29,153 @@ const runtime: RuntimeEnv = { }), }; -const runReplySpy = vi.spyOn(commandReply, "runCommandReply"); const configSpy = vi.spyOn(configModule, "loadConfig"); -function makeStorePath() { - return path.join( - os.tmpdir(), - `clawdis-agent-test-${Date.now()}-${Math.random()}.json`, - ); +async function withTempHome(fn: (home: string) => Promise): Promise { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "clawdis-agent-")); + const previousHome = process.env.HOME; + process.env.HOME = base; + try { + return await fn(base); + } finally { + process.env.HOME = previousHome; + fs.rmSync(base, { recursive: true, force: true }); + } } function mockConfig( + home: string, storePath: string, - replyOverrides?: Partial["reply"]>, + inboundOverrides?: Partial>, ) { configSpy.mockReturnValue({ inbound: { - reply: { - mode: "command", - command: ["echo", "{{Body}}"], - session: { - store: storePath, - sendSystemOnce: false, - }, - ...replyOverrides, - }, + workspace: path.join(home, "clawd"), + agent: { provider: "anthropic", model: "claude-opus-4-5" }, + session: { store: storePath, mainKey: "main" }, + ...inboundOverrides, }, }); } beforeEach(() => { vi.clearAllMocks(); - runReplySpy.mockResolvedValue({ + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ payloads: [{ text: "ok" }], - meta: { durationMs: 5 }, + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, }); }); describe("agentCommand", () => { it("creates a session entry when deriving from --to", async () => { - const store = makeStorePath(); - mockConfig(store); + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + mockConfig(home, store); - await agentCommand({ message: "hello", to: "+1555" }, runtime); + await agentCommand({ message: "hello", to: "+1555" }, runtime); - const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record< - string, - { sessionId: string } - >; - const entry = Object.values(saved)[0]; - expect(entry.sessionId).toBeTruthy(); + const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record< + string, + { sessionId: string } + >; + const entry = Object.values(saved)[0]; + expect(entry.sessionId).toBeTruthy(); + }); }); it("persists thinking and verbose overrides", async () => { - const store = makeStorePath(); - mockConfig(store); + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + mockConfig(home, store); - await agentCommand( - { message: "hi", to: "+1222", thinking: "high", verbose: "on" }, - runtime, - ); + await agentCommand( + { message: "hi", to: "+1222", thinking: "high", verbose: "on" }, + runtime, + ); - const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record< - string, - { thinkingLevel?: string; verboseLevel?: string } - >; - const entry = Object.values(saved)[0]; - expect(entry.thinkingLevel).toBe("high"); - expect(entry.verboseLevel).toBe("on"); + const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record< + string, + { thinkingLevel?: string; verboseLevel?: string } + >; + const entry = Object.values(saved)[0]; + expect(entry.thinkingLevel).toBe("high"); + expect(entry.verboseLevel).toBe("on"); - const callArgs = runReplySpy.mock.calls.at(-1)?.[0]; - expect(callArgs?.thinkLevel).toBe("high"); - expect(callArgs?.verboseLevel).toBe("on"); + const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; + expect(callArgs?.thinkLevel).toBe("high"); + expect(callArgs?.verboseLevel).toBe("on"); + }); }); it("resumes when session-id is provided", async () => { - const store = makeStorePath(); - fs.mkdirSync(path.dirname(store), { recursive: true }); - fs.writeFileSync( - store, - JSON.stringify( - { - foo: { - sessionId: "session-123", - updatedAt: Date.now(), - systemSent: true, + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + fs.mkdirSync(path.dirname(store), { recursive: true }); + fs.writeFileSync( + store, + JSON.stringify( + { + foo: { + sessionId: "session-123", + updatedAt: Date.now(), + systemSent: true, + }, }, - }, - null, - 2, - ), - ); - mockConfig(store); + null, + 2, + ), + ); + mockConfig(home, store); - await agentCommand( - { message: "resume me", sessionId: "session-123" }, - runtime, - ); + await agentCommand( + { message: "resume me", sessionId: "session-123" }, + runtime, + ); - const callArgs = runReplySpy.mock.calls.at(-1)?.[0]; - expect(callArgs?.isNewSession).toBe(false); - expect(callArgs?.templatingCtx.SessionId).toBe("session-123"); + const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; + expect(callArgs?.sessionId).toBe("session-123"); + }); }); it("prints JSON payload when requested", async () => { - runReplySpy.mockResolvedValue({ - payloads: [{ text: "json-reply", mediaUrl: "http://x.test/a.jpg" }], - meta: { durationMs: 42 }, + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "json-reply", mediaUrl: "http://x.test/a.jpg" }], + meta: { + durationMs: 42, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + const store = path.join(home, "sessions.json"); + mockConfig(home, store); + + await agentCommand({ message: "hi", to: "+1999", json: true }, runtime); + + const logged = (runtime.log as MockInstance).mock.calls.at( + -1, + )?.[0] as string; + const parsed = JSON.parse(logged) as { + payloads: Array<{ text: string; mediaUrl?: string | null }>; + meta: { durationMs: number }; + }; + expect(parsed.payloads[0].text).toBe("json-reply"); + expect(parsed.payloads[0].mediaUrl).toBe("http://x.test/a.jpg"); + expect(parsed.meta.durationMs).toBe(42); }); - const store = makeStorePath(); - mockConfig(store); - - await agentCommand({ message: "hi", to: "+1999", json: true }, runtime); - - const logged = (runtime.log as MockInstance).mock.calls.at( - -1, - )?.[0] as string; - const parsed = JSON.parse(logged) as { - payloads: Array<{ text: string; mediaUrl?: string }>; - meta: { durationMs: number }; - }; - expect(parsed.payloads[0].text).toBe("json-reply"); - expect(parsed.payloads[0].mediaUrl).toBe("http://x.test/a.jpg"); - expect(parsed.meta.durationMs).toBe(42); }); - it("builds command body without WhatsApp wrappers", async () => { - const store = makeStorePath(); - mockConfig(store, { - mode: "command", - command: ["echo", "{{Body}}"], - session: { - store, - sendSystemOnce: false, - sessionIntro: "Intro {{SessionId}}", - }, - bodyPrefix: "[pfx] ", + it("passes the message through as the agent prompt", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + mockConfig(home, store); + + await agentCommand({ message: "ping", to: "+1333" }, runtime); + + const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; + expect(callArgs?.prompt).toBe("ping"); }); - - await agentCommand({ message: "ping", to: "+1333" }, runtime); - - const callArgs = runReplySpy.mock.calls.at(-1)?.[0]; - const body = callArgs?.templatingCtx.Body as string; - expect(body.startsWith("Intro")).toBe(true); - expect(body).toContain("[pfx] ping"); - expect(body).not.toContain("WhatsApp"); - expect(body).not.toContain("MEDIA:"); }); }); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 342a3e9b9..e8163ec07 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -1,11 +1,17 @@ import crypto from "node:crypto"; -import { chunkText } from "../auto-reply/chunk.js"; -import { runCommandReply } from "../auto-reply/command-reply.js"; +import { lookupContextTokens } from "../agents/context.js"; import { - applyTemplate, - type MsgContext, - type TemplateContext, -} from "../auto-reply/templating.js"; + DEFAULT_CONTEXT_TOKENS, + DEFAULT_MODEL, + DEFAULT_PROVIDER, +} from "../agents/defaults.js"; +import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; +import { + DEFAULT_AGENT_WORKSPACE_DIR, + ensureAgentWorkspace, +} from "../agents/workspace.js"; +import { chunkText } from "../auto-reply/chunk.js"; +import type { MsgContext } from "../auto-reply/templating.js"; import { normalizeThinkLevel, normalizeVerboseLevel, @@ -18,6 +24,7 @@ import { DEFAULT_IDLE_MINUTES, loadSessionStore, resolveSessionKey, + resolveSessionTranscriptPath, resolveStorePath, type SessionEntry, saveSessionStore, @@ -37,7 +44,7 @@ type AgentCommandOpts = { timeout?: string; deliver?: boolean; surface?: string; - provider?: string; + provider?: string; // delivery provider (whatsapp|telegram|...) bestEffortDeliver?: boolean; }; @@ -46,31 +53,18 @@ type SessionResolution = { sessionKey?: string; sessionEntry?: SessionEntry; sessionStore?: Record; - storePath?: string; + storePath: string; isNewSession: boolean; - systemSent: boolean; persistedThinking?: ThinkLevel; persistedVerbose?: VerboseLevel; }; -function assertCommandConfig(cfg: ClawdisConfig) { - const reply = cfg.inbound?.reply; - if (!reply || reply.mode !== "command" || !reply.command?.length) { - throw new Error( - "Configure inbound.reply.mode=command with reply.command before using `clawdis agent`.", - ); - } - return reply as NonNullable< - NonNullable["reply"] - > & { mode: "command"; command: string[] }; -} - function resolveSession(opts: { + cfg: ClawdisConfig; to?: string; sessionId?: string; - replyCfg: NonNullable["reply"]>; }): SessionResolution { - const sessionCfg = opts.replyCfg?.session; + const sessionCfg = opts.cfg.inbound?.session; const scope = sessionCfg?.scope ?? "per-sender"; const mainKey = sessionCfg?.mainKey ?? "main"; const idleMinutes = Math.max( @@ -78,20 +72,20 @@ function resolveSession(opts: { 1, ); const idleMs = idleMinutes * 60_000; - const storePath = sessionCfg ? resolveStorePath(sessionCfg.store) : undefined; - const sessionStore = storePath ? loadSessionStore(storePath) : undefined; + const storePath = resolveStorePath(sessionCfg?.store); + const sessionStore = loadSessionStore(storePath); const now = Date.now(); - let sessionKey: string | undefined = - sessionStore && opts.to - ? resolveSessionKey(scope, { From: opts.to } as MsgContext, mainKey) - : undefined; - let sessionEntry = - sessionKey && sessionStore ? sessionStore[sessionKey] : undefined; + const ctx: MsgContext | undefined = opts.to?.trim() + ? { From: opts.to } + : undefined; + let sessionKey: string | undefined = ctx + ? resolveSessionKey(scope, ctx, mainKey) + : undefined; + let sessionEntry = sessionKey ? sessionStore[sessionKey] : undefined; // If a session id was provided, prefer to re-use its entry (by id) even when no key was derived. if ( - sessionStore && opts.sessionId && (!sessionEntry || sessionEntry.sessionId !== opts.sessionId) ) { @@ -104,52 +98,29 @@ function resolveSession(opts: { } } - let sessionId = opts.sessionId?.trim() || sessionEntry?.sessionId; - let isNewSession = false; - let systemSent = sessionEntry?.systemSent ?? false; - - if (!opts.sessionId) { - const fresh = sessionEntry && sessionEntry.updatedAt >= now - idleMs; - if (!sessionEntry || !fresh) { - sessionId = sessionId ?? crypto.randomUUID(); - isNewSession = true; - systemSent = false; - if (sessionCfg && sessionStore && sessionKey) { - sessionEntry = { - sessionId, - updatedAt: now, - abortedLastRun: sessionEntry?.abortedLastRun, - }; - } - } - } else { - sessionId = sessionId ?? crypto.randomUUID(); - isNewSession = false; - if (!sessionEntry && sessionCfg && sessionStore && sessionKey) { - sessionEntry = { - sessionId, - updatedAt: now, - }; - } - } + const fresh = sessionEntry && sessionEntry.updatedAt >= now - idleMs; + const sessionId = + opts.sessionId?.trim() || + (fresh ? sessionEntry?.sessionId : undefined) || + crypto.randomUUID(); + const isNewSession = !fresh && !opts.sessionId; const persistedThinking = - !isNewSession && sessionEntry + fresh && sessionEntry?.thinkingLevel ? normalizeThinkLevel(sessionEntry.thinkingLevel) : undefined; const persistedVerbose = - !isNewSession && sessionEntry + fresh && sessionEntry?.verboseLevel ? normalizeVerboseLevel(sessionEntry.verboseLevel) : undefined; return { - sessionId: sessionId ?? crypto.randomUUID(), + sessionId, sessionKey, sessionEntry, sessionStore, storePath, isNewSession, - systemSent, persistedThinking, persistedVerbose, }; @@ -161,16 +132,20 @@ export async function agentCommand( deps: CliDeps = createDefaultDeps(), ) { const body = (opts.message ?? "").trim(); - if (!body) { - throw new Error("Message (--message) is required"); - } + if (!body) throw new Error("Message (--message) is required"); if (!opts.to && !opts.sessionId) { throw new Error("Pass --to or --session-id to choose a session"); } const cfg = loadConfig(); - const replyCfg = assertCommandConfig(cfg); - const sessionCfg = replyCfg.session; + const agentCfg = cfg.inbound?.agent; + const workspaceDirRaw = cfg.inbound?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; + const workspace = await ensureAgentWorkspace({ + dir: workspaceDirRaw, + ensureBootstrapFiles: true, + }); + const workspaceDir = workspace.dir; + const allowFrom = (cfg.inbound?.allowFrom ?? []) .map((val) => normalizeE164(val)) .filter((val) => val.length > 1); @@ -187,6 +162,7 @@ export async function agentCommand( "Invalid one-shot thinking level. Use one of: off, minimal, low, medium, high.", ); } + const verboseOverride = normalizeVerboseLevel(opts.verbose); if (opts.verbose && !verboseOverride) { throw new Error('Invalid verbose level. Use "on" or "off".'); @@ -195,18 +171,18 @@ export async function agentCommand( const timeoutSecondsRaw = opts.timeout !== undefined ? Number.parseInt(String(opts.timeout), 10) - : (replyCfg.timeoutSeconds ?? 600); - const timeoutSeconds = Math.max(timeoutSecondsRaw, 1); + : (agentCfg?.timeoutSeconds ?? 600); if (Number.isNaN(timeoutSecondsRaw) || timeoutSecondsRaw <= 0) { throw new Error("--timeout must be a positive integer (seconds)"); } - const timeoutMs = timeoutSeconds * 1000; + const timeoutMs = Math.max(timeoutSecondsRaw, 1) * 1000; const sessionResolution = resolveSession({ + cfg, to: opts.to, sessionId: opts.sessionId, - replyCfg, }); + const { sessionId, sessionKey, @@ -214,88 +190,40 @@ export async function agentCommand( sessionStore, storePath, isNewSession, - systemSent: initialSystemSent, persistedThinking, persistedVerbose, } = sessionResolution; - let systemSent = initialSystemSent; - const sendSystemOnce = sessionCfg?.sendSystemOnce === true; - const isFirstTurnInSession = isNewSession || !systemSent; - - // Merge thinking/verbose levels: one-shot override > flag override > persisted > defaults. - const resolvedThinkLevel: ThinkLevel | undefined = + const resolvedThinkLevel = thinkOnce ?? thinkOverride ?? persistedThinking ?? - (replyCfg.thinkingDefault as ThinkLevel | undefined); - const resolvedVerboseLevel: VerboseLevel | undefined = + (agentCfg?.thinkingDefault as ThinkLevel | undefined); + const resolvedVerboseLevel = verboseOverride ?? persistedVerbose ?? - (replyCfg.verboseDefault as VerboseLevel | undefined); + (agentCfg?.verboseDefault as VerboseLevel | undefined); - // Persist overrides into the session store (mirrors directive-only flow). - if (sessionStore && sessionEntry && sessionKey && storePath) { - sessionEntry.updatedAt = Date.now(); + // Persist explicit /command overrides to the session store when we have a key. + if (sessionStore && sessionKey) { + const entry = sessionEntry ?? + sessionStore[sessionKey] ?? { sessionId, updatedAt: Date.now() }; + const next: SessionEntry = { ...entry, sessionId, updatedAt: Date.now() }; if (thinkOverride) { - if (thinkOverride === "off") { - delete sessionEntry.thinkingLevel; - } else { - sessionEntry.thinkingLevel = thinkOverride; - } - } else if (isNewSession) { - delete sessionEntry.thinkingLevel; + if (thinkOverride === "off") delete next.thinkingLevel; + else next.thinkingLevel = thinkOverride; } - if (verboseOverride) { - if (verboseOverride === "off") { - delete sessionEntry.verboseLevel; - } else { - sessionEntry.verboseLevel = verboseOverride; - } - } else if (isNewSession) { - delete sessionEntry.verboseLevel; + if (verboseOverride === "off") delete next.verboseLevel; + else next.verboseLevel = verboseOverride; } - - if (sendSystemOnce && isFirstTurnInSession) { - sessionEntry.systemSent = true; - systemSent = true; - } - - sessionStore[sessionKey] = sessionEntry; + sessionStore[sessionKey] = next; await saveSessionStore(storePath, sessionStore); } - const baseCtx: TemplateContext = { - Body: body, - BodyStripped: body, - From: opts.to, - SessionId: sessionId, - IsNewSession: isNewSession ? "true" : "false", - Surface: opts.surface, - }; - - const sessionIntro = - isFirstTurnInSession && sessionCfg?.sessionIntro - ? applyTemplate(sessionCfg.sessionIntro, baseCtx) - : ""; - const bodyPrefix = replyCfg.bodyPrefix - ? applyTemplate(replyCfg.bodyPrefix, baseCtx) - : ""; - - let commandBody = body; - if (!sendSystemOnce || isFirstTurnInSession) { - commandBody = bodyPrefix ? `${bodyPrefix}${commandBody}` : commandBody; - } - if (sessionIntro) { - commandBody = `${sessionIntro}\n\n${commandBody}`; - } - - const templatingCtx: TemplateContext = { - ...baseCtx, - Body: commandBody, - BodyStripped: commandBody, - }; + const provider = agentCfg?.provider?.trim() || DEFAULT_PROVIDER; + const model = agentCfg?.model?.trim() || DEFAULT_MODEL; + const sessionFile = resolveSessionTranscriptPath(sessionId); const startedAt = Date.now(); emitAgentEvent({ @@ -304,25 +232,24 @@ export async function agentCommand( data: { state: "started", startedAt, - to: opts.to, + to: opts.to ?? null, sessionId, isNewSession, }, }); - let result: Awaited>; + let result: Awaited>; try { - result = await runCommandReply({ - reply: { ...replyCfg, mode: "command" }, - templatingCtx, - sendSystemOnce, - isNewSession, - isFirstTurnInSession, - systemSent, - timeoutMs, - timeoutSeconds, + result = await runEmbeddedPiAgent({ + sessionId, + sessionFile, + workspaceDir, + prompt: body, + provider, + model, thinkLevel: resolvedThinkLevel, verboseLevel: resolvedVerboseLevel, + timeoutMs, runId: sessionId, onAgentEvent: (evt) => { emitAgentEvent({ @@ -339,7 +266,7 @@ export async function agentCommand( state: "done", startedAt, endedAt: Date.now(), - to: opts.to, + to: opts.to ?? null, sessionId, durationMs: Date.now() - startedAt, }, @@ -352,7 +279,7 @@ export async function agentCommand( state: "error", startedAt, endedAt: Date.now(), - to: opts.to, + to: opts.to ?? null, sessionId, durationMs: Date.now() - startedAt, error: String(err), @@ -361,50 +288,68 @@ export async function agentCommand( throw err; } - // If the agent returned a new session id, persist it. - const returnedSessionId = result.meta.agentMeta?.sessionId; - if ( - returnedSessionId && - returnedSessionId !== sessionId && - sessionStore && - sessionEntry && - sessionKey && - storePath - ) { - sessionEntry.sessionId = returnedSessionId; - sessionEntry.updatedAt = Date.now(); - sessionStore[sessionKey] = sessionEntry; + // Update token+model fields in the session store. + if (sessionStore && sessionKey) { + const usage = result.meta.agentMeta?.usage; + const modelUsed = result.meta.agentMeta?.model ?? model; + const contextTokens = + agentCfg?.contextTokens ?? + lookupContextTokens(modelUsed) ?? + DEFAULT_CONTEXT_TOKENS; + + const entry = sessionStore[sessionKey] ?? { + sessionId, + updatedAt: Date.now(), + }; + const next: SessionEntry = { + ...entry, + sessionId, + updatedAt: Date.now(), + model: modelUsed, + contextTokens, + }; + if (usage) { + const input = usage.input ?? 0; + const output = usage.output ?? 0; + const promptTokens = + input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0); + next.inputTokens = input; + next.outputTokens = output; + next.totalTokens = + promptTokens > 0 ? promptTokens : (usage.total ?? input); + } + sessionStore[sessionKey] = next; await saveSessionStore(storePath, sessionStore); } const payloads = result.payloads ?? []; const deliver = opts.deliver === true; const bestEffortDeliver = opts.bestEffortDeliver === true; - const provider = (opts.provider ?? "whatsapp").toLowerCase(); + const deliveryProvider = (opts.provider ?? "whatsapp").toLowerCase(); const whatsappTarget = opts.to ? normalizeE164(opts.to) : allowFrom[0]; const telegramTarget = opts.to?.trim() || undefined; const logDeliveryError = (err: unknown) => { - const message = `Delivery failed (${provider}): ${String(err)}`; + const message = `Delivery failed (${deliveryProvider}): ${String(err)}`; runtime.error?.(message); if (!runtime.error) runtime.log(message); }; if (deliver) { - if (provider === "whatsapp" && !whatsappTarget) { + if (deliveryProvider === "whatsapp" && !whatsappTarget) { const err = new Error( "Delivering to WhatsApp requires --to or inbound.allowFrom[0]", ); if (!bestEffortDeliver) throw err; logDeliveryError(err); } - if (provider === "telegram" && !telegramTarget) { + if (deliveryProvider === "telegram" && !telegramTarget) { const err = new Error("Delivering to Telegram requires --to "); if (!bestEffortDeliver) throw err; logDeliveryError(err); } - if (provider === "webchat") { + if (deliveryProvider === "webchat") { const err = new Error( "Delivering to WebChat is not supported via `clawdis agent`; use WebChat RPC instead.", ); @@ -412,11 +357,11 @@ export async function agentCommand( logDeliveryError(err); } if ( - provider !== "whatsapp" && - provider !== "telegram" && - provider !== "webchat" + deliveryProvider !== "whatsapp" && + deliveryProvider !== "telegram" && + deliveryProvider !== "webchat" ) { - const err = new Error(`Unknown provider: ${provider}`); + const err = new Error(`Unknown provider: ${deliveryProvider}`); if (!bestEffortDeliver) throw err; logDeliveryError(err); } @@ -430,16 +375,11 @@ export async function agentCommand( })); runtime.log( JSON.stringify( - { - payloads: normalizedPayloads, - meta: result.meta, - }, + { payloads: normalizedPayloads, meta: result.meta }, null, 2, ), ); - // If JSON output was requested, suppress additional human-readable logs unless we're - // also delivering, in which case we still proceed to send below. if (!deliver) return; } @@ -451,12 +391,11 @@ export async function agentCommand( for (const payload of payloads) { const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + if (!opts.json) { const lines: string[] = []; if (payload.text) lines.push(payload.text.trimEnd()); - for (const url of mediaList) { - lines.push(`MEDIA:${url}`); - } + for (const url of mediaList) lines.push(`MEDIA:${url}`); runtime.log(lines.join("\n")); } @@ -466,14 +405,13 @@ export async function agentCommand( const media = mediaList; if (!text && media.length === 0) continue; - if (provider === "whatsapp" && whatsappTarget) { + if (deliveryProvider === "whatsapp" && whatsappTarget) { try { const primaryMedia = media[0]; await deps.sendMessageWhatsApp(whatsappTarget, text, { verbose: false, mediaUrl: primaryMedia, }); - for (const extra of media.slice(1)) { await deps.sendMessageWhatsApp(whatsappTarget, "", { verbose: false, @@ -487,7 +425,7 @@ export async function agentCommand( continue; } - if (provider === "telegram" && telegramTarget) { + if (deliveryProvider === "telegram" && telegramTarget) { try { if (media.length === 0) { for (const chunk of chunkText(text, 4000)) { diff --git a/src/commands/health.ts b/src/commands/health.ts index 44e3f3d54..13a9332a3 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -152,7 +152,7 @@ export async function getHealthSnapshot( const linked = await webAuthExists(); const authAgeMs = getWebAuthAgeMs(); const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined); - const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store); + const storePath = resolveStorePath(cfg.inbound?.session?.store); const store = loadSessionStore(storePath); const sessions = Object.entries(store) .filter(([key]) => key !== "global" && key !== "unknown") diff --git a/src/commands/sessions.test.ts b/src/commands/sessions.test.ts index 9985189dd..4f6826ec9 100644 --- a/src/commands/sessions.test.ts +++ b/src/commands/sessions.test.ts @@ -10,9 +10,7 @@ process.env.FORCE_COLOR = "0"; vi.mock("../config/config.js", () => ({ loadConfig: () => ({ inbound: { - reply: { - agent: { model: "pi:opus", contextTokens: 32000 }, - }, + agent: { model: "pi:opus", contextTokens: 32000 }, }, }), })); diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index 13fe22267..4abda1f27 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -149,13 +149,11 @@ export async function sessionsCommand( ) { const cfg = loadConfig(); const configContextTokens = - cfg.inbound?.reply?.agent?.contextTokens ?? - lookupContextTokens(cfg.inbound?.reply?.agent?.model) ?? + cfg.inbound?.agent?.contextTokens ?? + lookupContextTokens(cfg.inbound?.agent?.model) ?? DEFAULT_CONTEXT_TOKENS; - const configModel = cfg.inbound?.reply?.agent?.model ?? DEFAULT_MODEL; - const storePath = resolveStorePath( - opts.store ?? cfg.inbound?.reply?.session?.store, - ); + const configModel = cfg.inbound?.agent?.model ?? DEFAULT_MODEL; + const storePath = resolveStorePath(opts.store ?? cfg.inbound?.session?.store); const store = loadSessionStore(storePath); let activeMinutes: number | undefined; diff --git a/src/commands/setup.ts b/src/commands/setup.ts new file mode 100644 index 000000000..a820c450b --- /dev/null +++ b/src/commands/setup.ts @@ -0,0 +1,81 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import JSON5 from "json5"; + +import { + DEFAULT_AGENT_WORKSPACE_DIR, + ensureAgentWorkspace, +} from "../agents/workspace.js"; +import { type ClawdisConfig, CONFIG_PATH_CLAWDIS } from "../config/config.js"; +import { resolveSessionTranscriptsDir } from "../config/sessions.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { defaultRuntime } from "../runtime.js"; + +async function readConfigFileRaw(): Promise<{ + exists: boolean; + parsed: ClawdisConfig; +}> { + try { + const raw = await fs.readFile(CONFIG_PATH_CLAWDIS, "utf-8"); + const parsed = JSON5.parse(raw); + if (parsed && typeof parsed === "object") { + return { exists: true, parsed: parsed as ClawdisConfig }; + } + return { exists: true, parsed: {} }; + } catch { + return { exists: false, parsed: {} }; + } +} + +async function writeConfigFile(cfg: ClawdisConfig) { + await fs.mkdir(path.dirname(CONFIG_PATH_CLAWDIS), { recursive: true }); + const json = JSON.stringify(cfg, null, 2).trimEnd().concat("\n"); + await fs.writeFile(CONFIG_PATH_CLAWDIS, json, "utf-8"); +} + +export async function setupCommand( + opts?: { workspace?: string }, + runtime: RuntimeEnv = defaultRuntime, +) { + const desiredWorkspace = + typeof opts?.workspace === "string" && opts.workspace.trim() + ? opts.workspace.trim() + : undefined; + + const existingRaw = await readConfigFileRaw(); + const cfg = existingRaw.parsed; + const inbound = cfg.inbound ?? {}; + + const workspace = + desiredWorkspace ?? inbound.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; + + const next: ClawdisConfig = { + ...cfg, + inbound: { + ...inbound, + workspace, + }, + }; + + if (!existingRaw.exists || inbound.workspace !== workspace) { + await writeConfigFile(next); + runtime.log( + !existingRaw.exists + ? `Wrote ${CONFIG_PATH_CLAWDIS}` + : `Updated ${CONFIG_PATH_CLAWDIS} (set inbound.workspace)`, + ); + } else { + runtime.log(`Config OK: ${CONFIG_PATH_CLAWDIS}`); + } + + const ws = await ensureAgentWorkspace({ + dir: workspace, + ensureBootstrapFiles: true, + }); + runtime.log(`Workspace OK: ${ws.dir}`); + + const sessionsDir = resolveSessionTranscriptsDir(); + await fs.mkdir(sessionsDir, { recursive: true }); + runtime.log(`Sessions OK: ${sessionsDir}`); +} diff --git a/src/commands/status.ts b/src/commands/status.ts index 183bc4b1c..a54eb2fa6 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -61,13 +61,13 @@ export async function getStatusSummary(): Promise { const providerSummary = await buildProviderSummary(cfg); const queuedSystemEvents = peekSystemEvents(); - const configModel = cfg.inbound?.reply?.agent?.model ?? DEFAULT_MODEL; + const configModel = cfg.inbound?.agent?.model ?? DEFAULT_MODEL; const configContextTokens = - cfg.inbound?.reply?.agent?.contextTokens ?? + cfg.inbound?.agent?.contextTokens ?? lookupContextTokens(configModel) ?? DEFAULT_CONTEXT_TOKENS; - const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store); + const storePath = resolveStorePath(cfg.inbound?.session?.store); const store = loadSessionStore(storePath); const now = Date.now(); const sessions = Object.entries(store) diff --git a/src/config/config.test.ts b/src/config/config.test.ts index ea3ff425f..2901790b1 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -27,89 +27,7 @@ describe("config identity defaults", () => { process.env.HOME = previousHome; }); - it("derives responsePrefix, mentionPatterns, and sessionIntro when identity is set", async () => { - await withTempHome(async (home) => { - const configDir = path.join(home, ".clawdis"); - await fs.mkdir(configDir, { recursive: true }); - await fs.writeFile( - path.join(configDir, "clawdis.json"), - JSON.stringify( - { - identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" }, - inbound: { - reply: { - mode: "command", - command: ["pi", "--mode", "rpc", "x"], - session: {}, - }, - }, - }, - null, - 2, - ), - "utf-8", - ); - - vi.resetModules(); - const { loadConfig } = await import("./config.js"); - const cfg = loadConfig(); - - expect(cfg.inbound?.responsePrefix).toBe("🦥"); - expect(cfg.inbound?.groupChat?.mentionPatterns).toEqual([ - "\\b@?Samantha\\b", - ]); - expect(cfg.inbound?.reply?.session?.sessionIntro).toContain( - "You are Samantha.", - ); - expect(cfg.inbound?.reply?.session?.sessionIntro).toContain( - "Theme: helpful sloth.", - ); - expect(cfg.inbound?.reply?.session?.sessionIntro).toContain( - "Your emoji is 🦥.", - ); - }); - }); - - it("does not override explicit values", async () => { - await withTempHome(async (home) => { - const configDir = path.join(home, ".clawdis"); - await fs.mkdir(configDir, { recursive: true }); - await fs.writeFile( - path.join(configDir, "clawdis.json"), - JSON.stringify( - { - identity: { - name: "Samantha Sloth", - theme: "space lobster", - emoji: "🦞", - }, - inbound: { - responsePrefix: "✅", - groupChat: { mentionPatterns: ["@clawd"] }, - reply: { - mode: "command", - command: ["pi", "--mode", "rpc", "x"], - session: { sessionIntro: "Explicit intro" }, - }, - }, - }, - null, - 2, - ), - "utf-8", - ); - - vi.resetModules(); - const { loadConfig } = await import("./config.js"); - const cfg = loadConfig(); - - expect(cfg.inbound?.responsePrefix).toBe("✅"); - expect(cfg.inbound?.groupChat?.mentionPatterns).toEqual(["@clawd"]); - expect(cfg.inbound?.reply?.session?.sessionIntro).toBe("Explicit intro"); - }); - }); - - it("does not synthesize inbound.reply when it is absent", async () => { + it("derives responsePrefix and mentionPatterns when identity is set", async () => { await withTempHome(async (home) => { const configDir = path.join(home, ".clawdis"); await fs.mkdir(configDir, { recursive: true }); @@ -134,7 +52,69 @@ describe("config identity defaults", () => { expect(cfg.inbound?.groupChat?.mentionPatterns).toEqual([ "\\b@?Samantha\\b", ]); - expect(cfg.inbound?.reply).toBeUndefined(); + }); + }); + + it("does not override explicit values", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".clawdis"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "clawdis.json"), + JSON.stringify( + { + identity: { + name: "Samantha Sloth", + theme: "space lobster", + emoji: "🦞", + }, + inbound: { + responsePrefix: "✅", + groupChat: { mentionPatterns: ["@clawd"] }, + }, + }, + null, + 2, + ), + "utf-8", + ); + + vi.resetModules(); + const { loadConfig } = await import("./config.js"); + const cfg = loadConfig(); + + expect(cfg.inbound?.responsePrefix).toBe("✅"); + expect(cfg.inbound?.groupChat?.mentionPatterns).toEqual(["@clawd"]); + }); + }); + + it("does not synthesize inbound.agent/session when absent", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".clawdis"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "clawdis.json"), + JSON.stringify( + { + identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" }, + inbound: {}, + }, + null, + 2, + ), + "utf-8", + ); + + vi.resetModules(); + const { loadConfig } = await import("./config.js"); + const cfg = loadConfig(); + + expect(cfg.inbound?.responsePrefix).toBe("🦥"); + expect(cfg.inbound?.groupChat?.mentionPatterns).toEqual([ + "\\b@?Samantha\\b", + ]); + expect(cfg.inbound?.agent).toBeUndefined(); + expect(cfg.inbound?.session).toBeUndefined(); }); }); }); diff --git a/src/config/config.ts b/src/config/config.ts index 8b36deea0..74ca57e9d 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -5,9 +5,6 @@ import path from "node:path"; import JSON5 from "json5"; import { z } from "zod"; -import type { AgentKind } from "../agents/index.js"; - -export type ReplyMode = "text" | "command"; export type SessionScope = "per-sender" | "global"; export type SessionConfig = { @@ -16,13 +13,7 @@ export type SessionConfig = { idleMinutes?: number; heartbeatIdleMinutes?: number; store?: string; - sessionArgNew?: string[]; - sessionArgResume?: string[]; - sessionArgBeforeBody?: boolean; - sendSystemOnce?: boolean; - sessionIntro?: string; typingIntervalSeconds?: number; - heartbeatMinutes?: number; mainKey?: string; }; @@ -105,31 +96,25 @@ export type ClawdisConfig = { timeoutSeconds?: number; }; groupChat?: GroupChatConfig; - reply?: { - mode: ReplyMode; - text?: string; - command?: string[]; - heartbeatCommand?: string[]; + agent?: { + /** Provider id, e.g. "anthropic" or "openai" (pi-ai catalog). */ + provider?: string; + /** Model id within provider, e.g. "claude-opus-4-5". */ + model?: string; + /** Optional display-only context window override (used for % in status UIs). */ + contextTokens?: number; + /** Default thinking level when no /think directive is present. */ thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high"; + /** Default verbose level when no /verbose directive is present. */ verboseDefault?: "off" | "on"; - cwd?: string; - template?: string; timeoutSeconds?: number; - bodyPrefix?: string; - mediaUrl?: string; - session?: SessionConfig; + /** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */ mediaMaxMb?: number; typingIntervalSeconds?: number; + /** Periodic background heartbeat runs (minutes). 0 disables. */ heartbeatMinutes?: number; - agent?: { - kind: AgentKind; - format?: "text" | "json"; - identityPrefix?: string; - provider?: string; - model?: string; - contextTokens?: number; - }; }; + session?: SessionConfig; }; web?: WebConfig; telegram?: TelegramConfig; @@ -144,70 +129,6 @@ export const CONFIG_PATH_CLAWDIS = path.join( "clawdis.json", ); -const ReplySchema = z - .object({ - mode: z.union([z.literal("text"), z.literal("command")]), - text: z.string().optional(), - command: z.array(z.string()).optional(), - heartbeatCommand: z.array(z.string()).optional(), - thinkingDefault: z - .union([ - z.literal("off"), - z.literal("minimal"), - z.literal("low"), - z.literal("medium"), - z.literal("high"), - ]) - .optional(), - verboseDefault: z.union([z.literal("off"), z.literal("on")]).optional(), - cwd: z.string().optional(), - template: z.string().optional(), - timeoutSeconds: z.number().int().positive().optional(), - bodyPrefix: z.string().optional(), - mediaUrl: z.string().optional(), - mediaMaxMb: z.number().positive().optional(), - typingIntervalSeconds: z.number().int().positive().optional(), - session: z - .object({ - scope: z - .union([z.literal("per-sender"), z.literal("global")]) - .optional(), - resetTriggers: z.array(z.string()).optional(), - idleMinutes: z.number().int().positive().optional(), - heartbeatIdleMinutes: z.number().int().positive().optional(), - store: z.string().optional(), - sessionArgNew: z.array(z.string()).optional(), - sessionArgResume: z.array(z.string()).optional(), - sessionArgBeforeBody: z.boolean().optional(), - sendSystemOnce: z.boolean().optional(), - sessionIntro: z.string().optional(), - typingIntervalSeconds: z.number().int().positive().optional(), - mainKey: z.string().optional(), - }) - .optional(), - heartbeatMinutes: z.number().int().nonnegative().optional(), - agent: z - .object({ - kind: z.literal("pi"), - format: z.union([z.literal("text"), z.literal("json")]).optional(), - identityPrefix: z.string().optional(), - provider: z.string().optional(), - model: z.string().optional(), - contextTokens: z.number().int().positive().optional(), - }) - .optional(), - }) - .refine( - (val) => - val.mode === "text" - ? Boolean(val.text) - : Boolean(val.command || val.heartbeatCommand), - { - message: - "reply.text is required for mode=text; reply.command or reply.heartbeatCommand is required for mode=command", - }, - ); - const ClawdisSchema = z.object({ identity: z .object({ @@ -261,7 +182,42 @@ const ClawdisSchema = z.object({ timeoutSeconds: z.number().int().positive().optional(), }) .optional(), - reply: ReplySchema.optional(), + agent: z + .object({ + provider: z.string().optional(), + model: z.string().optional(), + contextTokens: z.number().int().positive().optional(), + thinkingDefault: z + .union([ + z.literal("off"), + z.literal("minimal"), + z.literal("low"), + z.literal("medium"), + z.literal("high"), + ]) + .optional(), + verboseDefault: z + .union([z.literal("off"), z.literal("on")]) + .optional(), + timeoutSeconds: z.number().int().positive().optional(), + mediaMaxMb: z.number().positive().optional(), + typingIntervalSeconds: z.number().int().positive().optional(), + heartbeatMinutes: z.number().nonnegative().optional(), + }) + .optional(), + session: z + .object({ + scope: z + .union([z.literal("per-sender"), z.literal("global")]) + .optional(), + resetTriggers: z.array(z.string()).optional(), + idleMinutes: z.number().int().positive().optional(), + heartbeatIdleMinutes: z.number().int().positive().optional(), + store: z.string().optional(), + typingIntervalSeconds: z.number().int().positive().optional(), + mainKey: z.string().optional(), + }) + .optional(), }) .optional(), cron: z @@ -315,12 +271,9 @@ function applyIdentityDefaults(cfg: ClawdisConfig): ClawdisConfig { const emoji = identity.emoji?.trim(); const name = identity.name?.trim(); - const theme = identity.theme?.trim(); const inbound = cfg.inbound ?? {}; const groupChat = inbound.groupChat ?? {}; - const reply = inbound.reply ?? undefined; - const session = reply?.session ?? undefined; let mutated = false; const next: ClawdisConfig = { ...cfg }; @@ -341,22 +294,6 @@ function applyIdentityDefaults(cfg: ClawdisConfig): ClawdisConfig { mutated = true; } - if (name && reply && !session?.sessionIntro) { - const introParts = [ - `You are ${name}.`, - theme ? `Theme: ${theme}.` : undefined, - emoji ? `Your emoji is ${emoji}.` : undefined, - ].filter(Boolean); - next.inbound = { - ...(next.inbound ?? inbound), - reply: { - ...reply, - session: { ...(session ?? {}), sessionIntro: introParts.join(" ") }, - }, - }; - mutated = true; - } - return mutated ? next : cfg; } diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 6acb6e7aa..ba9f5fe51 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -5,7 +5,7 @@ import path from "node:path"; import JSON5 from "json5"; import type { MsgContext } from "../auto-reply/templating.js"; -import { CONFIG_DIR, normalizeE164 } from "../utils.js"; +import { normalizeE164 } from "../utils.js"; export type SessionScope = "per-sender" | "global"; @@ -27,16 +27,22 @@ export type SessionEntry = { syncing?: boolean | string; }; -export const SESSION_STORE_DEFAULT = path.join( - CONFIG_DIR, - "sessions", - "sessions.json", -); +export function resolveSessionTranscriptsDir(): string { + return path.join(os.homedir(), ".clawdis", "sessions"); +} + +export function resolveDefaultSessionStorePath(): string { + return path.join(resolveSessionTranscriptsDir(), "sessions.json"); +} export const DEFAULT_RESET_TRIGGER = "/new"; export const DEFAULT_IDLE_MINUTES = 60; +export function resolveSessionTranscriptPath(sessionId: string): string { + return path.join(resolveSessionTranscriptsDir(), `${sessionId}.jsonl`); +} + export function resolveStorePath(store?: string) { - if (!store) return SESSION_STORE_DEFAULT; + if (!store) return resolveDefaultSessionStorePath(); if (store.startsWith("~")) return path.resolve(store.replace("~", os.homedir())); return path.resolve(store); diff --git a/src/cron/isolated-agent.test.ts b/src/cron/isolated-agent.test.ts index 1ff0dee83..11a8624c7 100644 --- a/src/cron/isolated-agent.test.ts +++ b/src/cron/isolated-agent.test.ts @@ -8,17 +8,28 @@ import type { CliDeps } from "../cli/deps.js"; import type { ClawdisConfig } from "../config/config.js"; import type { CronJob } from "./types.js"; -vi.mock("../auto-reply/command-reply.js", () => ({ - runCommandReply: vi.fn(), +vi.mock("../agents/pi-embedded.js", () => ({ + runEmbeddedPiAgent: vi.fn(), })); -import { runCommandReply } from "../auto-reply/command-reply.js"; +import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; -async function makeSessionStorePath() { - const dir = await fs.mkdtemp( - path.join(os.tmpdir(), "clawdis-cron-sessions-"), - ); +async function withTempHome(fn: (home: string) => Promise): Promise { + const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-cron-")); + const previousHome = process.env.HOME; + process.env.HOME = base; + try { + return await fn(base); + } finally { + process.env.HOME = previousHome; + await fs.rm(base, { recursive: true, force: true }); + } +} + +async function writeSessionStore(home: string) { + const dir = path.join(home, ".clawdis", "sessions"); + await fs.mkdir(dir, { recursive: true }); const storePath = path.join(dir, "sessions.json"); await fs.writeFile( storePath, @@ -34,26 +45,17 @@ async function makeSessionStorePath() { null, 2, ), + "utf-8", ); - return { - storePath, - cleanup: async () => { - await fs.rm(dir, { recursive: true, force: true }); - }, - }; + return storePath; } -function makeCfg(storePath: string): ClawdisConfig { +function makeCfg(home: string, storePath: string): ClawdisConfig { return { inbound: { - reply: { - mode: "command", - command: ["echo", "ok"], - session: { - store: storePath, - mainKey: "main", - }, - }, + workspace: path.join(home, "clawd"), + agent: { provider: "anthropic", model: "claude-opus-4-5" }, + session: { store: storePath, mainKey: "main" }, }, } as ClawdisConfig; } @@ -76,122 +78,138 @@ function makeJob(payload: CronJob["payload"]): CronJob { describe("runCronIsolatedAgentTurn", () => { beforeEach(() => { - vi.mocked(runCommandReply).mockReset(); + vi.mocked(runEmbeddedPiAgent).mockReset(); }); it("uses last non-empty agent text as summary", async () => { - const sessions = await makeSessionStorePath(); - const deps: CliDeps = { - sendMessageWhatsApp: vi.fn(), - sendMessageTelegram: vi.fn(), - }; - vi.mocked(runCommandReply).mockResolvedValue({ - payloads: [{ text: "first" }, { text: " " }, { text: " last " }], + await withTempHome(async (home) => { + const storePath = await writeSessionStore(home); + const deps: CliDeps = { + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn(), + }; + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "first" }, { text: " " }, { text: " last " }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + const res = await runCronIsolatedAgentTurn({ + cfg: makeCfg(home, storePath), + deps, + job: makeJob({ kind: "agentTurn", message: "do it", deliver: false }), + message: "do it", + sessionKey: "cron:job-1", + lane: "cron", + }); + + expect(res.status).toBe("ok"); + expect(res.summary).toBe("last"); }); - - const res = await runCronIsolatedAgentTurn({ - cfg: makeCfg(sessions.storePath), - deps, - job: makeJob({ kind: "agentTurn", message: "do it", deliver: false }), - message: "do it", - sessionKey: "cron:job-1", - lane: "cron", - }); - - expect(res.status).toBe("ok"); - expect(res.summary).toBe("last"); - - await sessions.cleanup(); }); it("truncates long summaries", async () => { - const sessions = await makeSessionStorePath(); - const deps: CliDeps = { - sendMessageWhatsApp: vi.fn(), - sendMessageTelegram: vi.fn(), - }; - const long = "a".repeat(2001); - vi.mocked(runCommandReply).mockResolvedValue({ - payloads: [{ text: long }], + await withTempHome(async (home) => { + const storePath = await writeSessionStore(home); + const deps: CliDeps = { + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn(), + }; + const long = "a".repeat(2001); + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: long }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + const res = await runCronIsolatedAgentTurn({ + cfg: makeCfg(home, storePath), + deps, + job: makeJob({ kind: "agentTurn", message: "do it", deliver: false }), + message: "do it", + sessionKey: "cron:job-1", + lane: "cron", + }); + + expect(res.status).toBe("ok"); + expect(String(res.summary ?? "")).toMatch(/…$/); }); - - const res = await runCronIsolatedAgentTurn({ - cfg: makeCfg(sessions.storePath), - deps, - job: makeJob({ kind: "agentTurn", message: "do it", deliver: false }), - message: "do it", - sessionKey: "cron:job-1", - lane: "cron", - }); - - expect(res.status).toBe("ok"); - expect(String(res.summary ?? "")).toMatch(/…$/); - - await sessions.cleanup(); }); it("fails delivery without a WhatsApp recipient when bestEffortDeliver=false", async () => { - const sessions = await makeSessionStorePath(); - const deps: CliDeps = { - sendMessageWhatsApp: vi.fn(), - sendMessageTelegram: vi.fn(), - }; - vi.mocked(runCommandReply).mockResolvedValue({ - payloads: [{ text: "hello" }], - }); + await withTempHome(async (home) => { + const storePath = await writeSessionStore(home); + const deps: CliDeps = { + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn(), + }; + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "hello" }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); - const res = await runCronIsolatedAgentTurn({ - cfg: makeCfg(sessions.storePath), - deps, - job: makeJob({ - kind: "agentTurn", + const res = await runCronIsolatedAgentTurn({ + cfg: makeCfg(home, storePath), + deps, + job: makeJob({ + kind: "agentTurn", + message: "do it", + deliver: true, + channel: "whatsapp", + bestEffortDeliver: false, + }), message: "do it", - deliver: true, - channel: "whatsapp", - bestEffortDeliver: false, - }), - message: "do it", - sessionKey: "cron:job-1", - lane: "cron", + sessionKey: "cron:job-1", + lane: "cron", + }); + + expect(res.status).toBe("error"); + expect(res.summary).toBe("hello"); + expect(String(res.error ?? "")).toMatch(/requires a recipient/i); + expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); }); - - expect(res.status).toBe("error"); - expect(res.summary).toBe("hello"); - expect(String(res.error ?? "")).toMatch(/requires a recipient/i); - expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); - - await sessions.cleanup(); }); it("skips delivery without a WhatsApp recipient when bestEffortDeliver=true", async () => { - const sessions = await makeSessionStorePath(); - const deps: CliDeps = { - sendMessageWhatsApp: vi.fn(), - sendMessageTelegram: vi.fn(), - }; - vi.mocked(runCommandReply).mockResolvedValue({ - payloads: [{ text: "hello" }], - }); + await withTempHome(async (home) => { + const storePath = await writeSessionStore(home); + const deps: CliDeps = { + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn(), + }; + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "hello" }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); - const res = await runCronIsolatedAgentTurn({ - cfg: makeCfg(sessions.storePath), - deps, - job: makeJob({ - kind: "agentTurn", + const res = await runCronIsolatedAgentTurn({ + cfg: makeCfg(home, storePath), + deps, + job: makeJob({ + kind: "agentTurn", + message: "do it", + deliver: true, + channel: "whatsapp", + bestEffortDeliver: true, + }), message: "do it", - deliver: true, - channel: "whatsapp", - bestEffortDeliver: true, - }), - message: "do it", - sessionKey: "cron:job-1", - lane: "cron", + sessionKey: "cron:job-1", + lane: "cron", + }); + + expect(res.status).toBe("skipped"); + expect(String(res.summary ?? "")).toMatch(/delivery skipped/i); + expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); }); - - expect(res.status).toBe("skipped"); - expect(String(res.summary ?? "")).toMatch(/delivery skipped/i); - expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); - - await sessions.cleanup(); }); }); diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index 2637e110d..40fb8e50d 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -1,22 +1,27 @@ import crypto from "node:crypto"; - -import { chunkText } from "../auto-reply/chunk.js"; -import { runCommandReply } from "../auto-reply/command-reply.js"; +import { lookupContextTokens } from "../agents/context.js"; import { - applyTemplate, - type TemplateContext, -} from "../auto-reply/templating.js"; + DEFAULT_CONTEXT_TOKENS, + DEFAULT_MODEL, + DEFAULT_PROVIDER, +} from "../agents/defaults.js"; +import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; +import { + DEFAULT_AGENT_WORKSPACE_DIR, + ensureAgentWorkspace, +} from "../agents/workspace.js"; +import { chunkText } from "../auto-reply/chunk.js"; import { normalizeThinkLevel } from "../auto-reply/thinking.js"; import type { CliDeps } from "../cli/deps.js"; import type { ClawdisConfig } from "../config/config.js"; import { DEFAULT_IDLE_MINUTES, loadSessionStore, + resolveSessionTranscriptPath, resolveStorePath, type SessionEntry, saveSessionStore, } from "../config/sessions.js"; -import { enqueueCommandInLane } from "../process/command-queue.js"; import { normalizeE164 } from "../utils.js"; import type { CronJob } from "./types.js"; @@ -26,21 +31,6 @@ export type RunCronAgentTurnResult = { error?: string; }; -function assertCommandReplyConfig(cfg: ClawdisConfig) { - const reply = cfg.inbound?.reply; - if (!reply || reply.mode !== "command" || !reply.command?.length) { - throw new Error( - "Configure inbound.reply.mode=command with reply.command before using cron agent jobs.", - ); - } - return reply as NonNullable< - NonNullable["reply"] - > & { - mode: "command"; - command: string[]; - }; -} - function pickSummaryFromOutput(text: string | undefined) { const clean = (text ?? "").trim(); if (!clean) return undefined; @@ -72,7 +62,7 @@ function resolveDeliveryTarget( ? jobPayload.to.trim() : undefined; - const sessionCfg = cfg.inbound?.reply?.session; + const sessionCfg = cfg.inbound?.session; const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; const storePath = resolveStorePath(sessionCfg?.store); const store = loadSessionStore(storePath); @@ -120,7 +110,7 @@ function resolveCronSession(params: { sessionKey: string; nowMs: number; }) { - const sessionCfg = params.cfg.inbound?.reply?.session; + const sessionCfg = params.cfg.inbound?.session; const idleMinutes = Math.max( sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES, 1, @@ -155,28 +145,28 @@ export async function runCronIsolatedAgentTurn(params: { sessionKey: string; lane?: string; }): Promise { - const replyCfg = assertCommandReplyConfig(params.cfg); + const agentCfg = params.cfg.inbound?.agent; + void params.lane; + const workspaceDirRaw = + params.cfg.inbound?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; + const workspace = await ensureAgentWorkspace({ + dir: workspaceDirRaw, + ensureBootstrapFiles: true, + }); + const workspaceDir = workspace.dir; + + const provider = agentCfg?.provider?.trim() || DEFAULT_PROVIDER; + const model = agentCfg?.model?.trim() || DEFAULT_MODEL; const now = Date.now(); const cronSession = resolveCronSession({ cfg: params.cfg, sessionKey: params.sessionKey, nowMs: now, }); - const sendSystemOnce = replyCfg.session?.sendSystemOnce === true; const isFirstTurnInSession = cronSession.isNewSession || !cronSession.systemSent; - const sessionIntro = replyCfg.session?.sessionIntro - ? applyTemplate(replyCfg.session.sessionIntro, { - SessionId: cronSession.sessionEntry.sessionId, - }) - : ""; - const bodyPrefix = replyCfg.bodyPrefix - ? applyTemplate(replyCfg.bodyPrefix, { - SessionId: cronSession.sessionEntry.sessionId, - }) - : ""; - const thinkOverride = normalizeThinkLevel(replyCfg.thinkingDefault); + const thinkOverride = normalizeThinkLevel(agentCfg?.thinkingDefault); const jobThink = normalizeThinkLevel( (params.job.payload.kind === "agentTurn" ? params.job.payload.thinking @@ -187,7 +177,7 @@ export async function runCronIsolatedAgentTurn(params: { const timeoutSecondsRaw = params.job.payload.kind === "agentTurn" && params.job.payload.timeoutSeconds ? params.job.payload.timeoutSeconds - : (replyCfg.timeoutSeconds ?? 600); + : (agentCfg?.timeoutSeconds ?? 600); const timeoutSeconds = Math.max(Math.floor(timeoutSecondsRaw), 1); const timeoutMs = timeoutSeconds * 1000; @@ -212,26 +202,10 @@ export async function runCronIsolatedAgentTurn(params: { const base = `[cron:${params.job.id}${params.job.name ? ` ${params.job.name}` : ""}] ${params.message}`.trim(); - let commandBody = base; - if (!sendSystemOnce || isFirstTurnInSession) { - commandBody = bodyPrefix ? `${bodyPrefix}${commandBody}` : commandBody; - } - if (sessionIntro) { - commandBody = `${sessionIntro}\n\n${commandBody}`; - } - - const templatingCtx: TemplateContext = { - Body: commandBody, - BodyStripped: commandBody, - SessionId: cronSession.sessionEntry.sessionId, - From: resolvedDelivery.to ?? "", - To: resolvedDelivery.to ?? "", - Surface: "Cron", - IsNewSession: cronSession.isNewSession ? "true" : "false", - }; + const commandBody = base; // Persist systemSent before the run, mirroring the inbound auto-reply behavior. - if (sendSystemOnce && isFirstTurnInSession) { + if (isFirstTurnInSession) { cronSession.sessionEntry.systemSent = true; cronSession.store[params.sessionKey] = cronSession.sessionEntry; await saveSessionStore(cronSession.storePath, cronSession.store); @@ -240,21 +214,23 @@ export async function runCronIsolatedAgentTurn(params: { await saveSessionStore(cronSession.storePath, cronSession.store); } - const lane = params.lane?.trim() || "cron"; - - let runResult: Awaited>; + let runResult: Awaited>; try { - runResult = await runCommandReply({ - reply: { ...replyCfg, mode: "command" }, - templatingCtx, - sendSystemOnce, - isNewSession: cronSession.isNewSession, - isFirstTurnInSession, - systemSent: cronSession.sessionEntry.systemSent ?? false, - timeoutMs, - timeoutSeconds, + const sessionFile = resolveSessionTranscriptPath( + cronSession.sessionEntry.sessionId, + ); + runResult = await runEmbeddedPiAgent({ + sessionId: cronSession.sessionEntry.sessionId, + sessionFile, + workspaceDir, + prompt: commandBody, + provider, + model, thinkLevel, - enqueue: (task, opts) => enqueueCommandInLane(lane, task, opts), + verboseLevel: + (cronSession.sessionEntry.verboseLevel as "on" | "off" | undefined) ?? + (agentCfg?.verboseDefault as "on" | "off" | undefined), + timeoutMs, runId: cronSession.sessionEntry.sessionId, }); } catch (err) { @@ -262,6 +238,31 @@ export async function runCronIsolatedAgentTurn(params: { } const payloads = runResult.payloads ?? []; + + // Update token+model fields in the session store. + { + const usage = runResult.meta.agentMeta?.usage; + const modelUsed = runResult.meta.agentMeta?.model ?? model; + const contextTokens = + agentCfg?.contextTokens ?? + lookupContextTokens(modelUsed) ?? + DEFAULT_CONTEXT_TOKENS; + + cronSession.sessionEntry.model = modelUsed; + cronSession.sessionEntry.contextTokens = contextTokens; + if (usage) { + const input = usage.input ?? 0; + const output = usage.output ?? 0; + const promptTokens = + input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0); + cronSession.sessionEntry.inputTokens = input; + cronSession.sessionEntry.outputTokens = output; + cronSession.sessionEntry.totalTokens = + promptTokens > 0 ? promptTokens : (usage.total ?? input); + } + cronSession.store[params.sessionKey] = cronSession.sessionEntry; + await saveSessionStore(cronSession.storePath, cronSession.store); + } const firstText = payloads[0]?.text ?? ""; const summary = pickSummaryFromPayloads(payloads) ?? pickSummaryFromOutput(firstText); diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index 670bb5802..c3674f766 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import { type AddressInfo, createServer } from "node:net"; import os from "node:os"; import path from "node:path"; -import { describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { WebSocket } from "ws"; import { agentCommand } from "../commands/agent.js"; import { emitAgentEvent } from "../infra/agent-events.js"; @@ -72,11 +72,9 @@ vi.mock("../config/config.js", () => ({ loadConfig: () => ({ inbound: { allowFrom: testAllowFrom, - reply: { - mode: "command", - command: ["echo", "ok"], - session: { mainKey: "main", store: testSessionStorePath }, - }, + workspace: path.join(os.tmpdir(), "clawd-gateway-test"), + agent: { provider: "anthropic", model: "claude-opus-4-5" }, + session: { mainKey: "main", store: testSessionStorePath }, }, cron: (() => { const cron: Record = {}; @@ -107,6 +105,23 @@ vi.mock("../commands/agent.js", () => ({ process.env.CLAWDIS_SKIP_PROVIDERS = "1"; +let previousHome: string | undefined; +let tempHome: string | undefined; + +beforeEach(async () => { + previousHome = process.env.HOME; + tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gateway-home-")); + process.env.HOME = tempHome; +}); + +afterEach(async () => { + process.env.HOME = previousHome; + if (tempHome) { + await fs.rm(tempHome, { recursive: true, force: true }); + tempHome = undefined; + } +}); + async function getFreePort(): Promise { return await new Promise((resolve, reject) => { const server = createServer(); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 03039c6cf..80b00c8a2 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -377,7 +377,7 @@ function capArrayByJsonBytes( function loadSessionEntry(sessionKey: string) { const cfg = loadConfig(); - const sessionCfg = cfg.inbound?.reply?.session; + const sessionCfg = cfg.inbound?.session; const storePath = sessionCfg?.store ? resolveStorePath(sessionCfg.store) : resolveStorePath(undefined); @@ -394,9 +394,9 @@ function classifySessionKey(key: string): GatewaySessionRow["kind"] { } function getSessionDefaults(cfg: ClawdisConfig): GatewaySessionsDefaults { - const model = cfg.inbound?.reply?.agent?.model ?? DEFAULT_MODEL; + const model = cfg.inbound?.agent?.model ?? DEFAULT_MODEL; const contextTokens = - cfg.inbound?.reply?.agent?.contextTokens ?? + cfg.inbound?.agent?.contextTokens ?? lookupContextTokens(model) ?? DEFAULT_CONTEXT_TOKENS; return { model: model ?? null, contextTokens: contextTokens ?? null }; @@ -886,7 +886,7 @@ export async function startGatewayServer( ).items; const thinkingLevel = entry?.thinkingLevel ?? - loadConfig().inbound?.reply?.thinkingDefault ?? + loadConfig().inbound?.agent?.thinkingDefault ?? "off"; return { ok: true, @@ -1864,7 +1864,7 @@ export async function startGatewayServer( ).items; const thinkingLevel = entry?.thinkingLevel ?? - loadConfig().inbound?.reply?.thinkingDefault ?? + loadConfig().inbound?.agent?.thinkingDefault ?? "off"; respond(true, { sessionKey, @@ -2192,9 +2192,7 @@ export async function startGatewayServer( } const p = params as SessionsListParams; const cfg = loadConfig(); - const storePath = resolveStorePath( - cfg.inbound?.reply?.session?.store, - ); + const storePath = resolveStorePath(cfg.inbound?.session?.store); const store = loadSessionStore(storePath); const result = listSessionsFromStore({ cfg, @@ -2230,9 +2228,7 @@ export async function startGatewayServer( } const cfg = loadConfig(); - const storePath = resolveStorePath( - cfg.inbound?.reply?.session?.store, - ); + const storePath = resolveStorePath(cfg.inbound?.session?.store); const store = loadSessionStore(storePath); const now = Date.now(); @@ -2867,8 +2863,7 @@ export async function startGatewayServer( } resolvedSessionId = sessionId; const mainKey = - (cfg.inbound?.reply?.session?.mainKey ?? "main").trim() || - "main"; + (cfg.inbound?.session?.mainKey ?? "main").trim() || "main"; if (requestedSessionKey === mainKey) { chatRunSessions.set(sessionId, { sessionKey: requestedSessionKey, diff --git a/src/process/tau-rpc.test.ts b/src/process/tau-rpc.test.ts deleted file mode 100644 index 0737fc2c1..000000000 --- a/src/process/tau-rpc.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { EventEmitter } from "node:events"; -import { PassThrough } from "node:stream"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { resetPiRpc, runPiRpc } from "./tau-rpc.js"; - -vi.mock("node:child_process", () => { - const spawn = vi.fn(); - return { spawn }; -}); - -type MockChild = EventEmitter & { - stdin: EventEmitter & { - write: (chunk: string, cb?: (err?: Error | null) => void) => boolean; - once: (event: "drain", listener: () => void) => unknown; - }; - stdout: PassThrough; - stderr: PassThrough; - killed: boolean; - kill: (signal?: NodeJS.Signals) => boolean; -}; - -function makeChild(): MockChild { - const child = new EventEmitter() as MockChild; - const stdin = new EventEmitter() as MockChild["stdin"]; - stdin.write = (_chunk: string, cb?: (err?: Error | null) => void) => { - cb?.(null); - return true; - }; - child.stdin = stdin; - child.stdout = new PassThrough(); - child.stderr = new PassThrough(); - child.killed = false; - child.kill = () => { - child.killed = true; - return true; - }; - return child; -} - -describe("tau-rpc", () => { - afterEach(() => { - resetPiRpc(); - vi.resetAllMocks(); - }); - - it("sends prompt with string message", async () => { - const { spawn } = await import("node:child_process"); - const child = makeChild(); - vi.mocked(spawn).mockReturnValue(child as never); - - const writes: string[] = []; - child.stdin.write = (chunk: string, cb?: (err?: Error | null) => void) => { - writes.push(String(chunk)); - cb?.(null); - return true; - }; - - const run = runPiRpc({ - argv: ["tau", "--mode", "rpc"], - cwd: "/tmp", - timeoutMs: 500, - prompt: "hello", - }); - - // Allow the async `prompt()` to install the pending resolver before exiting. - await Promise.resolve(); - - expect(writes.length).toBeGreaterThan(0); - child.emit("exit", 0, null); - const res = await run; - - expect(res.code).toBe(0); - expect(writes.length).toBeGreaterThan(0); - const first = writes[0]?.trim(); - expect(first?.endsWith("\n")).toBe(false); - const obj = JSON.parse(first ?? "{}") as { - type?: string; - message?: unknown; - }; - expect(obj.type).toBe("prompt"); - expect(obj.message).toBe("hello"); - }); -}); diff --git a/src/process/tau-rpc.ts b/src/process/tau-rpc.ts deleted file mode 100644 index 1e11ed7c0..000000000 --- a/src/process/tau-rpc.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; -import readline from "node:readline"; - -type TauRpcOptions = { - argv: string[]; - cwd?: string; - timeoutMs: number; - onEvent?: (line: string) => void; -}; - -type TauRpcResult = { - stdout: string; - stderr: string; - code: number; - signal?: NodeJS.Signals | null; - killed?: boolean; -}; - -class TauRpcClient { - private child: ChildProcessWithoutNullStreams | null = null; - private rl: readline.Interface | null = null; - private stderr = ""; - private buffer: string[] = []; - private idleTimer: NodeJS.Timeout | null = null; - private resolveTimer: NodeJS.Timeout | null = null; - private compactionRunning = false; - private pendingRetryCount = 0; - private seenAgentEnd = false; - private pending: - | { - resolve: (r: TauRpcResult) => void; - reject: (err: unknown) => void; - timer: NodeJS.Timeout; - onEvent?: (line: string) => void; - capMs: number; - } - | undefined; - - constructor( - private readonly argv: string[], - private readonly cwd: string | undefined, - ) {} - - private resetRunState() { - this.buffer = []; - this.compactionRunning = false; - this.pendingRetryCount = 0; - this.seenAgentEnd = false; - this.clearResolveTimer(); - } - - private ensureChild() { - if (this.child) return; - this.child = spawn(this.argv[0], this.argv.slice(1), { - cwd: this.cwd, - stdio: ["pipe", "pipe", "pipe"], - }); - this.rl = readline.createInterface({ input: this.child.stdout }); - this.rl.on("line", (line) => this.handleLine(line)); - this.child.stderr.on("data", (d) => { - this.stderr += d.toString(); - }); - this.child.on("exit", (code, signal) => { - this.clearResolveTimer(); - if (this.idleTimer) clearTimeout(this.idleTimer); - if (this.pending) { - const pending = this.pending; - this.pending = undefined; - const out = this.buffer.join("\n"); - clearTimeout(pending.timer); - // Treat process exit as completion with whatever output we captured. - pending.resolve({ - stdout: out, - stderr: this.stderr, - code: code ?? 0, - signal, - }); - } - this.resetRunState(); - this.dispose(); - }); - } - - private handleLine(line: string) { - // Any line = activity; refresh timeout watchdog. - if (this.pending) { - this.resetTimeout(); - } - if (!this.pending) return; - this.buffer.push(line); - this.pending?.onEvent?.(line); - - // Parse the line once to track agent lifecycle signals. - try { - const evt = JSON.parse(line) as { - type?: string; - command?: string; - success?: boolean; - error?: string; - message?: unknown; - willRetry?: boolean; - id?: string; - method?: string; - }; - - if (evt.type === "response" && evt.command === "prompt") { - if (evt.success === false) { - const pending = this.pending; - this.pending = undefined; - this.buffer = []; - this.clearResolveTimer(); - this.resetRunState(); - if (pending) { - clearTimeout(pending.timer); - pending.reject( - new Error(evt.error ?? "tau rpc prompt failed (response=false)"), - ); - } - this.child?.kill("SIGKILL"); - return; - } - } - - if (evt.type === "auto_compaction_start") { - this.compactionRunning = true; - this.clearResolveTimer(); - return; - } - - if (evt.type === "auto_compaction_end") { - this.compactionRunning = false; - if (evt.willRetry) this.pendingRetryCount += 1; - this.scheduleMaybeResolve(); - return; - } - - if (evt?.type === "agent_end") { - this.seenAgentEnd = true; - if (this.pendingRetryCount > 0) { - this.pendingRetryCount -= 1; - } - this.scheduleMaybeResolve(); - return; - } - - // Handle hook UI requests by auto-cancelling (non-interactive surfaces like WhatsApp) - if (evt.type === "hook_ui_request" && evt.id) { - // Fire-and-forget response to unblock hook runner - this.child?.stdin.write( - `${JSON.stringify({ - type: "hook_ui_response", - id: evt.id, - cancelled: true, - })}\n`, - ); - return; - } - } catch { - // ignore malformed/non-JSON lines - } - } - - private scheduleMaybeResolve() { - if (!this.pending) return; - this.clearResolveTimer(); - // Allow a short window for auto-compaction events to arrive after agent_end. - this.resolveTimer = setTimeout(() => { - this.resolveTimer = null; - this.maybeResolve(); - }, 150); - } - - private maybeResolve() { - if (!this.pending) return; - if (!this.seenAgentEnd) return; - if (this.compactionRunning) return; - if (this.pendingRetryCount > 0) return; - - const pending = this.pending; - this.pending = undefined; - const out = this.buffer.join("\n"); - this.buffer = []; - clearTimeout(pending.timer); - pending.resolve({ stdout: out, stderr: this.stderr, code: 0 }); - } - - private clearResolveTimer() { - if (this.resolveTimer) { - clearTimeout(this.resolveTimer); - this.resolveTimer = null; - } - } - - private resetTimeout() { - if (!this.pending) return; - const capMs = this.pending.capMs; - if (this.pending.timer) clearTimeout(this.pending.timer); - this.pending.timer = setTimeout(() => { - const pending = this.pending; - this.pending = undefined; - pending?.reject( - new Error(`tau rpc timed out after ${Math.round(capMs / 1000)}s`), - ); - this.child?.kill("SIGKILL"); - }, capMs); - } - - async prompt( - prompt: string, - timeoutMs: number, - onEvent?: (line: string) => void, - ): Promise { - this.ensureChild(); - if (this.pending) { - throw new Error("tau rpc already handling a request"); - } - const child = this.child; - if (!child) throw new Error("tau rpc child not initialized"); - this.resetRunState(); - await new Promise((resolve, reject) => { - const ok = child.stdin.write( - `${JSON.stringify({ - type: "prompt", - // Pi/Tau RPC expects a plain string prompt. - // (The structured { content: [{type:"text", text}] } shape is used by some - // model APIs, but is not the RPC wire format here.) - message: prompt, - })}\n`, - (err) => (err ? reject(err) : resolve()), - ); - if (!ok) child.stdin.once("drain", () => resolve()); - }); - return await new Promise((resolve, reject) => { - // Hard cap to avoid stuck gateways; resets on every line received. - const capMs = Math.min(timeoutMs, 5 * 60 * 1000); - const timer = setTimeout(() => { - this.pending = undefined; - reject( - new Error(`tau rpc timed out after ${Math.round(capMs / 1000)}s`), - ); - child.kill("SIGKILL"); - }, capMs); - this.pending = { resolve, reject, timer, onEvent, capMs }; - }); - } - - dispose() { - this.clearResolveTimer(); - this.rl?.close(); - this.rl = null; - if (this.child && !this.child.killed) { - this.child.kill("SIGKILL"); - } - this.child = null; - this.buffer = []; - this.stderr = ""; - } -} - -let singleton: { key: string; client: TauRpcClient } | undefined; - -export async function runPiRpc( - opts: TauRpcOptions & { prompt: string }, -): Promise { - const key = `${opts.cwd ?? ""}|${opts.argv.join(" ")}`; - if (!singleton || singleton.key !== key) { - singleton?.client.dispose(); - singleton = { key, client: new TauRpcClient(opts.argv, opts.cwd) }; - } - return singleton.client.prompt(opts.prompt, opts.timeoutMs, opts.onEvent); -} - -export function resetPiRpc() { - singleton?.client.dispose(); - singleton = undefined; -} diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 6f03b038d..66197bb53 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -149,7 +149,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { }; if (!isGroup) { - const sessionCfg = cfg.inbound?.reply?.session; + const sessionCfg = cfg.inbound?.session; const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; const storePath = resolveStorePath(sessionCfg?.store); await updateLastRoute({ diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 29af09a5c..92b0f78d5 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -5,6 +5,12 @@ import os from "node:os"; import path from "node:path"; import sharp from "sharp"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../agents/pi-embedded.js", () => ({ + runEmbeddedPiAgent: vi.fn(), +})); + +import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ClawdisConfig } from "../config/config.js"; import { resetLogger, setLoggerOverride } from "../logging.js"; @@ -26,6 +32,23 @@ import { setLoadConfigMock, } from "./test-helpers.js"; +let previousHome: string | undefined; +let tempHome: string | undefined; + +beforeEach(async () => { + previousHome = process.env.HOME; + tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-web-home-")); + process.env.HOME = tempHome; +}); + +afterEach(async () => { + process.env.HOME = previousHome; + if (tempHome) { + await fs.rm(tempHome, { recursive: true, force: true }); + tempHome = undefined; + } +}); + const makeSessionStore = async ( entries: Record = {}, ): Promise<{ storePath: string; cleanup: () => Promise }> => { @@ -89,27 +112,20 @@ describe("heartbeat helpers", () => { it("resolves heartbeat minutes with default and overrides", () => { const cfgBase: ClawdisConfig = { - inbound: { - reply: { mode: "command" as const }, - }, + inbound: {}, }; expect(resolveReplyHeartbeatMinutes(cfgBase)).toBe(30); expect( resolveReplyHeartbeatMinutes({ - inbound: { reply: { mode: "command", heartbeatMinutes: 5 } }, + inbound: { agent: { heartbeatMinutes: 5 } }, }), ).toBe(5); expect( resolveReplyHeartbeatMinutes({ - inbound: { reply: { mode: "command", heartbeatMinutes: 0 } }, + inbound: { agent: { heartbeatMinutes: 0 } }, }), ).toBeNull(); expect(resolveReplyHeartbeatMinutes(cfgBase, 7)).toBe(7); - expect( - resolveReplyHeartbeatMinutes({ - inbound: { reply: { mode: "text" } }, - }), - ).toBeNull(); }); }); @@ -122,7 +138,7 @@ describe("resolveHeartbeatRecipients", () => { const cfg: ClawdisConfig = { inbound: { allowFrom: ["+1999"], - reply: { mode: "command", session: { store: store.storePath } }, + session: { store: store.storePath }, }, }; const result = resolveHeartbeatRecipients(cfg); @@ -140,7 +156,7 @@ describe("resolveHeartbeatRecipients", () => { const cfg: ClawdisConfig = { inbound: { allowFrom: ["+1999"], - reply: { mode: "command", session: { store: store.storePath } }, + session: { store: store.storePath }, }, }; const result = resolveHeartbeatRecipients(cfg); @@ -154,7 +170,7 @@ describe("resolveHeartbeatRecipients", () => { const cfg: ClawdisConfig = { inbound: { allowFrom: ["*"], - reply: { mode: "command", session: { store: store.storePath } }, + session: { store: store.storePath }, }, }; const result = resolveHeartbeatRecipients(cfg); @@ -171,7 +187,7 @@ describe("resolveHeartbeatRecipients", () => { const cfg: ClawdisConfig = { inbound: { allowFrom: ["+1999"], - reply: { mode: "command", session: { store: store.storePath } }, + session: { store: store.storePath }, }, }; const result = resolveHeartbeatRecipients(cfg, { all: true }); @@ -191,7 +207,6 @@ describe("partial reply gating", () => { const mockConfig: ClawdisConfig = { inbound: { - reply: { mode: "command" }, allowFrom: ["*"], }, }; @@ -240,10 +255,7 @@ describe("partial reply gating", () => { const mockConfig: ClawdisConfig = { inbound: { allowFrom: ["*"], - reply: { - mode: "command", - session: { store: store.storePath, mainKey: "main" }, - }, + session: { store: store.storePath, mainKey: "main" }, }, }; @@ -289,12 +301,13 @@ describe("partial reply gating", () => { }); it("defaults to self-only when no config is present", async () => { - const cfg: ClawdisConfig = { - inbound: { - // No allowFrom provided; this simulates zero config file while keeping reply simple - reply: { mode: "text", text: "ok" }, + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, }, - }; + }); // Not self: should be blocked const blocked = await getReplyFromConfig( @@ -304,9 +317,10 @@ describe("partial reply gating", () => { To: "whatsapp:+123", }, undefined, - cfg, + {}, ); expect(blocked).toBeUndefined(); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); // Self: should be allowed const allowed = await getReplyFromConfig( @@ -316,9 +330,10 @@ describe("partial reply gating", () => { To: "whatsapp:+123", }, undefined, - cfg, + {}, ); expect(allowed).toEqual({ text: "ok" }); + expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); }); }); @@ -331,7 +346,7 @@ describe("runWebHeartbeatOnce", () => { cfg: { inbound: { allowFrom: ["+1555"], - reply: { mode: "command", session: { store: store.storePath } }, + session: { store: store.storePath }, }, }, to: "+1555", @@ -354,7 +369,7 @@ describe("runWebHeartbeatOnce", () => { cfg: { inbound: { allowFrom: ["+1555"], - reply: { mode: "command", session: { store: store.storePath } }, + session: { store: store.storePath }, }, }, to: "+1555", @@ -383,7 +398,7 @@ describe("runWebHeartbeatOnce", () => { cfg: { inbound: { allowFrom: ["+1999"], - reply: { mode: "command", session: { store: storePath } }, + session: { store: storePath }, }, }, to: "+1999", @@ -412,13 +427,10 @@ describe("runWebHeartbeatOnce", () => { setLoadConfigMock({ inbound: { allowFrom: ["+1555"], - reply: { - mode: "command", - session: { - store: storePath, - idleMinutes: 60, - heartbeatIdleMinutes: 10, - }, + session: { + store: storePath, + idleMinutes: 60, + heartbeatIdleMinutes: 10, }, }, }); @@ -451,11 +463,8 @@ describe("runWebHeartbeatOnce", () => { setLoadConfigMock(() => ({ inbound: { allowFrom: ["+4367"], - reply: { - mode: "command", - heartbeatMinutes: 0.001, - session: { store: storePath, idleMinutes: 60 }, - }, + agent: { heartbeatMinutes: 0.001 }, + session: { store: storePath, idleMinutes: 60 }, }, })); @@ -464,10 +473,7 @@ describe("runWebHeartbeatOnce", () => { const cfg: ClawdisConfig = { inbound: { allowFrom: ["+4367"], - reply: { - mode: "command", - session: { store: storePath, idleMinutes: 60 }, - }, + session: { store: storePath, idleMinutes: 60 }, }, }; @@ -496,10 +502,7 @@ describe("runWebHeartbeatOnce", () => { setLoadConfigMock(() => ({ inbound: { allowFrom: ["+1999"], - reply: { - mode: "command", - session: { store: storePath, idleMinutes: 60 }, - }, + session: { store: storePath, idleMinutes: 60 }, }, })); @@ -507,10 +510,7 @@ describe("runWebHeartbeatOnce", () => { const cfg: ClawdisConfig = { inbound: { allowFrom: ["+1999"], - reply: { - mode: "command", - session: { store: storePath, idleMinutes: 60 }, - }, + session: { store: storePath, idleMinutes: 60 }, }, }; await runWebHeartbeatOnce({ @@ -541,7 +541,7 @@ describe("runWebHeartbeatOnce", () => { cfg: { inbound: { allowFrom: ["+1555"], - reply: { mode: "command", session: { store: store.storePath } }, + session: { store: store.storePath }, }, }, to: "+1555", @@ -565,7 +565,7 @@ describe("runWebHeartbeatOnce", () => { cfg: { inbound: { allowFrom: ["+1555"], - reply: { mode: "command", session: { store: store.storePath } }, + session: { store: store.storePath }, }, }, to: "+1555", @@ -717,7 +717,7 @@ describe("web auto-reply", () => { setLoadConfigMock(() => ({ inbound: { allowFrom: ["+1555"], - reply: { mode: "command", session: { store: storePath } }, + session: { store: storePath }, }, })); @@ -776,7 +776,7 @@ describe("web auto-reply", () => { inbound: { allowFrom: ["+1555"], groupChat: { requireMention: true, mentionPatterns: ["@clawd"] }, - reply: { mode: "command", session: { store: store.storePath } }, + session: { store: store.storePath }, }, })); @@ -879,7 +879,7 @@ describe("web auto-reply", () => { setLoadConfigMock(() => ({ inbound: { timestampPrefix: "UTC", - reply: { mode: "command", session: { store: store.storePath } }, + session: { store: store.storePath }, }, })); @@ -1155,7 +1155,7 @@ describe("web auto-reply", () => { for (const fmt of formats) { // Force a small cap to ensure compression is exercised for every format. - setLoadConfigMock(() => ({ inbound: { reply: { mediaMaxMb: 1 } } })); + setLoadConfigMock(() => ({ inbound: { agent: { mediaMaxMb: 1 } } })); const sendMedia = vi.fn(); const reply = vi.fn().mockResolvedValue(undefined); const sendComposing = vi.fn(); @@ -1220,7 +1220,7 @@ describe("web auto-reply", () => { ); it("honors mediaMaxMb from config", async () => { - setLoadConfigMock(() => ({ inbound: { reply: { mediaMaxMb: 1 } } })); + setLoadConfigMock(() => ({ inbound: { agent: { mediaMaxMb: 1 } } })); const sendMedia = vi.fn(); const reply = vi.fn().mockResolvedValue(undefined); const sendComposing = vi.fn(); diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index feadbdc64..7ab0a0328 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -74,7 +74,7 @@ const formatDuration = (ms: number) => const DEFAULT_REPLY_HEARTBEAT_MINUTES = 30; export const HEARTBEAT_TOKEN = "HEARTBEAT_OK"; -export const HEARTBEAT_PROMPT = "HEARTBEAT /think:high"; +export const HEARTBEAT_PROMPT = "HEARTBEAT"; function elide(text?: string, limit = 400) { if (!text) return text; @@ -164,12 +164,10 @@ export function resolveReplyHeartbeatMinutes( cfg: ReturnType, overrideMinutes?: number, ) { - const raw = overrideMinutes ?? cfg.inbound?.reply?.heartbeatMinutes; + const raw = overrideMinutes ?? cfg.inbound?.agent?.heartbeatMinutes; if (raw === 0) return null; if (typeof raw === "number" && raw > 0) return raw; - return cfg.inbound?.reply?.mode === "command" - ? DEFAULT_REPLY_HEARTBEAT_MINUTES - : null; + return DEFAULT_REPLY_HEARTBEAT_MINUTES; } export function stripHeartbeatToken(raw?: string) { @@ -214,12 +212,12 @@ export async function runWebHeartbeatOnce(opts: { }); const cfg = cfgOverride ?? loadConfig(); - const sessionCfg = cfg.inbound?.reply?.session; + const sessionCfg = cfg.inbound?.session; const sessionScope = sessionCfg?.scope ?? "per-sender"; const mainKey = sessionCfg?.mainKey; const sessionKey = resolveSessionKey(sessionScope, { From: to }, mainKey); if (sessionId) { - const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store); + const storePath = resolveStorePath(cfg.inbound?.session?.store); const store = loadSessionStore(storePath); store[sessionKey] = { ...(store[sessionKey] ?? {}), @@ -319,7 +317,7 @@ export async function runWebHeartbeatOnce(opts: { const stripped = stripHeartbeatToken(replyPayload.text); if (stripped.shouldSkip && !hasMedia) { // Don't let heartbeats keep sessions alive: restore previous updatedAt so idle expiry still works. - const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store); + const storePath = resolveStorePath(cfg.inbound?.session?.store); const store = loadSessionStore(storePath); if (sessionSnapshot.entry && store[sessionSnapshot.key]) { store[sessionSnapshot.key].updatedAt = sessionSnapshot.entry.updatedAt; @@ -381,7 +379,7 @@ export async function runWebHeartbeatOnce(opts: { } function getFallbackRecipient(cfg: ReturnType) { - const sessionCfg = cfg.inbound?.reply?.session; + const sessionCfg = cfg.inbound?.session; const storePath = resolveStorePath(sessionCfg?.store); const store = loadSessionStore(storePath); const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; @@ -402,10 +400,10 @@ function getFallbackRecipient(cfg: ReturnType) { } function getSessionRecipients(cfg: ReturnType) { - const sessionCfg = cfg.inbound?.reply?.session; + const sessionCfg = cfg.inbound?.session; const scope = sessionCfg?.scope ?? "per-sender"; if (scope === "global") return []; - const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store); + const storePath = resolveStorePath(cfg.inbound?.session?.store); const store = loadSessionStore(storePath); const isGroupKey = (key: string) => key.startsWith("group:") || key.includes("@g.us"); @@ -470,7 +468,7 @@ function getSessionSnapshot( from: string, isHeartbeat = false, ) { - const sessionCfg = cfg.inbound?.reply?.session; + const sessionCfg = cfg.inbound?.session; const scope = sessionCfg?.scope ?? "per-sender"; const key = resolveSessionKey( scope, @@ -700,7 +698,7 @@ export async function monitorWebProvider( const heartbeatLogger = getChildLogger({ module: "web-heartbeat", runId }); const reconnectLogger = getChildLogger({ module: "web-reconnect", runId }); const cfg = loadConfig(); - const configuredMaxMb = cfg.inbound?.reply?.mediaMaxMb; + const configuredMaxMb = cfg.inbound?.agent?.mediaMaxMb; const maxMediaBytes = typeof configuredMaxMb === "number" && configuredMaxMb > 0 ? configuredMaxMb * 1024 * 1024 @@ -873,7 +871,7 @@ export async function monitorWebProvider( ); if (latest.chatType !== "group") { - const sessionCfg = cfg.inbound?.reply?.session; + const sessionCfg = cfg.inbound?.session; const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; const storePath = resolveStorePath(sessionCfg?.store); const to = (() => { diff --git a/src/web/session.ts b/src/web/session.ts index 3679ffd9c..ed907ecbc 100644 --- a/src/web/session.ts +++ b/src/web/session.ts @@ -11,7 +11,7 @@ import { } from "@whiskeysockets/baileys"; import qrcode from "qrcode-terminal"; -import { SESSION_STORE_DEFAULT } from "../config/sessions.js"; +import { resolveDefaultSessionStorePath } from "../config/sessions.js"; import { danger, info, success } from "../globals.js"; import { getChildLogger, toPinoLikeLogger } from "../logging.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; @@ -153,7 +153,7 @@ export async function logoutWeb(runtime: RuntimeEnv = defaultRuntime) { } await fs.rm(WA_WEB_AUTH_DIR, { recursive: true, force: true }); // Also drop session store to clear lingering per-sender state after logout. - await fs.rm(SESSION_STORE_DEFAULT, { force: true }); + await fs.rm(resolveDefaultSessionStorePath(), { force: true }); runtime.log(success("Cleared WhatsApp Web credentials.")); return true; }