diff --git a/docs/adapters/claude-local.md b/docs/adapters/claude-local.md index fc64fcf8d83..d0e79455f0f 100644 --- a/docs/adapters/claude-local.md +++ b/docs/adapters/claude-local.md @@ -3,19 +3,19 @@ title: Claude Local summary: Claude Code local adapter setup and configuration --- -The `claude_local` adapter runs Anthropic's Claude Code CLI locally. It supports session persistence, skills injection, and structured output parsing. +The `claude_local` adapter runs Anthropic's Claude Code CLI locally. It supports session persistence, stable prompt-bundle materialization, skills injection, and structured output parsing. ## Prerequisites - Claude Code CLI installed (`claude` command available) -- `ANTHROPIC_API_KEY` set in the environment or agent config +- Claude subscription login (`claude login`) or `ANTHROPIC_API_KEY` set in the environment or agent config ## Configuration Fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `cwd` | string | Yes | Working directory for the agent process (absolute path; created automatically if missing when permissions allow) | -| `model` | string | No | Claude model to use (e.g. `claude-opus-4-6`) | +| `model` | string | No | Claude model to use (e.g. `claude-opus-4-7` or `claude-sonnet-4-6`) | | `promptTemplate` | string | No | Prompt used for all runs | | `env` | object | No | Environment variables (supports secret refs) | | `timeoutSec` | number | No | Process timeout (0 = no timeout) | @@ -45,7 +45,9 @@ If resume fails with an unknown session error, the adapter automatically retries ## Skills Injection -The adapter creates a temporary directory with symlinks to Paperclip skills and passes it via `--add-dir`. This makes skills discoverable without polluting the agent's working directory. +The adapter creates a company-scoped, content-addressed prompt bundle under the active Paperclip instance and passes it via `--add-dir`. The bundle includes symlinks to selected Paperclip skills and, when configured, a stable `agent-instructions.md` used with `--append-system-prompt-file`. This keeps repeated agent instructions and skill manifests byte-stable across heartbeats, which lets Claude's prompt caching recognize repeated context. + +Prompt-cache hit telemetry is captured from Claude's `usage.cache_read_input_tokens` stream field. Paperclip normalizes that value to `usage.cachedInputTokens`, stores it on the run usage JSON, and writes it into the cost ledger's cached input token column. For manual local CLI usage outside heartbeat runs (for example running as `claudecoder` directly), use: diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 3d87f7a284c..96085693479 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -114,6 +114,7 @@ export interface AdapterInvocationMeta { commandArgs?: string[]; commandNotes?: string[]; env?: Record; + promptCache?: Record; prompt?: string; promptMetrics?: Record; context?: Record; diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index ecfaa2fefe2..43d1c24d701 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -53,4 +53,5 @@ Operational fields: Notes: - When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling. +- Repeated agent instructions and Paperclip skills are materialized into a company-scoped, content-addressed prompt bundle under the Paperclip instance. Claude usage telemetry exposes prompt-cache hits as usage.cachedInputTokens and cost ledger cached input tokens. `; diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index f23a6e6c9c7..504efbc9b8e 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -749,6 +749,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise { +async function writeFakeClaudeCommand(commandPath: string, opts: { cachedInputTokens?: number } = {}): Promise { + const cachedInputTokens = opts.cachedInputTokens ?? 0; const script = `#!/usr/bin/env node const fs = require("node:fs"); const path = require("node:path"); @@ -68,7 +69,7 @@ if (capturePath) { } console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "claude-session-1", model: "claude-sonnet" })); console.log(JSON.stringify({ type: "assistant", session_id: "claude-session-1", message: { content: [{ type: "text", text: "hello" }] } })); -console.log(JSON.stringify({ type: "result", session_id: "claude-session-1", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } })); +console.log(JSON.stringify({ type: "result", session_id: "claude-session-1", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: ${cachedInputTokens}, output_tokens: 1 } })); `; await fs.writeFile(commandPath, script, "utf8"); await fs.chmod(commandPath, 0o755); @@ -688,7 +689,7 @@ describe("claude execute", () => { const paperclipHome = path.join(root, "paperclip-home"); await fs.mkdir(workspace, { recursive: true }); await fs.writeFile(instructionsPath, "You are managed instructions.\n", "utf8"); - await writeFakeClaudeCommand(commandPath); + await writeFakeClaudeCommand(commandPath, { cachedInputTokens: 7 }); const previousHome = process.env.HOME; const previousPaperclipHome = process.env.PAPERCLIP_HOME; @@ -697,6 +698,7 @@ describe("claude execute", () => { process.env.PAPERCLIP_HOME = paperclipHome; delete process.env.PAPERCLIP_INSTANCE_ID; + const metaEvents: Array> = []; try { const first = await execute({ runId: "run-1", @@ -725,6 +727,9 @@ describe("claude execute", () => { context: {}, authToken: "run-jwt-token", onLog: async () => {}, + onMeta: async (meta) => { + metaEvents.push(meta as unknown as Record); + }, }); expect(first.exitCode).toBe(0); @@ -796,10 +801,15 @@ describe("claude execute", () => { }, authToken: "run-jwt-token", onLog: async () => {}, + onMeta: async (meta) => { + metaEvents.push(meta as unknown as Record); + }, }); expect(second.exitCode).toBe(0); expect(second.errorMessage).toBeNull(); + expect(first.usage?.cachedInputTokens).toBe(7); + expect(second.usage?.cachedInputTokens).toBe(7); const capture1 = JSON.parse(await fs.readFile(capturePath1, "utf8")) as CapturePayload; const capture2 = JSON.parse(await fs.readFile(capturePath2, "utf8")) as CapturePayload; @@ -825,6 +835,11 @@ describe("claude execute", () => { expect(capture2.argv).toContain("claude-session-1"); expect(capture2.prompt).toContain("## Paperclip Resume Delta"); expect(capture2.prompt).not.toContain("Follow the paperclip heartbeat."); + + const latestPromptCache = metaEvents.at(-1)?.promptCache as Record | undefined; + expect(latestPromptCache?.strategy).toBe("content-addressed-prompt-bundle"); + expect(latestPromptCache?.telemetry).toBe("usage.cachedInputTokens"); + expect(latestPromptCache?.addDir).toBe(capture2.addDir); } finally { if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome;