@axplusb/kepler 2.2.0 → 2.3.1

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.
@@ -0,0 +1,92 @@
1
+ /**
2
+ * cache_control breakpoints for Anthropic prompt caching (PRD-071 Phase 2).
3
+ *
4
+ * Shared by every direct-API call site that talks to Anthropic — LocalAgent
5
+ * (Anthropic direct + OpenRouter passthrough) and agent-loop's Task sub-agents.
6
+ *
7
+ * Anthropic allows 4 breakpoints per request; we spend 3:
8
+ * 1) End of system prompt (1h TTL — persistent across long-idle sessions)
9
+ * 2) Last tool schema (1h TTL — persistent)
10
+ * 3) Second-to-last user message (5min TTL — rolls each turn, cheaper to write)
11
+ *
12
+ * The 4th slot stays reserved (attachments, future retrieval prefix).
13
+ *
14
+ * Extended 1-hour TTL is a beta — pass ANTHROPIC_BETA_HEADER on the request
15
+ * whenever any block carries ttl:'1h'.
16
+ */
17
+
18
+ export const ANTHROPIC_BETA_HEADER = 'extended-cache-ttl-2025-04-11';
19
+
20
+ const CACHE_1H = { type: 'ephemeral', ttl: '1h' };
21
+ const CACHE_5M = { type: 'ephemeral' };
22
+
23
+ /**
24
+ * Turn a `system` string into a content-block array with a cache_control
25
+ * breakpoint on the tail. If the caller already passed blocks, returns them
26
+ * unchanged. Undefined / non-string inputs pass through as-is.
27
+ */
28
+ export function cacheableSystem(systemPrompt) {
29
+ if (Array.isArray(systemPrompt)) return systemPrompt;
30
+ if (!systemPrompt || typeof systemPrompt !== 'string') return systemPrompt;
31
+ return [{ type: 'text', text: systemPrompt, cache_control: CACHE_1H }];
32
+ }
33
+
34
+ /**
35
+ * Return a copy of `tools` with cache_control on the LAST tool. Anthropic
36
+ * caches system + tools as one prefix from that breakpoint, so this single
37
+ * marker covers the whole tool schema regardless of length.
38
+ */
39
+ export function cacheableTools(tools) {
40
+ if (!Array.isArray(tools) || tools.length === 0) return tools || [];
41
+ const out = tools.slice();
42
+ const last = out[out.length - 1];
43
+ out[out.length - 1] = { ...last, cache_control: CACHE_1H };
44
+ return out;
45
+ }
46
+
47
+ /**
48
+ * Tag the SECOND-to-last user message with a 5-min cache_control breakpoint.
49
+ * Leaves the last turn write-through so the next round extends the cache
50
+ * instead of re-writing it. Returns messages unchanged when there aren't
51
+ * enough user turns yet (< 2).
52
+ *
53
+ * Handles both content shapes: string (wrapped into blocks) and block array
54
+ * (tagged on the last block).
55
+ */
56
+ export function withMessageBreakpoint(messages) {
57
+ if (!Array.isArray(messages) || messages.length < 2) return messages;
58
+ const userIdx = [];
59
+ for (let i = 0; i < messages.length; i++) {
60
+ if (messages[i].role === 'user') userIdx.push(i);
61
+ }
62
+ if (userIdx.length < 2) return messages;
63
+ const targetIdx = userIdx[userIdx.length - 2];
64
+ const msg = messages[targetIdx];
65
+
66
+ if (typeof msg.content === 'string') {
67
+ return messages.map((m, i) => i === targetIdx ? {
68
+ ...m,
69
+ content: [{ type: 'text', text: m.content, cache_control: CACHE_5M }],
70
+ } : m);
71
+ }
72
+
73
+ if (Array.isArray(msg.content) && msg.content.length > 0) {
74
+ const blocks = msg.content.slice();
75
+ const last = blocks[blocks.length - 1];
76
+ blocks[blocks.length - 1] = { ...last, cache_control: CACHE_5M };
77
+ return messages.map((m, i) => i === targetIdx ? { ...m, content: blocks } : m);
78
+ }
79
+
80
+ return messages;
81
+ }
82
+
83
+ /**
84
+ * True if the given model id needs Anthropic-style explicit cache_control
85
+ * (vs OpenAI/DeepSeek which auto-cache). Handles bare Claude ids and
86
+ * OpenRouter's `anthropic/*` prefix.
87
+ */
88
+ export function needsExplicitCacheControl(model) {
89
+ if (!model) return false;
90
+ const m = model.toLowerCase();
91
+ return m.startsWith('claude') || m.startsWith('anthropic/');
92
+ }
@@ -0,0 +1,127 @@
1
+ const SUMMARY_PREFIX = 'Session continuity summary after /compact:';
2
+
3
+ export function parseCompactTailCount(rest = '', fallback = 8) {
4
+ const text = String(rest || '').trim();
5
+ const match = text.match(/(?:^|\s)(?:--tail=|--tail\s+)?(\d+)(?:\s|$)/);
6
+ const n = match ? Number(match[1]) : Number(fallback);
7
+ if (!Number.isFinite(n)) return fallback;
8
+ return Math.max(2, Math.min(50, Math.floor(n)));
9
+ }
10
+
11
+ export function isCompactHistory(history = []) {
12
+ return typeof history?.[2]?.content === 'string'
13
+ && history[2].content.startsWith(SUMMARY_PREFIX);
14
+ }
15
+
16
+ export function extractCompactSummary(history = []) {
17
+ const content = String(history?.[2]?.content || '');
18
+ if (!content.startsWith(SUMMARY_PREFIX)) return '';
19
+ return content.slice(SUMMARY_PREFIX.length).trim();
20
+ }
21
+
22
+ export function prepareCompactHistory({
23
+ agentHistory = [],
24
+ tailCount = 8,
25
+ minSourceMessages = 2,
26
+ } = {}) {
27
+ const history = Array.isArray(agentHistory)
28
+ ? agentHistory.filter(msg => msg && typeof msg.content === 'string' && msg.content.trim())
29
+ : [];
30
+ const beforeCount = history.length;
31
+ const retainedCount = Math.min(Math.max(2, Number(tailCount) || 8), Math.max(0, beforeCount));
32
+ const prefixCount = isCompactHistory(history) ? 3 : 0;
33
+ const sourceEnd = Math.max(prefixCount, beforeCount - retainedCount);
34
+ const sourceMessages = history.slice(prefixCount, sourceEnd);
35
+ const tail = history.slice(sourceEnd);
36
+
37
+ if (sourceMessages.length < minSourceMessages) {
38
+ return {
39
+ ok: false,
40
+ reason: beforeCount <= prefixCount + retainedCount
41
+ ? 'not enough history beyond the retained tail'
42
+ : 'not enough compactable messages',
43
+ beforeCount,
44
+ prefixCount,
45
+ sourceMessages,
46
+ tail,
47
+ retainedCount,
48
+ };
49
+ }
50
+
51
+ return {
52
+ ok: true,
53
+ beforeCount,
54
+ prefixCount,
55
+ sourceMessages,
56
+ tail,
57
+ retainedCount: tail.length,
58
+ previousSummary: extractCompactSummary(history),
59
+ };
60
+ }
61
+
62
+ export function applyCompactSummary({
63
+ prepared,
64
+ summary,
65
+ sessionId = '',
66
+ cwd = '',
67
+ originalRequest = '',
68
+ previousSourceMessageCount = 0,
69
+ now = new Date(),
70
+ } = {}) {
71
+ if (!prepared?.ok) throw new Error(prepared?.reason || 'history is not compactable');
72
+ const compactSummary = String(summary || '').trim();
73
+ if (!compactSummary) throw new Error('summary is required');
74
+ const priorCount = Math.max(0, Number(previousSourceMessageCount) || 0);
75
+ const sourceMessageCount = priorCount + prepared.sourceMessages.length;
76
+ const timestamp = now instanceof Date ? now.toISOString() : String(now || new Date().toISOString());
77
+ const firstUser = originalRequest || firstUserMessage(prepared.sourceMessages) || firstUserMessage(prepared.tail) || '(unknown)';
78
+
79
+ const metadata = [
80
+ 'Compact metadata.',
81
+ sessionId ? `Session: ${sessionId}` : '',
82
+ cwd ? `Project: ${cwd}` : '',
83
+ `Compacted at: ${timestamp}`,
84
+ `Covered live messages: ${sourceMessageCount}`,
85
+ `Retained tail messages: ${prepared.tail.length}`,
86
+ ].filter(Boolean).join('\n');
87
+
88
+ const agentHistory = [
89
+ { role: 'user', content: metadata },
90
+ { role: 'user', content: `Original user request from this compacted session:\n${firstUser}` },
91
+ { role: 'user', content: `${SUMMARY_PREFIX}\n${compactSummary}` },
92
+ ...prepared.tail,
93
+ ];
94
+
95
+ return {
96
+ agentHistory,
97
+ summary: compactSummary,
98
+ sourceMessageCount,
99
+ previousSourceMessageCount: priorCount,
100
+ beforeCount: prepared.beforeCount,
101
+ afterCount: agentHistory.length,
102
+ retainedCount: prepared.tail.length,
103
+ compactedCount: prepared.sourceMessages.length,
104
+ };
105
+ }
106
+
107
+ export function localCompactSummary(messages = []) {
108
+ const list = Array.isArray(messages) ? messages : [];
109
+ const userMessages = list.filter(m => m.role === 'user').map(m => String(m.content || '').trim()).filter(Boolean);
110
+ const assistantMessages = list.filter(m => m.role === 'assistant').map(m => String(m.content || '').trim()).filter(Boolean);
111
+ return [
112
+ 'Local compact summary from live conversation context.',
113
+ userMessages.length ? `User requests (${userMessages.length}):` : '',
114
+ ...userMessages.slice(-8).map(text => `- ${truncate(text, 500)}`),
115
+ assistantMessages.length ? `Assistant progress (${assistantMessages.length}):` : '',
116
+ ...assistantMessages.slice(-8).map(text => `- ${truncate(text, 700)}`),
117
+ ].filter(Boolean).join('\n');
118
+ }
119
+
120
+ function firstUserMessage(messages = []) {
121
+ return messages.find(m => m?.role === 'user' && typeof m.content === 'string')?.content || '';
122
+ }
123
+
124
+ function truncate(text, max) {
125
+ const value = String(text || '').replace(/\s+/g, ' ').trim();
126
+ return value.length > max ? value.slice(0, max - 3) + '...' : value;
127
+ }
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { TarangStreamClient } from './stream-client.mjs';
15
15
  import { createToolExecutor } from './tool-executor.mjs';
