@axplusb/kepler 2.0.7 → 2.3.0

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.
Files changed (42) hide show
  1. package/KEPLER-README.md +157 -142
  2. package/README.md +37 -65
  3. package/package.json +2 -2
  4. package/src/config/cli-args.mjs +14 -0
  5. package/src/config/hook-runner.mjs +100 -0
  6. package/src/config/memory-loader.mjs +32 -0
  7. package/src/config/settings-loader.mjs +45 -0
  8. package/src/core/agent-loop.mjs +8 -2
  9. package/src/core/approval-log.mjs +104 -0
  10. package/src/core/approval.mjs +164 -24
  11. package/src/core/cache-control.mjs +92 -0
  12. package/src/core/context-envelope.mjs +54 -0
  13. package/src/core/headless.mjs +99 -10
  14. package/src/core/jsonl-writer.mjs +50 -0
  15. package/src/core/local-agent.mjs +121 -14
  16. package/src/core/local-store.mjs +486 -5
  17. package/src/core/policy-resolver.mjs +156 -0
  18. package/src/core/project-context-loader.mjs +139 -0
  19. package/src/core/rate-limit-display.mjs +97 -0
  20. package/src/core/resume-mode.mjs +154 -0
  21. package/src/core/risk-tier.mjs +88 -2
  22. package/src/core/safety.mjs +3 -0
  23. package/src/core/session-manager.mjs +53 -10
  24. package/src/core/stream-client.mjs +69 -10
  25. package/src/core/system-prompt.mjs +6 -1
  26. package/src/core/tasks.mjs +196 -0
  27. package/src/core/tool-executor.mjs +72 -6
  28. package/src/core/trust.mjs +158 -0
  29. package/src/core/work-scope.mjs +217 -0
  30. package/src/onboarding/preflight.mjs +27 -11
  31. package/src/permissions/command-classifier.mjs +78 -0
  32. package/src/terminal/init.mjs +145 -0
  33. package/src/terminal/main.mjs +9 -0
  34. package/src/terminal/repl.mjs +1822 -140
  35. package/src/tools/bash.mjs +57 -9
  36. package/src/tools/project-overview.mjs +83 -10
  37. package/src/ui/approval.mjs +154 -35
  38. package/src/ui/commands.mjs +5 -4
  39. package/src/ui/formatter.mjs +39 -5
  40. package/src/ui/mission-report.mjs +55 -22
  41. package/src/ui/slash-commands.mjs +57 -5
  42. package/src/ui/tool-card.mjs +51 -1
@@ -0,0 +1,54 @@
1
+ import { contextToPromptBlock } from './project-context-loader.mjs';
2
+
3
+ export function buildContextEnvelope({
4
+ cwd = process.cwd(),
5
+ command = null,
6
+ args = [],
7
+ source = 'repl',
8
+ dryRun = false,
9
+ aliasesResolved = [],
10
+ effectivePolicy,
11
+ projectContext,
12
+ activeHints = [],
13
+ projectResources = [],
14
+ agentContext = {},
15
+ } = {}) {
16
+ const policy = effectivePolicy?.policy || {};
17
+ const commandTimeouts = policy.commands?.timeouts || {};
18
+ const enabled = policy.commands?.enabled || [];
19
+ const activeCommand = command || null;
20
+ const specificTimeout = activeCommand ? commandTimeouts[`${activeCommand}Seconds`] : null;
21
+
22
+ return {
23
+ cwd,
24
+ project_resources: projectResources,
25
+ agent_context: agentContext,
26
+ project_context: {
27
+ loaded_files: projectContext?.loaded || [],
28
+ changed_files: projectContext?.changed?.map(f => ({ label: f.label, path: f.path, hash: f.hash })) || [],
29
+ prompt_block: contextToPromptBlock(projectContext),
30
+ },
31
+ command_context: {
32
+ active_command: activeCommand,
33
+ source,
34
+ args,
35
+ dry_run: dryRun,
36
+ aliases_resolved: aliasesResolved,
37
+ runtime_limits: {
38
+ command_timeout_seconds: specificTimeout || commandTimeouts.defaultSeconds || 300,
39
+ tool_timeout_seconds: 120,
40
+ approval_timeout_seconds: null,
41
+ },
42
+ },
43
+ effective_options: {
44
+ commands_enabled: enabled,
45
+ plan_owner: policy.planning?.owner || 'auto',
46
+ hitl_default_scope: policy.hitl?.defaultScope || 'once',
47
+ reask_after_minutes: policy.hitl?.reaskAfterMinutes ?? 30,
48
+ hook_timeout_seconds: policy.hooks?.timeoutSeconds ?? 5,
49
+ },
50
+ context_hints: activeHints,
51
+ available_skills: projectContext?.available_skills || [],
52
+ task_state: projectContext?.task_state || [],
53
+ };
54
+ }
@@ -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;
@@ -81,6 +116,7 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
81
116
  let toolCount = 0;
82
117
  let finalContent = '';
83
118
  let totalCost = 0;
119
+ let rateLimit = null;
84
120
 
