codeep 2.0.1 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -600,6 +600,62 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
600
600
  }
601
601
  }
602
602
  // ─── Export ────────────────────────────────────────────────────────────────
603
+ // ─── Plan mode (2.0.2) ────────────────────────────────────────────────────
604
+ case 'plan': {
605
+ // Identical contract to TUI /plan: generate a pre-execution plan,
606
+ // surface it, hold as pending so /go can execute it without re-planning.
607
+ if (!args.length) {
608
+ const { getPendingPlan } = await import('../utils/planMode.js');
609
+ const cur = getPendingPlan();
610
+ return {
611
+ handled: true,
612
+ response: cur
613
+ ? `**Pending plan for:** _${cur.task}_\n\n${cur.plan}\n\n---\nRun \`/go\` to execute, or \`/plan <revised task>\` to revise.`
614
+ : 'Usage: `/plan <task>` — generates a plan you can review, then `/go` to execute.',
615
+ };
616
+ }
617
+ const task = args.join(' ');
618
+ onChunk(`_Generating plan for: ${task.slice(0, 80)}${task.length > 80 ? '…' : ''}_\n\n`);
619
+ try {
620
+ const { generatePlan } = await import('../utils/planMode.js');
621
+ const plan = await generatePlan(task);
622
+ return {
623
+ handled: true,
624
+ response: `${plan}\n\n---\nRun \`/go\` to execute this plan, or \`/plan <revised task>\` to refine it.`,
625
+ streaming: true,
626
+ };
627
+ }
628
+ catch (err) {
629
+ return { handled: true, response: `Plan generation failed: ${err.message}`, streaming: true };
630
+ }
631
+ }
632
+ case 'go': {
633
+ const { getPendingPlan, composeExecutionPrompt, clearPendingPlan } = await import('../utils/planMode.js');
634
+ const cur = getPendingPlan();
635
+ if (!cur) {
636
+ return { handled: true, response: 'No pending plan. Run `/plan <task>` first.' };
637
+ }
638
+ const prompt = composeExecutionPrompt(cur);
639
+ clearPendingPlan();
640
+ onChunk(`_Executing approved plan…_\n\n`);
641
+ try {
642
+ const { buildProjectContext } = await import('./session.js');
643
+ const ctx = buildProjectContext(session.workspaceRoot);
644
+ const agentResult = await runAgent(prompt, ctx, {
645
+ abortSignal,
646
+ onIteration: (_i, msg) => { onChunk(msg + '\n'); },
647
+ onThinking: (text) => { onChunk(text); },
648
+ });
649
+ return {
650
+ handled: true,
651
+ response: agentResult.finalResponse || '_(plan executed; no final summary)_',
652
+ streaming: true,
653
+ };
654
+ }
655
+ catch (err) {
656
+ return { handled: true, response: `Plan execution failed: ${err.message}`, streaming: true };
657
+ }
658
+ }
603
659
  case 'export': {
604
660
  if (!session.history.length)
605
661
  return { handled: true, response: 'No messages to export.' };
@@ -52,6 +52,9 @@ const AVAILABLE_COMMANDS = [
52
52
  { name: 'mcp', description: 'Manage MCP servers, marketplace, resources, prompts', input: { hint: '[browse | install <id> | add | remove | reload | resources | read <uri> | prompts | prompt <server> <name>]' } },
53
53
  { name: 'openrouter', description: 'OpenRouter routing preferences (prefer/ignore/fallbacks/privacy/clear)', input: { hint: '[show | prefer <p,...> | ignore <p,...> | fallbacks on|off | privacy strict|allow | clear]' } },
54
54
  { name: 'export', description: 'Export conversation', input: { hint: 'json | md | txt' } },
55
+ // Plan mode (2.0.2)
56
+ { name: 'plan', description: 'Generate a numbered plan for a task — review before /go executes', input: { hint: '<task>' } },
57
+ { name: 'go', description: 'Execute the pending plan from /plan' },
55
58
  // Project intelligence
56
59
  { name: 'scan', description: 'Scan project structure and generate summary' },
57
60
  { name: 'review', description: 'Run code review on project or specific files', input: { hint: '[file…]' } },
package/dist/api/index.js CHANGED
@@ -593,6 +593,13 @@ async function chatAnthropic(message, history, model, apiKey, onChunk, abortSign
593
593
  headers['x-api-key'] = apiKey;
594
594
  }
595
595
  try {
596
+ // Anthropic prompt caching: wrap system as an array with a
597
+ // `cache_control` marker so the static system prompt (typically large
598
+ // and stable across a session) is cached. Below 1024 input tokens
599
+ // Anthropic silently skips caching — no error.
600
+ const cachedSystem = useNativeSystem
601
+ ? { system: [{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } }] }
602
+ : {};
596
603
  const response = await fetch(`${baseUrl}/v1/messages`, {
597
604
  method: 'POST',
598
605
  headers,
@@ -602,7 +609,7 @@ async function chatAnthropic(message, history, model, apiKey, onChunk, abortSign
602
609
  max_tokens: maxTokens,
603
610
  temperature,
604
611
  stream,
605
- ...(useNativeSystem ? { system: systemPrompt } : {}),
612
+ ...cachedSystem,
606
613
  }),
607
614
  signal: controller.signal,
608
615
  });
@@ -649,6 +656,8 @@ async function handleAnthropicStream(body, onChunk) {
649
656
  let buffer = '';
650
657
  let inputTokens = 0;
651
658
  let outputTokens = 0;
659
+ let cacheCreationTokens = 0;
660
+ let cacheReadTokens = 0;
652
661
  let streamModel = '';
653
662
  while (true) {
654
663
  const { done, value } = await reader.read();
@@ -669,9 +678,13 @@ async function handleAnthropicStream(body, onChunk) {
669
678
  onChunk(text);
670
679
  }
671
680
  }
672
- // message_start contains input_tokens
681
+ // message_start contains input_tokens (and cache create/read
682
+ // when prompt caching is in play).
673
683
  if (parsed.type === 'message_start' && parsed.message?.usage) {
674
- inputTokens = parsed.message.usage.input_tokens || 0;
684
+ const u = parsed.message.usage;
685
+ inputTokens = u.input_tokens || 0;
686
+ cacheCreationTokens = u.cache_creation_input_tokens || 0;
687
+ cacheReadTokens = u.cache_read_input_tokens || 0;
675
688
  streamModel = parsed.message.model || '';
676
689
  }
677
690
  // message_delta contains output_tokens
@@ -686,8 +699,15 @@ async function handleAnthropicStream(body, onChunk) {
686
699
  }
687
700
  }
688
701
  // Record token usage
689
- if (inputTokens > 0 || outputTokens > 0) {
690
- recordTokenUsage({ promptTokens: inputTokens, completionTokens: outputTokens, totalTokens: inputTokens + outputTokens }, streamModel || 'unknown', config.get('provider'));
702
+ if (inputTokens > 0 || outputTokens > 0 || cacheReadTokens > 0 || cacheCreationTokens > 0) {
703
+ const totalPrompt = inputTokens + cacheCreationTokens + cacheReadTokens;
704
+ recordTokenUsage({
705
+ promptTokens: totalPrompt,
706
+ completionTokens: outputTokens,
707
+ totalTokens: totalPrompt + outputTokens,
708
+ cacheCreationTokens: cacheCreationTokens || undefined,
709
+ cacheReadTokens: cacheReadTokens || undefined,
710
+ }, streamModel || 'unknown', config.get('provider'));
691
711
  }
692
712
  // Strip <think> tags from MiniMax responses
693
713
  return stripThinkTags(chunks.join(''));
@@ -91,6 +91,8 @@ const COMMAND_DESCRIPTIONS = {
91
91
  'hooks': 'List installed lifecycle hooks (.codeep/hooks/<event>.sh)',
92
92
  'mcp': 'Manage MCP servers (browse, install, add, remove, resources, prompts)',
93
93
  'openrouter': 'Tune OpenRouter routing (preferred / ignore providers, fallbacks, privacy)',
94
+ 'plan': 'Generate a numbered plan for a task — review before /go executes it',
95
+ 'go': 'Execute the pending plan from /plan',
94
96
  };
95
97
  import { helpCategories, keyboardShortcuts } from './components/Help.js';
96
98
  import { handleSettingsKey, SETTINGS } from './components/Settings.js';
@@ -230,6 +232,8 @@ export class App {
230
232
  // Keep in lockstep with COMMAND_DESCRIPTIONS below and helpCategories.
231
233
  'compact', 'commands', 'checkpoint', 'checkpoints', 'rewind',
232
234
  'hooks', 'mcp', 'openrouter',
235
+ // 2.0.2 — plan mode.
236
+ 'plan', 'go',
233
237
  'c', 't', 'd', 'r', 'f', 'e', 'o', 'b', 'p',
234
238
  ];
235
239
  constructor(options) {
@@ -216,6 +216,65 @@ export async function handleCommand(command, args, ctx) {
216
216
  runAgentTask(args.join(' '), true, ctx, () => null, () => { });
217
217
  break;
218
218
  }
219
+ case 'plan': {
220
+ // Plan mode: ask the model for a plan, surface it, hold as pending.
221
+ // The user runs /go to execute or /plan <revised> to revise. See
222
+ // src/utils/planMode.ts for the rationale + system prompt.
223
+ if (!args.length) {
224
+ const { getPendingPlan } = await import('../utils/planMode.js');
225
+ const cur = getPendingPlan();
226
+ if (cur) {
227
+ ctx.app.addMessage({
228
+ role: 'system',
229
+ content: `**Pending plan for:** _${cur.task}_\n\n${cur.plan}\n\n---\nRun \`/go\` to execute, or \`/plan <revised task>\` to revise.`,
230
+ });
231
+ }
232
+ else {
233
+ ctx.app.notify('Usage: /plan <task> — generates a plan you can review, then /go to execute.');
234
+ }
235
+ return;
236
+ }
237
+ if (ctx.isAgentRunning()) {
238
+ ctx.app.notify('Agent already running. Use /stop first.');
239
+ return;
240
+ }
241
+ const task = args.join(' ');
242
+ ctx.app.addMessage({ role: 'user', content: `/plan ${task}` });
243
+ ctx.app.notify('Generating plan…');
244
+ try {
245
+ const { generatePlan } = await import('../utils/planMode.js');
246
+ const plan = await generatePlan(task);
247
+ ctx.app.addMessage({
248
+ role: 'assistant',
249
+ content: `${plan}\n\n---\nRun \`/go\` to execute this plan, or \`/plan <revised task>\` to refine it.`,
250
+ });
251
+ }
252
+ catch (err) {
253
+ ctx.app.notify(`Plan generation failed: ${err.message}`);
254
+ }
255
+ break;
256
+ }
257
+ case 'go': {
258
+ // Execute the pending plan from /plan. The agent loop sees the
259
+ // task + plan as a single prompt, so MCP tools, hooks, permissions,
260
+ // and verification all apply unchanged.
261
+ const { getPendingPlan, composeExecutionPrompt, clearPendingPlan } = await import('../utils/planMode.js');
262
+ const cur = getPendingPlan();
263
+ if (!cur) {
264
+ ctx.app.notify('No pending plan. Run `/plan <task>` first.');
265
+ return;
266
+ }
267
+ if (ctx.isAgentRunning()) {
268
+ ctx.app.notify('Agent already running. Use /stop first.');
269
+ return;
270
+ }
271
+ const prompt = composeExecutionPrompt(cur);
272
+ clearPendingPlan();
273
+ ctx.app.notify(`Executing plan for: ${cur.task.slice(0, 80)}${cur.task.length > 80 ? '…' : ''}`);
274
+ const { runAgentTask } = await import('./agentExecution.js');
275
+ runAgentTask(prompt, false, ctx, () => null, () => { });
276
+ break;
277
+ }
219
278
  case 'stop': {
220
279
  if (ctx.isAgentRunning() && ctx.abortController) {
221
280
  ctx.abortController.abort();
@@ -46,6 +46,8 @@ export const helpCategories = [
46
46
  items: [
47
47
  { key: '/agent <task>', description: 'Run agent with task' },
48
48
  { key: '/agent-dry <task>', description: 'Dry run (no changes)' },
49
+ { key: '/plan <task>', description: 'Generate a plan first — review before /go executes' },
50
+ { key: '/go', description: 'Execute the pending plan from /plan' },
49
51
  { key: '/stop', description: 'Stop running agent' },
50
52
  { key: '/undo', description: 'Undo last agent action' },
51
53
  { key: '/undo-all', description: 'Undo all agent actions' },
@@ -301,9 +301,25 @@ additionalTools) {
301
301
  }
302
302
  else {
303
303
  endpoint = `${baseUrl}/v1/messages`;
304
+ // Anthropic prompt caching. Two cache breakpoints:
305
+ // 1. `system` (largest stable block — system prompt + skills catalog)
306
+ // 2. last tool in `tools` (Anthropic caches everything up to and
307
+ // including the marker, so this caches the entire tools array)
308
+ // Cache hits cost 0.1× input. Misses ("cache creation") cost 1.25×.
309
+ // Net win after the 2nd same-shape request. Below 1024 input tokens
310
+ // Anthropic silently skips caching — no error path to handle.
311
+ const anthropicTools = getAnthropicTools(additionalTools);
312
+ const cachedTools = anthropicTools.length > 0
313
+ ? [
314
+ ...anthropicTools.slice(0, -1),
315
+ { ...anthropicTools[anthropicTools.length - 1], cache_control: { type: 'ephemeral' } },
316
+ ]
317
+ : anthropicTools;
304
318
  body = {
305
- model, system: systemPrompt, messages,
306
- tools: getAnthropicTools(additionalTools), stream: useStreaming,
319
+ model,
320
+ system: [{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } }],
321
+ messages,
322
+ tools: cachedTools, stream: useStreaming,
307
323
  ...tempParam, max_tokens: getEffectiveMaxTokens(providerId, Math.max(config.get('maxTokens'), 16384)),
308
324
  };
309
325
  }
@@ -435,10 +451,15 @@ export async function agentChatFallback(messages, systemPrompt, onChunk, abortSi
435
451
  }
436
452
  else {
437
453
  endpoint = `${baseUrl}/v1/messages`;
454
+ // Fallback path injects system+tools as the first user message
455
+ // (no native tool API). Cache that block — it's large and stable.
438
456
  body = {
439
457
  model,
440
458
  messages: [
441
- { role: 'user', content: fallbackPrompt },
459
+ {
460
+ role: 'user',
461
+ content: [{ type: 'text', text: fallbackPrompt, cache_control: { type: 'ephemeral' } }],
462
+ },
442
463
  { role: 'assistant', content: 'Understood. I will use the tools as specified.' },
443
464
  ...messages,
444
465
  ],
@@ -169,13 +169,30 @@ export async function handleAnthropicAgentStream(body, onChunk, model, providerI
169
169
  const data = line.slice(6);
170
170
  try {
171
171
  const parsed = JSON.parse(data);
172
- // message_start has input tokens; message_delta has output tokens — merge both
172
+ // message_start has input tokens (incl. cache create/read fields if
173
+ // prompt caching is in use); message_delta has output tokens —
174
+ // merge both so extractAnthropicUsage sees the full picture.
173
175
  if (parsed.type === 'message_start' && parsed.message?.usage) {
174
- usageData = { usage: { input_tokens: parsed.message.usage.input_tokens || 0, output_tokens: 0 } };
176
+ const u = parsed.message.usage;
177
+ usageData = {
178
+ usage: {
179
+ input_tokens: u.input_tokens || 0,
180
+ output_tokens: 0,
181
+ cache_creation_input_tokens: u.cache_creation_input_tokens || 0,
182
+ cache_read_input_tokens: u.cache_read_input_tokens || 0,
183
+ },
184
+ };
175
185
  }
176
186
  else if (parsed.type === 'message_delta' && parsed.usage) {
177
- const inputTokens = usageData?.usage?.input_tokens || 0;
178
- usageData = { usage: { input_tokens: inputTokens, output_tokens: parsed.usage.output_tokens || 0 } };
187
+ const prev = usageData?.usage ?? {};
188
+ usageData = {
189
+ usage: {
190
+ input_tokens: prev.input_tokens || 0,
191
+ output_tokens: parsed.usage.output_tokens || 0,
192
+ cache_creation_input_tokens: prev.cache_creation_input_tokens || 0,
193
+ cache_read_input_tokens: prev.cache_read_input_tokens || 0,
194
+ },
195
+ };
179
196
  }
180
197
  if (parsed.type === 'content_block_start') {
181
198
  const block = parsed.content_block;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Plan mode — explicit pre-execution preview.
3
+ *
4
+ * Flow:
5
+ * 1. User runs `/plan <task>` — we ask the LLM for a numbered plan
6
+ * (no tool calls, no file changes) and surface it to the user.
7
+ * 2. We hold the (task, plan) pair as the *pending* plan, scoped to
8
+ * the current process.
9
+ * 3. User runs `/go` to execute, or `/plan <revised task>` to refine.
10
+ * `/go` hands the original task + approved plan to the regular
11
+ * agent loop as a single prompt, so the existing tool execution,
12
+ * verification, and permission paths apply unchanged.
13
+ *
14
+ * Why this design (MVP):
15
+ * - Zero changes to the agent loop, MCP wiring, or ACP server.
16
+ * - Plan rendering reuses the chat markdown renderer (no new TUI
17
+ * panel to maintain).
18
+ * - Edit flow is just "/plan <revised task>" — generates a new plan
19
+ * that replaces the pending one; user pays one extra LLM call but
20
+ * gets human-readable revision history in the chat above.
21
+ * - When we ship a proper plan-mode UI later (TUI panel with
22
+ * Accept/Edit/Reject buttons + per-step progress), it can keep
23
+ * using this module as the backend.
24
+ */
25
+ export interface PendingPlan {
26
+ task: string;
27
+ plan: string;
28
+ createdAt: number;
29
+ }
30
+ /**
31
+ * Ask the model for a plan for the given task. Stores the (task, plan)
32
+ * pair as the pending plan so a subsequent `/go` can execute it.
33
+ * Throws on chat failure — caller renders the error.
34
+ */
35
+ export declare function generatePlan(task: string, onChunk?: (text: string) => void): Promise<string>;
36
+ export declare function getPendingPlan(): PendingPlan | null;
37
+ export declare function clearPendingPlan(): void;
38
+ /**
39
+ * Compose the prompt the agent loop receives when the user runs `/go`.
40
+ * The agent treats this as a normal task, so tool calls / verification /
41
+ * permissions / hooks all flow through the existing paths — we just
42
+ * front-load the plan as context so the model doesn't re-plan
43
+ * implicitly.
44
+ */
45
+ export declare function composeExecutionPrompt(plan: PendingPlan): string;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Plan mode — explicit pre-execution preview.
3
+ *
4
+ * Flow:
5
+ * 1. User runs `/plan <task>` — we ask the LLM for a numbered plan
6
+ * (no tool calls, no file changes) and surface it to the user.
7
+ * 2. We hold the (task, plan) pair as the *pending* plan, scoped to
8
+ * the current process.
9
+ * 3. User runs `/go` to execute, or `/plan <revised task>` to refine.
10
+ * `/go` hands the original task + approved plan to the regular
11
+ * agent loop as a single prompt, so the existing tool execution,
12
+ * verification, and permission paths apply unchanged.
13
+ *
14
+ * Why this design (MVP):
15
+ * - Zero changes to the agent loop, MCP wiring, or ACP server.
16
+ * - Plan rendering reuses the chat markdown renderer (no new TUI
17
+ * panel to maintain).
18
+ * - Edit flow is just "/plan <revised task>" — generates a new plan
19
+ * that replaces the pending one; user pays one extra LLM call but
20
+ * gets human-readable revision history in the chat above.
21
+ * - When we ship a proper plan-mode UI later (TUI panel with
22
+ * Accept/Edit/Reject buttons + per-step progress), it can keep
23
+ * using this module as the backend.
24
+ */
25
+ import { chat } from '../api/index.js';
26
+ const PLAN_SYSTEM_PROMPT = `You are in PLAN MODE.
27
+
28
+ The user has given you a task. **Do not execute anything.** Do not call
29
+ tools. Do not modify files. Do not run shell commands. Instead, produce
30
+ a numbered plan of the steps you would take, so the user can review and
31
+ approve before any changes happen.
32
+
33
+ Format your response as Markdown:
34
+
35
+ ## Plan: <one-line summary of the task>
36
+
37
+ 1. **<short step name>** — what you'd do (e.g. read file, edit lines, run command).
38
+ _Why:_ rationale (1 sentence).
39
+ _Expected outcome:_ what the user should see after this step.
40
+
41
+ 2. **<step name>** — …
42
+
43
+ (continue numbering)
44
+
45
+ End with these three lines (verbatim labels, fill in values):
46
+
47
+ **Risk:** <low | medium | high> — <one-sentence reason; e.g. "schema change, requires migration">
48
+ **Files affected:** <comma-separated list of paths you'd touch, or "none">
49
+ **Commands run:** <comma-separated shell commands, or "none">
50
+
51
+ Rules:
52
+ - Be concrete. Reference real file paths from the project if you know them.
53
+ - Keep steps small enough that each maps to one or two tool calls when
54
+ later executed.
55
+ - If the task is ambiguous, list the assumption(s) you're making at the
56
+ top of the plan ("Assumes: ...") so the user can correct before /go.
57
+ - Do NOT produce code — describe what you'd change, not the change
58
+ itself. Code generation belongs in execution, not planning.
59
+ - If the task is trivial (single-file rename, single-line edit), say so
60
+ in one sentence and skip the formal plan — don't bloat tiny work.`;
61
+ let pending = null;
62
+ /**
63
+ * Ask the model for a plan for the given task. Stores the (task, plan)
64
+ * pair as the pending plan so a subsequent `/go` can execute it.
65
+ * Throws on chat failure — caller renders the error.
66
+ */
67
+ export async function generatePlan(task, onChunk) {
68
+ const history = [
69
+ { role: 'system', content: PLAN_SYSTEM_PROMPT },
70
+ ];
71
+ const plan = await chat(task, history, onChunk);
72
+ pending = { task, plan, createdAt: Date.now() };
73
+ return plan;
74
+ }
75
+ export function getPendingPlan() {
76
+ return pending;
77
+ }
78
+ export function clearPendingPlan() {
79
+ pending = null;
80
+ }
81
+ /**
82
+ * Compose the prompt the agent loop receives when the user runs `/go`.
83
+ * The agent treats this as a normal task, so tool calls / verification /
84
+ * permissions / hooks all flow through the existing paths — we just
85
+ * front-load the plan as context so the model doesn't re-plan
86
+ * implicitly.
87
+ */
88
+ export function composeExecutionPrompt(plan) {
89
+ return `${plan.task}
90
+
91
+ I've reviewed the following plan and approved it. Execute it step by step. If any step turns out to be wrong or impossible mid-execution, stop and report — don't silently improvise.
92
+
93
+ ${plan.plan}`;
94
+ }
@@ -5,6 +5,13 @@ export interface TokenUsage {
5
5
  promptTokens: number;
6
6
  completionTokens: number;
7
7
  totalTokens: number;
8
+ /** Anthropic prompt caching: tokens written to the cache on this call
9
+ * (billed at ~1.25× input rate). Undefined for providers that don't
10
+ * support caching or for calls below the cache size threshold. */
11
+ cacheCreationTokens?: number;
12
+ /** Anthropic prompt caching: tokens read from cache on this call
13
+ * (billed at ~0.1× input rate — the big savings live here). */
14
+ cacheReadTokens?: number;
8
15
  }
9
16
  export interface SessionTokenStats {
10
17
  totalPromptTokens: number;
@@ -18,6 +25,9 @@ interface TokenRecord {
18
25
  promptTokens: number;
19
26
  completionTokens: number;
20
27
  totalTokens: number;
28
+ /** Anthropic prompt caching breakdown — see TokenUsage. */
29
+ cacheCreationTokens?: number;
30
+ cacheReadTokens?: number;
21
31
  model: string;
22
32
  provider: string;
23
33
  /** Authoritative per-call USD from the provider (OpenRouter), if available. */
@@ -59,6 +69,19 @@ export interface ProviderCostBreakdown {
59
69
  * Get cost breakdown grouped by provider/model
60
70
  */
61
71
  export declare function getCostBreakdown(): ProviderCostBreakdown[];
72
+ /**
73
+ * Aggregate Anthropic prompt-caching stats for the current session.
74
+ * Returns the breakdown plus an estimate of what the input billing would
75
+ * have been *without* caching, so we can surface "you saved $X" to the
76
+ * user.
77
+ */
78
+ export interface CacheStats {
79
+ cacheCreationTokens: number;
80
+ cacheReadTokens: number;
81
+ /** Sum of estimatedSavings across all Anthropic-priced records. */
82
+ estimatedSavingsUsd: number;
83
+ }
84
+ export declare function getCacheStats(): CacheStats;
62
85
  /**
63
86
  * Get session stats
64
87
  */
@@ -81,6 +81,8 @@ export function recordTokenUsage(usage, model, provider, actualCostUsd) {
81
81
  promptTokens: usage.promptTokens,
82
82
  completionTokens: usage.completionTokens,
83
83
  totalTokens: usage.totalTokens,
84
+ cacheCreationTokens: usage.cacheCreationTokens,
85
+ cacheReadTokens: usage.cacheReadTokens,
84
86
  model,
85
87
  provider,
86
88
  actualCostUsd,
@@ -104,10 +106,19 @@ export function extractOpenAIUsage(data) {
104
106
  */
105
107
  export function extractAnthropicUsage(data) {
106
108
  if (data?.usage) {
109
+ const inputTokens = data.usage.input_tokens || 0;
110
+ const outputTokens = data.usage.output_tokens || 0;
111
+ const cacheCreation = data.usage.cache_creation_input_tokens || 0;
112
+ const cacheRead = data.usage.cache_read_input_tokens || 0;
113
+ // Anthropic returns input_tokens EXCLUSIVE of cache creation and cache
114
+ // read tokens — they're reported separately. Total prompt = sum of all
115
+ // three so our context window math doesn't undercount.
107
116
  return {
108
- promptTokens: data.usage.input_tokens || 0,
109
- completionTokens: data.usage.output_tokens || 0,
110
- totalTokens: (data.usage.input_tokens || 0) + (data.usage.output_tokens || 0),
117
+ promptTokens: inputTokens + cacheCreation + cacheRead,
118
+ completionTokens: outputTokens,
119
+ totalTokens: inputTokens + cacheCreation + cacheRead + outputTokens,
120
+ cacheCreationTokens: cacheCreation || undefined,
121
+ cacheReadTokens: cacheRead || undefined,
111
122
  };
112
123
  }
113
124
  return null;
@@ -132,13 +143,42 @@ export function getCostBreakdown() {
132
143
  else {
133
144
  const pricing = MODEL_PRICING[record.model];
134
145
  if (pricing) {
135
- existing.estimatedCost += (record.promptTokens / 1_000_000) * pricing.inputPer1M + (record.completionTokens / 1_000_000) * pricing.outputPer1M;
146
+ // Anthropic prompt caching: cache_creation_input is billed at 1.25×
147
+ // the base input rate, cache_read_input at 0.1×. The remaining
148
+ // (uncached) prompt tokens bill at the standard 1.0× rate.
149
+ const cacheCreate = record.cacheCreationTokens ?? 0;
150
+ const cacheRead = record.cacheReadTokens ?? 0;
151
+ const uncachedPrompt = Math.max(0, record.promptTokens - cacheCreate - cacheRead);
152
+ existing.estimatedCost +=
153
+ (uncachedPrompt / 1_000_000) * pricing.inputPer1M
154
+ + (cacheCreate / 1_000_000) * pricing.inputPer1M * 1.25
155
+ + (cacheRead / 1_000_000) * pricing.inputPer1M * 0.1
156
+ + (record.completionTokens / 1_000_000) * pricing.outputPer1M;
136
157
  }
137
158
  }
138
159
  grouped.set(key, existing);
139
160
  }
140
161
  return Array.from(grouped.values());
141
162
  }
163
+ export function getCacheStats() {
164
+ let cacheCreate = 0;
165
+ let cacheRead = 0;
166
+ let savings = 0;
167
+ for (const record of records) {
168
+ cacheCreate += record.cacheCreationTokens ?? 0;
169
+ cacheRead += record.cacheReadTokens ?? 0;
170
+ // Savings = what cache-read tokens would have cost at full input rate,
171
+ // minus what they actually cost at 0.1×. (Cache creation is a slight
172
+ // *penalty* of 0.25× — netted in for honest reporting.)
173
+ const pricing = MODEL_PRICING[record.model];
174
+ if (pricing) {
175
+ const cReadSaved = ((record.cacheReadTokens ?? 0) / 1_000_000) * pricing.inputPer1M * 0.9;
176
+ const cCreateCost = ((record.cacheCreationTokens ?? 0) / 1_000_000) * pricing.inputPer1M * 0.25;
177
+ savings += cReadSaved - cCreateCost;
178
+ }
179
+ }
180
+ return { cacheCreationTokens: cacheCreate, cacheReadTokens: cacheRead, estimatedSavingsUsd: Math.max(0, savings) };
181
+ }
142
182
  /**
143
183
  * Get session stats
144
184
  */
@@ -207,6 +247,18 @@ export function formatCostReport() {
207
247
  lines.push(`| \`${b.provider}\` / \`${b.model}\` | ${formatTokenCount(b.promptTokens)} | ${formatTokenCount(b.completionTokens)} | $${b.estimatedCost.toFixed(4)} |`);
208
248
  }
209
249
  }
250
+ // Prompt caching summary — only shown if at least one cached call landed.
251
+ const cache = getCacheStats();
252
+ if (cache.cacheReadTokens > 0 || cache.cacheCreationTokens > 0) {
253
+ lines.push('', '### Prompt caching');
254
+ lines.push(`**Cache reads:** ${formatTokenCount(cache.cacheReadTokens)} tokens (billed at 0.1× input rate)`);
255
+ if (cache.cacheCreationTokens > 0) {
256
+ lines.push(`**Cache writes:** ${formatTokenCount(cache.cacheCreationTokens)} tokens (billed at 1.25× input rate)`);
257
+ }
258
+ if (cache.estimatedSavingsUsd > 0) {
259
+ lines.push(`**Estimated savings vs no caching:** $${cache.estimatedSavingsUsd.toFixed(4)}`);
260
+ }
261
+ }
210
262
  // Models with no pricing entry don't contribute to cost — flag so users
211
263
  // aren't surprised the total looks low.
212
264
  const untracked = breakdown.filter(b => b.estimatedCost === 0 && (b.promptTokens + b.completionTokens) > 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeep",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "AI-powered coding assistant built for the terminal. Multiple LLM providers, project-aware context, and a seamless development workflow.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",