16
+ import { buildWorkScope } from './work-scope.mjs';
16
17
  import { persistProjectArtifacts } from './project-artifacts.mjs';
17
18
  import { TarangAuth } from '../auth/tarang-auth.mjs';
18
19
  import { ApprovalManager } from './approval.mjs';
@@ -26,7 +27,7 @@ import { ApprovalManager } from './approval.mjs';
26
27
  * @param {number} [opts.maxCost] - abort if cost exceeds this USD amount
27
28
  * @param {boolean} [opts.verbose] - show progress on stderr
28
29
  */
29
- export async function runHeadless({ instruction, model, timeout = 300, maxCost, verbose = false }) {
30
+ export async function runHeadless({ instruction, model, timeout = 300, maxCost, verbose = false, cacheReport = null, local = false }) {
30
31
  const startTime = Date.now();
31
32
 
32
33
  const log = (msg) => {
@@ -51,13 +52,41 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
51
52
  // Auto-approve everything — no prompts
52
53
  const approval = new ApprovalManager({ autoApprove: true });
53
54
 
54
- // ── Stream client ──
55
- const client = new TarangStreamClient({
56
- baseUrl: creds.backendUrl,
57
- token: creds.token,
58
- toolExecutor,
59
- approvalManager: approval,
60
- });
55
+ // ── Client selection ──
56
+ // PRD-071 Phase 2 measurement — --local forces the CLI-side LocalAgent path,
57
+ // bypassing the backend so we can exercise the cache_control wiring we
58
+ // just added to _callClaude / _callOpenRouter. Model comes from the
59
+ // --model flag (which overrides settings dynamically for benchmarking).
60
+ let client;
61
+ if (local) {
62
+ const { LocalAgent } = await import('./local-agent.mjs');
63
+ const localModel = model || creds.models?.local || 'anthropic/claude-sonnet-4';
64
+ const orKey = process.env.OPENROUTER_API_KEY || creds.openRouterKey;
65
+ const anthKey = process.env.ANTHROPIC_API_KEY || creds.anthropicKey;
66
+ if (!orKey && !anthKey) {
67
+ emit({ type: 'error', error: '--local requires OPENROUTER_API_KEY or ANTHROPIC_API_KEY' });
68
+ process.exit(1);
69
+ }
70
+ client = {
71
+ execute: (instr, ctx) => new LocalAgent({
72
+ apiKey: anthKey,
73
+ openRouterKey: orKey,
74
+ model: localModel,
75
+ toolExecutor,
76
+ verbose,
77
+ cwd: process.cwd(),
78
+ maxTurns: 50,
79
+ }).execute(instr, ctx),
80
+ };
81
+ log(`Local mode: ${localModel}`);
82
+ } else {
83
+ client = new TarangStreamClient({
84
+ baseUrl: creds.backendUrl,
85
+ token: creds.token,
86
+ toolExecutor,
87
+ approvalManager: approval,
88
+ });
89
+ }
61
90
 
62
91
  // ── Timeout ──
63
92
  const timeoutMs = timeout * 1000;
@@ -70,10 +99,16 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
70
99
  // ── Execute ──
71
100
  emit({ type: 'start', timestamp: Date.now(), instruction, model: model || 'default', cwd: process.cwd() });
72
101
 
102
+ const projectResources = toolExecutor.getProjectResources();
73
103
  const execContext = {
74
104
  cwd: process.cwd(),
75
105
  freeswim: true,
76
- project_resources: toolExecutor.getProjectResources(),
106
+ project_resources: projectResources,
107
+ work_scope: buildWorkScope({
108
+ instruction,
109
+ cwd: process.cwd(),
110
+ projectResources,
111
+ }),
77
112
  agent_context: toolExecutor.getAgentContext(),
78
113
  };
79
114
  if (model) execContext.model_override = model;
@@ -219,6 +254,47 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
219
254
  content_length: finalContent.length,
220
255
  });
221
256
 
257
+ // PRD-071 §1.5 — cache summary for benchmark harness. Machine-readable,
258
+ // one file per run. Fields match what benchmark/cache-check.sh already
259
+ // computes (input, cache_read, cache_write, rate) so the shell script
260
+ // becomes a thin reader instead of re-doing the arithmetic.
261
+ if (cacheReport && usage) {
262
+ const cacheRead = usage.cache_read || 0;
263
+ const cacheWrite = usage.cache_write || 0;
264
+ const inputT = usage.input_tokens || 0;
265
+ // Two conventions in the wild:
266
+ // OpenAI/DeepSeek: input_tokens INCLUDES cached tokens
267
+ // → hit_rate = cache_read / input_tokens
268
+ // Anthropic: input_tokens EXCLUDES cache reads AND writes
269
+ // → hit_rate = cache_read / (input + cache_read + cache_write)
270
+ // Report both. Also expose `cache_hit_rate_pct` as the "sane" number
271
+ // — auto-detects convention by whether cache_read > input_tokens.
272
+ const rateOpenAI = inputT > 0 ? Math.round((cacheRead / inputT) * 100) : 0;
273
+ const anthropicDenom = inputT + cacheRead + cacheWrite;
274
+ const rateAnthropic = anthropicDenom > 0 ? Math.round((cacheRead / anthropicDenom) * 100) : 0;
275
+ const rateAuto = cacheRead > inputT ? rateAnthropic : rateOpenAI;
276
+ const report = {
277
+ schema: 'kepler.cache-report/1',
278
+ model: model || 'default',
279
+ input_tokens: inputT,
280
+ output_tokens: usage.output_tokens || 0,
281
+ cache_read_tokens: cacheRead,
282
+ cache_write_tokens: cacheWrite,
283
+ cache_hit_rate_pct: rateAuto,
284
+ cache_hit_rate_openai_pct: rateOpenAI,
285
+ cache_hit_rate_anthropic_pct: rateAnthropic,
286
+ duration_s: Math.round(durationS * 10) / 10,
287
+ cost_usd: totalCost,
288
+ };
289
+ try {
290
+ const fs = await import('node:fs');
291
+ fs.writeFileSync(cacheReport, JSON.stringify(report, null, 2) + '\n');
292
+ log(`Cache report written: ${cacheReport}`);
293
+ } catch (err) {
294
+ log(`Cache report write failed: ${err.message}`);
295
+ }
296
+ }
297
+
222
298
  log(`Done: ${toolCount} tools, ${durationS.toFixed(1)}s, $${totalCost.toFixed(3)}`);
223
299
 
224
300
  // Write final content to stderr so it's human-readable (stdout is JSONL)
@@ -317,6 +317,15 @@ export class JsonlWriter {
317
317
  }
318
318
  }