85
121
  // ── Telemetry collectors ──
86
122
  const toolCalls = []; // { tool, duration_ms, success }
@@ -149,7 +185,12 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
149
185
  if (text) finalContent = text;
150
186
  }
151
187
 
188
+ if (type === 'session_info' && data?.rate_limit) {
189
+ rateLimit = data.rate_limit;
190
+ }
191
+
152
192
  if (type === 'complete') {
193
+ if (data?.rate_limit) rateLimit = data.rate_limit;
153
194
  totalCost = data?.cost || data?.total_cost || 0;
154
195
  if (data?.usage?.total_cost) totalCost = data.usage.total_cost;
155
196
  // Capture token usage
@@ -164,7 +205,13 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
164
205
  }
165
206
 
166
207
  if (type === 'error') {
167
- emit({ type: 'error', error: data?.message || 'Unknown error' });
208
+ emit({
209
+ type: 'error',
210
+ error: data?.message || 'Unknown error',
211
+ code: data?.code,
212
+ retry_after: data?.retry_after,
213
+ rate_limit: data?.rate_limit || null,
214
+ });
168
215
  }
169
216
 
170
217
  // ── Cost guard ──
@@ -202,10 +249,52 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
202
249
  usage,
203
250
  duration_s: Math.round(durationS * 10) / 10,
204
251
  cost_usd: totalCost,
252
+ rate_limit: rateLimit,
205
253
  model: model || 'default',
206
254
  content_length: finalContent.length,
207
255
  });
208
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
+
209
298
  log(`Done: ${toolCount} tools, ${durationS.toFixed(1)}s, $${totalCost.toFixed(3)}`);
210
299
 
211
300
  // Write final content to stderr so it's human-readable (stdout is JSONL)
@@ -40,6 +40,23 @@ function normalizeToolResultContent(output) {
40
40
  }
41
41
  }
42
42
 
43
+ function sanitizeEventValue(value, depth = 0) {
44
+ if (depth > 8) return '[truncated-depth]';
45
+ if (typeof value === 'string') {
46
+ return value.length > 10000 ? value.slice(0, 10000) + '\n[...truncated...]' : value;
47
+ }
48
+ if (value == null || typeof value === 'number' || typeof value === 'boolean') return value;
49
+ if (Array.isArray(value)) return value.slice(0, 100).map(item => sanitizeEventValue(item, depth + 1));
50
+ if (typeof value === 'object') {
51
+ const out = {};
52
+ for (const [key, item] of Object.entries(value).slice(0, 100)) {
53
+ out[key] = sanitizeEventValue(item, depth + 1);
54
+ }
55
+ return out;
56
+ }
57
+ return String(value);
58
+ }
59
+
43
60
  export class JsonlWriter {
44
61
  /**
45
62
  * @param {string} cwd — project working directory
@@ -68,6 +85,7 @@ export class JsonlWriter {
68
85
  this._turnToolResults = []; // [{tool_use_id, content, is_error}, ...]
69
86
  this._turnUsage = null;
70
87
  this._turnModel = null;
88
+ this._pendingKeplerEvents = [];
71
89
 
72
90
  // Git branch (captured once at construction)
73
91
  this._gitBranch = this._detectGitBranch();
@@ -83,6 +101,11 @@ export class JsonlWriter {
83
101
  this.sessionId = id;
84
102
  this._transcriptPath = path.join(this.projectDir, `${id}.jsonl`);
85
103
  this._ready = true;
104
+ if (this._pendingKeplerEvents.length > 0) {
105
+ const pending = this._pendingKeplerEvents;
106
+ this._pendingKeplerEvents = [];
107
+ for (const event of pending) this.writeKeplerEvent(event);
108
+ }
86
109
  // Flush any buffered entries now that we have a path
87
110
  if (this._buffer.length > 0) this._flush();
88
111
  }
@@ -119,6 +142,33 @@ export class JsonlWriter {
119
142
  this.lastUuid = uuid;
120
143
  }
121
144
 
145
+ /**
146
+ * Write a Kepler UI/SSE event for exact-ish terminal replay on /resume.
147
+ * This is a Kepler extension; cc-lens readers should ignore unknown types.
148
+ */
149
+ writeKeplerEvent(event) {
150
+ if (!event || !event.type) return;
151
+ const sanitized = sanitizeEventValue({
152
+ type: event.type,
153
+ data: event.data || {},
154
+ });
155
+
156
+ if (!this.sessionId) {
157
+ this._pendingKeplerEvents.push(sanitized);
158
+ return;
159
+ }
160
+
161
+ const entry = {
162
+ type: 'kepler_event',
163
+ timestamp: new Date().toISOString(),
164
+ cwd: this.cwd,
165
+ sessionId: this.sessionId,
166
+ version: this.version,
167
+ event: sanitized,
168
+ };
169
+ this._appendEntry(entry);
170
+ }
171
+
122
172
  /**
123
173
  * Accumulate content during streaming (call on content/content_partial events).
124
174
  */
@@ -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
+ }