319
319
 
320
+ async flush() {
321
+ await this._flush();
322
+ if (this._flushPromise) await this._flushPromise;
323
+ }
324
+
325
+ get transcriptPath() {
326
+ return this._transcriptPath;
327
+ }
328
+
320
329
  // ── Internal ──
321
330
 
322
331
  _resetTurn() {
@@ -6,6 +6,14 @@
6
6
 
7
7
  import { ContextRetriever } from '../context/retriever.mjs';
8
8
  import { createStagnationTracker, stagnationMessage } from './stagnation.mjs';
9
+ import { PromptCache } from './cache.mjs';
10
+ import {
11
+ ANTHROPIC_BETA_HEADER,
12
+ cacheableSystem,
13
+ cacheableTools,
14
+ withMessageBreakpoint,
15
+ needsExplicitCacheControl,
16
+ } from './cache-control.mjs';
9
17
 
10
18
  const MAX_ITERATIONS = 50;
11
19
 
@@ -209,12 +217,14 @@ export class LocalAgent {
209
217
  this.stagnationDetection = stagnationDetection;
210
218
  this.stagnationThreshold = stagnationThreshold;
211
219
  this._cancelled = false;
220
+ this.promptCache = new PromptCache();
212
221
  }
213
222
 
214
223
  async *execute(instruction, context = {}) {
215
224
  this._cancelled = false;
216
225
  const startTime = Date.now();
217
226
  let toolCount = 0;
227
+ const usageTotals = { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 };
218
228
 
219
229
  yield { type: 'status', data: { message: `Local mode: ${this.model}` } };
220
230
 
@@ -252,7 +262,15 @@ export class LocalAgent {
252
262
  return;
253
263
  }
254
264
 
255
- const { content, stopReason } = response;
265
+ const { content, stopReason, usage } = response;
266
+
267
+ if (usage) {
268
+ this.promptCache.updateStats(usage);
269
+ usageTotals.input_tokens += usage.input_tokens || 0;
270
+ usageTotals.output_tokens += usage.output_tokens || 0;
271
+ usageTotals.cache_read_tokens += usage.cache_read_input_tokens || 0;
272
+ usageTotals.cache_creation_tokens += usage.cache_creation_input_tokens || 0;
273
+ }
256
274
 
257
275
  // Process content blocks
258
276
  let hasToolUse = false;
@@ -303,13 +321,29 @@ export class LocalAgent {
303
321
 
304
322
  if (!hasToolUse || stopReason === 'end_turn') {
305
323
  const duration = (Date.now() - startTime) / 1000;
306
- yield { type: 'complete', data: { summary: 'Done (local)', changes: toolCount, duration_s: duration } };
324
+ yield {
325
+ type: 'complete',
326
+ data: {
327
+ summary: 'Done (local)',
328
+ changes: toolCount,
329
+ duration_s: duration,
330
+ usage: _buildLocalUsageEnvelope(this.model, usageTotals),
331
+ },
332
+ };
307
333
  return;
308
334
  }
309
335
  }
310
336
 
311
337
  yield { type: 'error', data: { message: `Max turns (${this.maxTurns}) reached.` } };
312
- yield { type: 'complete', data: { summary: 'Aborted (max turns)', changes: toolCount, duration_s: (Date.now() - startTime) / 1000 } };
338
+ yield {
339
+ type: 'complete',
340
+ data: {
341
+ summary: 'Aborted (max turns)',
342
+ changes: toolCount,
343
+ duration_s: (Date.now() - startTime) / 1000,
344
+ usage: _buildLocalUsageEnvelope(this.model, usageTotals),
345
+ },
346
+ };
313
347
  }
314
348
 
315
349
  async _callLLM(systemPrompt, messages, tools) {
@@ -333,18 +367,26 @@ export class LocalAgent {
333
367
  }
334
368
 
335
369
  async _callClaude(systemPrompt, messages, tools) {
370
+ // PRD-071 Phase 2 — cache_control breakpoints for Anthropic direct.
371
+ // Extended 1-hour TTL beta on the persistent prefix (system + tools).
372
+ // Message history breakpoint stays at default 5-min TTL.
373
+ const cachedSystem = cacheableSystem(systemPrompt);
374
+ const cachedTools = cacheableTools(tools);
375
+ const cachedMessages = withMessageBreakpoint(messages);
376
+
336
377
  const resp = await fetch('https://api.anthropic.com/v1/messages', {
337
378
  method: 'POST',
338
379
  headers: {
339
380
  'x-api-key': this.apiKey,
340
381
  'anthropic-version': '2023-06-01',
382
+ 'anthropic-beta': ANTHROPIC_BETA_HEADER,
341
383
  'content-type': 'application/json',
342
384
  },
343
385
  body: JSON.stringify({
344
386
  model: this.model,
345
- system: systemPrompt,
346
- messages,
347
- tools: tools.length > 0 ? tools : undefined,
387
+ system: cachedSystem,
388
+ messages: cachedMessages,
389
+ tools: cachedTools.length > 0 ? cachedTools : undefined,
348
390
  max_tokens: 8192,
349
391
  }),
350
392
  });
@@ -353,7 +395,7 @@ export class LocalAgent {
353
395
  throw new Error(`Claude API ${resp.status}: ${text.slice(0, 200)}`);
354
396
  }
355
397
  const data = await resp.json();
356
- return { content: data.content || [], stopReason: data.stop_reason };
398
+ return { content: data.content || [], stopReason: data.stop_reason, usage: data.usage || null };
357
399
  }
358
400
 
359
401
  async _callOpenRouter(systemPrompt, messages, tools) {
@@ -363,17 +405,43 @@ export class LocalAgent {
363
405
  model = `anthropic/${model}`;
364
406
  }
365
407
 
366
- const orMessages = [{ role: 'system', content: systemPrompt }, ...messages];
408
+ // PRD-071 Phase 2 for anthropic/* models we need explicit cache_control
409
+ // breakpoints (OpenRouter relays them through to Anthropic). OpenAI +
410
+ // DeepSeek auto-cache, so the string system prompt path is fine there.
411
+ const isAnthropic = needsExplicitCacheControl(model);
412
+ const systemForOR = isAnthropic
413
+ ? { role: 'system', content: cacheableSystem(systemPrompt) }
414
+ : { role: 'system', content: systemPrompt };
415
+ const messagesForOR = isAnthropic ? withMessageBreakpoint(messages) : messages;
416
+ const orMessages = [systemForOR, ...messagesForOR];
417
+
418
+ // Same tool-schema shape as before, but tag the last tool for Anthropic
419
+ // (OpenRouter passes cache_control on function tools through).
420
+ const orTools = tools.length > 0
421
+ ? tools.map(t => ({
422
+ type: 'function',
423
+ function: { name: t.name, description: t.description, parameters: t.input_schema },
424
+ }))
425
+ : [];
426
+ const finalTools = isAnthropic ? cacheableTools(orTools) : orTools;
427
+
428
+ const headers = {
429
+ 'Authorization': `Bearer ${this.openRouterKey}`,
430
+ 'Content-Type': 'application/json',
431
+ };
432
+ // OpenRouter forwards `anthropic-beta` to Anthropic upstreams.
433
+ if (isAnthropic) headers['anthropic-beta'] = ANTHROPIC_BETA_HEADER;
434
+
367
435
  const resp = await fetch('https://openrouter.ai/api/v1/chat/completions', {
368
436
  method: 'POST',
369
- headers: {
370
- 'Authorization': `Bearer ${this.openRouterKey}`,
371
- 'Content-Type': 'application/json',
372
- },
437
+ headers,
373
438
  body: JSON.stringify({
374
439
  model,
375
440
  messages: orMessages,
376
- tools: tools.length > 0 ? tools.map(t => ({ type: 'function', function: { name: t.name, description: t.description, parameters: t.input_schema } })) : undefined,
441
+ tools: finalTools.length > 0 ? finalTools : undefined,
442
+ // Ask OpenRouter to include upstream cache accounting in the
443
+ // usage payload so PromptCache.updateStats() sees real numbers.
444
+ usage: { include: true },
377
445
  }),
378
446
  });
379
447
  if (!resp.ok) {
@@ -389,7 +457,11 @@ export class LocalAgent {
389
457
  content.push({ type: 'tool_use', id: tc.id, name: tc.function.name, input: JSON.parse(tc.function.arguments || '{}') });
390
458
  }
391
459
  }
392
- return { content, stopReason: choice?.finish_reason === 'stop' ? 'end_turn' : 'tool_use' };
460
+ return {
461
+ content,
462
+ stopReason: choice?.finish_reason === 'stop' ? 'end_turn' : 'tool_use',
463
+ usage: _normalizeOpenRouterUsage(data.usage),
464
+ };
393
465
  }
394
466
 
395
467
  _buildToolDefs() {
@@ -427,3 +499,38 @@ export class LocalAgent {
427
499
 
428
500
  cancel() { this._cancelled = true; }
429
501
  }
502
+
503
+ // Shape the accumulated per-turn totals into the same envelope the remote
504
+ // SSE `complete` event uses, so repl.mjs:837 can consume both modes with the
505
+ // same code path. `models[0].role = 'local'` distinguishes single-agent
506
+ // local mode from remote's Coder/Explorer/Planner breakdown.
507
+ function _buildLocalUsageEnvelope(model, totals) {
508
+ return {
509
+ total_input_tokens: totals.input_tokens,
510
+ total_output_tokens: totals.output_tokens,
511
+ models: [{
512
+ model,
513
+ role: 'local',
514
+ input_tokens: totals.input_tokens,
515
+ output_tokens: totals.output_tokens,
516
+ cache_read_tokens: totals.cache_read_tokens,
517
+ cache_creation_tokens: totals.cache_creation_tokens,
518
+ }],
519
+ };
520
+ }
521
+
522
+ // Normalize OpenRouter usage into Anthropic's field names so downstream
523
+ // consumers (PromptCache, pricing.calculateCost) don't branch on shape.
524
+ // OpenRouter returns OpenAI-style: prompt_tokens, completion_tokens,
525
+ // prompt_tokens_details.cached_tokens. When the underlying model is
526
+ // Anthropic, OpenRouter also relays cache_read_input_tokens verbatim.
527
+ function _normalizeOpenRouterUsage(usage) {
528
+ if (!usage) return null;
529
+ const cachedFromOpenAI = usage.prompt_tokens_details?.cached_tokens || 0;
530
+ return {
531
+ input_tokens: usage.prompt_tokens || 0,
532
+ output_tokens: usage.completion_tokens || 0,
533
+ cache_read_input_tokens: usage.cache_read_input_tokens || cachedFromOpenAI || 0,
534
+ cache_creation_input_tokens: usage.cache_creation_input_tokens || 0,
535
+ };
536
+ }
@@ -531,10 +531,10 @@ export function buildResumeHistory(detail, mode = 'compact') {
531
531
  );
532
532
 
533
533
  // PRD-068 §5.14.4: resume mode picker.
534
- // 'full' — every turn sent verbatim (unchanged)
535
- // 'tail-N' recap block as system prime + last N conversation messages so the
536
- // agent has real recent conversation to reference.
537
- // 'summary' — recap block only. Cheapest continuity, biggest lossiness.
534
+ // 'full' — every turn sent verbatim (unchanged)
535
+ // 'checkpoint-full' latest summary checkpoint + every message after it
536
+ // 'tail-N' — recap block + last N conversation messages
537
+ // 'summary' — recap block only. Cheapest continuity, biggest lossiness.
538
538
  let agentHistory;
539
539
  let sourceMessages = fullAgentHistory;
540
540
  let summaryMessageIndex = -1;
@@ -543,6 +543,22 @@ export function buildResumeHistory(detail, mode = 'compact') {
543
543
  if (mode === 'full') {
544
544
  agentHistory = fullAgentHistory;
545
545
  summaryCoveredMessageCount = fullAgentHistory.length;
546
+ } else if (mode === 'checkpoint-full') {
547
+ sourceMessages = [];
548
+ const tail = fullAgentHistory.slice(summaryCheckpointMessageCount);
549
+ activeSummary = priorSummary
550
+ || summaryForMessages(fullAgentHistory.slice(0, summaryCheckpointMessageCount), {
551
+ fallback: summary,
552
+ label: 'Summary checkpoint',
553
+ });
554
+ summaryCoveredMessageCount = summaryCheckpointMessageCount;
555
+ agentHistory = [
556
+ { role: 'user', content: metadata },
557
+ { role: 'user', content: `Original user request from this resumed session:\n${originalRequest || '(unknown)'}` },
558
+ { role: 'user', content: activeSummary || summary },
559
+ ...tail,
560
+ ];
561
+ summaryMessageIndex = 2;
546
562
  } else if (mode === 'recap+tail' || /^tail-\d+$/.test(String(mode || ''))) {
547
563
  const tailTurns = tailTurnsForMode(mode, detail);
548
564
  const { tail, startIndex } = tailHistorySliceByRecentMessages(fullAgentHistory, tailTurns);
@@ -104,7 +104,7 @@ export function decideResumeMode({
104
104
  * Estimate the context tokens for a candidate mode. Used by the tri-choice
105
105
  * overlay to render "62k / 14k / 2k" per option before the user commits.
106
106
  *
107
- * @param {'full' | 'summary' | 'tail-10' | 'tail-20' | 'recap+tail'} choice
107
+ * @param {'full' | 'checkpoint-full' | 'summary' | 'tail-10' | 'tail-20' | 'recap+tail'} choice
108
108
  * @param {number} fullTokens — projected transcript size in full mode
109
109
  * @param {object} [opts]
110
110
  * @returns {number} projected tokens for the chosen mode
@@ -116,11 +116,21 @@ export function projectedTokensForChoice(choice, fullTokens, opts = {}) {
116
116
  tailBaseTokens = 2000,
117
117
  perTailTurnTokens = 500,
118
118
  summaryBaseTokens = 2000,
119
+ resumeSummary = null,
119
120
  } = opts;
120
121
 
121
122
  switch (choice) {
122
123
  case 'full':
123
124
  return Math.max(0, Number(fullTokens) || 0);
125
+ case 'checkpoint-full': {
126
+ const full = Math.max(0, Number(fullTokens) || 0);
127
+ const fullMessages = Math.max(0, Number(resumeSummary?.fullMessageCount) || 0);
128
+ const covered = Math.max(0, Number(resumeSummary?.sourceMessageCount) || 0);
129
+ if (!fullMessages || covered <= 0) return full;
130
+ const remainingMessages = Math.max(0, fullMessages - covered);
131
+ if (remainingMessages === 0) return summaryBaseTokens;
132
+ return summaryBaseTokens + (perTailTurnTokens * remainingMessages);
133
+ }
124
134
  case 'tail-10':
125
135
  return tailBaseTokens + (10 * perTailTurnTokens);
126
136
  case 'tail-20':