portable-agent-layer 0.99.2 → 0.100.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.99.2",
3
+ "version": "0.100.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -48,6 +48,7 @@
48
48
  "check-write": "bun .agents/scripts/check-write.ts",
49
49
  "knip": "knip-bun",
50
50
  "klint": "klint && klint --config test",
51
+ "klint:hook": "klint --json --hook && klint --config test --json --hook",
51
52
  "jscpd": "jscpd --noTips",
52
53
  "madge": "madge --circular --extensions ts --ts-config tsconfig.json src",
53
54
  "lf": "bun .agents/scripts/check-lf.ts",
@@ -80,7 +81,7 @@
80
81
  "@commitlint/cli": "21.0.1",
81
82
  "@commitlint/config-conventional": "21.0.1",
82
83
  "@hughescr/stryker-bun-runner": "1.3.8",
83
- "@konvert7/klint": "0.35.1",
84
+ "@konvert7/klint": "0.35.2",
84
85
  "@opencode-ai/plugin": "latest",
85
86
  "@secretlint/secretlint-rule-preset-recommend": "13.0.2",
86
87
  "@semantic-release/changelog": "^6.0.3",
@@ -17,7 +17,6 @@ import { type AgendaMove, readAgenda, writeAgenda } from "../lib/agenda-store";
17
17
  import { linkInputs, readGoalLinks, writeGoalLinks } from "../lib/goal-links";
18
18
  import { canInfer, inference } from "../lib/inference";
19
19
  import { logDebug, logError } from "../lib/log";
20
- import { SONNET_MODEL } from "../lib/models";
21
20
  import { readAllProjects } from "../lib/projects";
22
21
  import { isServesKind, SERVES_KINDS, setServes } from "../lib/serves";
23
22
  import { readTelosGoals } from "../lib/telos-goals";
@@ -182,7 +181,7 @@ async function guessMissingServes(sessionId?: string): Promise<number> {
182
181
  caller: "agenda-serves",
183
182
  sessionId,
184
183
  });
185
- if (result.usage) logTokenUsage("agenda-serves", result.usage, SONNET_MODEL);
184
+ logTokenUsage("agenda-serves", result);
186
185
  if (!result.success || !result.output) return 0;
187
186
 
188
187
  const parsed = parsePayload<{
@@ -256,7 +255,7 @@ async function refreshGoalLinks(
256
255
  caller: "agenda-goal-links",
257
256
  sessionId,
258
257
  });
259
- if (result.usage) logTokenUsage("agenda-goal-links", result.usage, SONNET_MODEL);
258
+ logTokenUsage("agenda-goal-links", result);
260
259
  if (!result.success || !result.output) return "failed";
261
260
 
262
261
  const parsed = parsePayload<{ links: { goalId: string; projects: unknown }[] }>(
@@ -310,7 +309,7 @@ async function extractWaitingOn(sessionId?: string): Promise<number> {
310
309
  caller: "agenda-waiting-on",
311
310
  sessionId,
312
311
  });
313
- if (result.usage) logTokenUsage("agenda-waiting-on", result.usage, SONNET_MODEL);
312
+ logTokenUsage("agenda-waiting-on", result);
314
313
  if (!result.success || !result.output) continue;
315
314
 
316
315
  const parsed = parsePayload<{ question: string }>(result.output, "agenda:waiting-on");
@@ -356,7 +355,7 @@ async function writeMoves(sessionId?: string): Promise<boolean> {
356
355
  caller: "agenda-moves",
357
356
  sessionId,
358
357
  });
359
- if (result.usage) logTokenUsage("agenda-moves", result.usage, SONNET_MODEL);
358
+ logTokenUsage("agenda-moves", result);
360
359
  if (!result.success || !result.output) return false;
361
360
 
362
361
  const parsed = parsePayload<{ moves: (AgendaMove & { project?: unknown })[] }>(
@@ -56,7 +56,7 @@ export async function extractSessionEntities(
56
56
  caller: "entity-extraction",
57
57
  sessionId,
58
58
  });
59
- if (result.usage) logTokenUsage("entity-extraction", result.usage);
59
+ logTokenUsage("entity-extraction", result);
60
60
  if (!result.success || !result.output) return;
61
61
 
62
62
  const source = sourceId(sessionId, count);
@@ -21,7 +21,6 @@ import { spawnDetachedInference } from "../lib/detached-inference";
21
21
  import { canInfer, inference } from "../lib/inference";
22
22
  import { replyEnd } from "../lib/interaction-samples";
23
23
  import { logDebug } from "../lib/log";
24
- import { HAIKU_5_5_MODEL } from "../lib/models";
25
24
  import { paths } from "../lib/paths";
26
25
  import { isSystemText, stripInjectedTags } from "../lib/prompt-text";
27
26
  import {
@@ -225,13 +224,13 @@ function handleImplicitReaction(message: string, sentAt: Date, sessionId?: strin
225
224
 
226
225
  async function labelReaction(reply: string, message: string, sessionId?: string) {
227
226
  const result = await inference(reactionRequest(reply, message, sessionId));
228
- if (result.usage) logTokenUsage("rating", result.usage);
227
+ logTokenUsage("rating", result);
229
228
  return result.success ? parseReactionLabel(result.output) : null;
230
229
  }
231
230
 
232
231
  async function checkCorrection(reply: string, message: string, sessionId?: string) {
233
232
  const result = await inference(correctionCheckRequest(reply, message, sessionId));
234
- if (result.usage) logTokenUsage("rating", result.usage);
233
+ logTokenUsage("rating", result);
235
234
  return result.success ? parseCorrectionCheck(result.output) : null;
236
235
  }
237
236
 
@@ -250,7 +249,7 @@ async function draftRuleCandidate(sessionId?: string): Promise<void> {
250
249
  const known = knownRules();
251
250
  if (!canRepeat(corrections) && !canWiden(corrections, known)) return;
252
251
  const result = await inference(drafterRequest(corrections, known, sessionId));
253
- if (result.usage) logTokenUsage("rule-drafter", result.usage, HAIKU_5_5_MODEL);
252
+ logTokenUsage("rule-drafter", result);
254
253
  const candidate = result.success ? parseDraft(result.output, corrections, known) : null;
255
254
  if (candidate) recordCandidate(candidate, turns);
256
255
  else logDebug("rule-drafter", `no candidate: ${result.output ?? result.error ?? ""}`);
@@ -162,7 +162,7 @@ export async function captureSessionIntelligence(
162
162
  sessionId,
163
163
  });
164
164
 
165
- if (result.usage) logTokenUsage("session-intelligence", result.usage);
165
+ logTokenUsage("session-intelligence", result);
166
166
 
167
167
  if (result.success && result.output) {
168
168
  output = JSON.parse(result.output) as IntelligenceOutput;
@@ -76,7 +76,7 @@ async function upgradeWithInference(
76
76
  sessionId,
77
77
  });
78
78
 
79
- if (result.usage) logTokenUsage("session-name", result.usage);
79
+ logTokenUsage("session-name", result);
80
80
  if (!result.success || !result.output) return;
81
81
 
82
82
  const label = labelFromModel(result.output);
@@ -180,7 +180,7 @@ export async function researchEntity(
180
180
  caller: "entity-research",
181
181
  web: true,
182
182
  });
183
- if (result.usage) logTokenUsage("entity-research", result.usage);
183
+ logTokenUsage("entity-research", result);
184
184
  const profile = result.success ? parseProfile(result.output) : null;
185
185
  if (!profile) return { found: false, reason: result.error ?? "no usable reply" };
186
186
  if (profile.match !== "sure")
@@ -8,6 +8,7 @@ import { appendFileSync, existsSync, readdirSync, readFileSync } from "node:fs";
8
8
  import { resolve } from "node:path";
9
9
  import { currentAttribution, type RecordAttribution } from "./actor";
10
10
  import type { AgentType } from "./agent";
11
+ import { USAGE_FIELDS } from "./inference-usage";
11
12
  import { logError } from "./log";
12
13
  import { ensureDir, paths } from "./paths";
13
14
  import { readAllProjects, resolveProjectFromCwd } from "./projects";
@@ -62,8 +63,7 @@ export const EVENT_FIELDS = {
62
63
  "web",
63
64
  "latencyMs",
64
65
  "failure",
65
- "inputTokens",
66
- "outputTokens",
66
+ ...USAGE_FIELDS,
67
67
  ],
68
68
  pal_update: ["outcome", "from", "to", "durationSec", "skipReason"],
69
69
  commitment: ["iscProject", "isc", "outcome", "surface", "snoozeDays"],
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { appendEvent, logQuietly } from "./events";
7
+ import { type InferenceUsage, USAGE_FIELDS } from "./inference-usage";
7
8
 
8
9
  interface InferenceCall {
9
10
  caller?: string;
@@ -16,7 +17,11 @@ interface InferenceOutcome {
16
17
  success: boolean;
17
18
  output?: string;
18
19
  error?: string;
19
- usage?: { inputTokens: number; outputTokens: number };
20
+ usage?: InferenceUsage;
21
+ }
22
+
23
+ function knownOrNull(usage: InferenceUsage = {}) {
24
+ return Object.fromEntries(USAGE_FIELDS.map((field) => [field, usage[field] ?? null]));
20
25
  }
21
26
 
22
27
  export const SCHEMA_MISMATCH = "the reply does not match the schema";
@@ -47,8 +52,7 @@ export function logInferenceCall(
47
52
  web: Boolean(call.web),
48
53
  latencyMs: Math.round(latencyMs),
49
54
  failure: failureOf(outcome, planned.route),
50
- inputTokens: outcome.usage?.inputTokens ?? null,
51
- outputTokens: outcome.usage?.outputTokens ?? null,
55
+ ...knownOrNull(outcome.usage),
52
56
  },
53
57
  { session: call.sessionId ?? null }
54
58
  )
@@ -0,0 +1,205 @@
1
+ /**
2
+ * What one inference cost, in the token fields model_call uses, read from each
3
+ * route's own usage report. A field the route does not report stays undefined.
4
+ */
5
+
6
+ import { cacheWritesOf, type TranscriptUsage } from "../../tools/lib/transcript-usage";
7
+
8
+ export interface InferenceUsage {
9
+ inputTokens?: number;
10
+ outputTokens?: number;
11
+ cacheWrite5m?: number;
12
+ cacheWrite1h?: number;
13
+ cacheRead?: number;
14
+ thinkingTokens?: number;
15
+ costUsd?: number;
16
+ }
17
+
18
+ export const USAGE_FIELDS = [
19
+ "inputTokens",
20
+ "outputTokens",
21
+ "cacheWrite5m",
22
+ "cacheWrite1h",
23
+ "cacheRead",
24
+ "thinkingTokens",
25
+ "costUsd",
26
+ ] as const satisfies readonly (keyof InferenceUsage)[];
27
+
28
+ export function jsonLines(raw: string): Record<string, unknown>[] {
29
+ return raw.split("\n").flatMap((line) => {
30
+ try {
31
+ const value: unknown = JSON.parse(line);
32
+ return value && typeof value === "object" ? [value as Record<string, unknown>] : [];
33
+ } catch {
34
+ return [];
35
+ }
36
+ });
37
+ }
38
+
39
+ const sum = (values: (number | undefined)[]) =>
40
+ values.some((v) => v !== undefined)
41
+ ? values.reduce<number>((total, v) => total + (v ?? 0), 0)
42
+ : undefined;
43
+
44
+ export function anthropicUsage(usage: TranscriptUsage, costUsd?: number): InferenceUsage {
45
+ return {
46
+ inputTokens: usage.input_tokens,
47
+ outputTokens: usage.output_tokens,
48
+ ...cacheWritesOf(usage),
49
+ cacheRead: usage.cache_read_input_tokens,
50
+ thinkingTokens: usage.output_tokens_details?.thinking_tokens,
51
+ costUsd,
52
+ };
53
+ }
54
+
55
+ export interface OpenAiUsage {
56
+ prompt_tokens?: number;
57
+ completion_tokens?: number;
58
+ prompt_tokens_details?: { cached_tokens?: number };
59
+ completion_tokens_details?: { reasoning_tokens?: number };
60
+ }
61
+
62
+ /** OpenAI counts cached tokens inside prompt_tokens. */
63
+ export function openAiUsage(usage: OpenAiUsage): InferenceUsage {
64
+ const cacheRead = usage.prompt_tokens_details?.cached_tokens;
65
+ return {
66
+ inputTokens:
67
+ usage.prompt_tokens === undefined
68
+ ? undefined
69
+ : usage.prompt_tokens - (cacheRead ?? 0),
70
+ outputTokens: usage.completion_tokens,
71
+ cacheRead,
72
+ thinkingTokens: usage.completion_tokens_details?.reasoning_tokens,
73
+ };
74
+ }
75
+
76
+ export function claudeResult(raw: string): Record<string, unknown> | undefined {
77
+ return jsonLines(raw).find((event) => event.type === "result");
78
+ }
79
+
80
+ export function claudeSpawnUsage(raw: string): InferenceUsage | undefined {
81
+ const result = claudeResult(raw);
82
+ if (!result?.usage) return undefined;
83
+ return anthropicUsage(result.usage as TranscriptUsage, result.total_cost_usd as number);
84
+ }
85
+
86
+ interface CodexUsage {
87
+ input_tokens?: number;
88
+ cached_input_tokens?: number;
89
+ cache_write_input_tokens?: number;
90
+ output_tokens?: number;
91
+ reasoning_output_tokens?: number;
92
+ }
93
+
94
+ /** Codex counts cache reads and writes inside input_tokens. */
95
+ export function codexSpawnUsage(raw: string): InferenceUsage | undefined {
96
+ const turns = jsonLines(raw)
97
+ .filter((event) => event.type === "turn.completed" && event.usage)
98
+ .map((event) => event.usage as CodexUsage);
99
+ if (turns.length === 0) return undefined;
100
+ const cacheRead = sum(turns.map((u) => u.cached_input_tokens));
101
+ const cacheWrite = sum(turns.map((u) => u.cache_write_input_tokens));
102
+ const input = sum(turns.map((u) => u.input_tokens));
103
+ return {
104
+ inputTokens:
105
+ input === undefined ? undefined : input - (cacheRead ?? 0) - (cacheWrite ?? 0),
106
+ outputTokens: sum(turns.map((u) => u.output_tokens)),
107
+ thinkingTokens: sum(turns.map((u) => u.reasoning_output_tokens)),
108
+ cacheWrite5m: cacheWrite,
109
+ cacheRead,
110
+ };
111
+ }
112
+
113
+ interface OpencodeTokens {
114
+ input?: number;
115
+ output?: number;
116
+ reasoning?: number;
117
+ cache?: { read?: number; write?: number };
118
+ }
119
+
120
+ interface OpencodeStep {
121
+ tokens?: OpencodeTokens | null;
122
+ cost?: number | null;
123
+ }
124
+
125
+ function opencodeSteps(raw: string): (OpencodeTokens & { cost?: number })[] {
126
+ return jsonLines(raw)
127
+ .filter((event) => event.type === "step_finish")
128
+ .flatMap((event) => {
129
+ const { tokens, cost } = (event.part ?? {}) as OpencodeStep;
130
+ return tokens ? [{ ...tokens, cost: cost ?? undefined }] : [];
131
+ });
132
+ }
133
+
134
+ /** opencode reports reasoning apart from output; model_call counts it in both. */
135
+ export function opencodeSpawnUsage(raw: string): InferenceUsage | undefined {
136
+ const steps = opencodeSteps(raw);
137
+ if (steps.length === 0) return undefined;
138
+ const output = sum(steps.map((s) => s.output));
139
+ const reasoning = sum(steps.map((s) => s.reasoning));
140
+ return {
141
+ inputTokens: sum(steps.map((s) => s.input)),
142
+ outputTokens: sum([output, reasoning]),
143
+ thinkingTokens: reasoning,
144
+ cacheWrite5m: sum(steps.map((s) => s.cache?.write)),
145
+ cacheRead: sum(steps.map((s) => s.cache?.read)),
146
+ costUsd: sum(steps.map((s) => s.cost)),
147
+ };
148
+ }
149
+
150
+ interface AntigravityUsage {
151
+ input_tokens?: number;
152
+ output_tokens?: number;
153
+ thinking_tokens?: number;
154
+ cache_read_tokens?: number;
155
+ }
156
+
157
+ export function antigravitySpawnUsage(raw: string): InferenceUsage | undefined {
158
+ const result = jsonLines(raw).find((event) => event.event === "result")?.result as
159
+ | { usage?: AntigravityUsage }
160
+ | undefined;
161
+ const usage = result?.usage;
162
+ if (!usage) return undefined;
163
+ return {
164
+ inputTokens: usage.input_tokens,
165
+ outputTokens: usage.output_tokens,
166
+ thinkingTokens: usage.thinking_tokens,
167
+ cacheRead: usage.cache_read_tokens,
168
+ };
169
+ }
170
+
171
+ interface CopilotModelCache {
172
+ prompt_tokens?: number;
173
+ cache_read?: number;
174
+ cache_write?: number;
175
+ }
176
+
177
+ function copilotModelCaches(event: Record<string, unknown>): CopilotModelCache[] {
178
+ const data = event.data as
179
+ | { promptCacheBreakState?: { models?: Record<string, CopilotModelCache> }[] }
180
+ | undefined;
181
+ return (data?.promptCacheBreakState ?? []).flatMap((state) =>
182
+ Object.values(state.models ?? {})
183
+ );
184
+ }
185
+
186
+ /**
187
+ * Copilot's JSON output reports the prompt side of a call only, in its last
188
+ * usage checkpoint, and counts cache reads and writes inside prompt_tokens.
189
+ */
190
+ export function copilotSpawnUsage(raw: string): InferenceUsage | undefined {
191
+ const checkpoint = jsonLines(raw).findLast(
192
+ (event) => event.type === "session.usage_checkpoint"
193
+ );
194
+ const models = checkpoint ? copilotModelCaches(checkpoint) : [];
195
+ if (models.length === 0) return undefined;
196
+ const prompt = sum(models.map((m) => m.prompt_tokens));
197
+ const cacheRead = sum(models.map((m) => m.cache_read));
198
+ const cacheWrite = sum(models.map((m) => m.cache_write));
199
+ return {
200
+ inputTokens:
201
+ prompt === undefined ? undefined : prompt - (cacheRead ?? 0) - (cacheWrite ?? 0),
202
+ cacheWrite5m: cacheWrite,
203
+ cacheRead,
204
+ };
205
+ }
@@ -11,7 +11,7 @@
11
11
  * 4. otherwise → { success: false }
12
12
  *
13
13
  * The claude-spawn path mirrors PAI/TOOLS/Inference.ts in spawn args:
14
- * --print --tools '' --setting-sources '' --output-format text --system-prompt …
14
+ * --print --tools '' --setting-sources '' --output-format json --system-prompt …
15
15
  * These flags prevent the spawned subprocess from loading PAL hooks or making
16
16
  * tool calls — the primary recursion defense. PAL's spawn-guard env sentinel
17
17
  * is the secondary belt-and-suspenders layer (see lib/spawn-guard.ts).
@@ -24,6 +24,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
24
24
  import { tmpdir } from "node:os";
25
25
  import { basename, join } from "node:path";
26
26
  import { z } from "zod";
27
+ import type { TranscriptUsage } from "../../tools/lib/transcript-usage";
27
28
  import {
28
29
  getActiveAgent,
29
30
  isAntigravity,
@@ -41,6 +42,19 @@ import {
41
42
  writeInferenceAgent,
42
43
  } from "./antigravity-inference";
43
44
  import { logInferenceCall, SCHEMA_MISMATCH } from "./inference-events";
45
+ import {
46
+ anthropicUsage,
47
+ antigravitySpawnUsage,
48
+ claudeResult,
49
+ claudeSpawnUsage,
50
+ codexSpawnUsage,
51
+ copilotSpawnUsage,
52
+ type InferenceUsage,
53
+ jsonLines,
54
+ type OpenAiUsage,
55
+ openAiUsage,
56
+ opencodeSpawnUsage,
57
+ } from "./inference-usage";
44
58
  import { logDebug, logError } from "./log";
45
59
  import {
46
60
  type FixedModelRoute,
@@ -175,7 +189,9 @@ interface InferenceResult {
175
189
  output?: string;
176
190
  /** First line of a failed CLI's error output. */
177
191
  error?: string;
178
- usage?: { inputTokens: number; outputTokens: number };
192
+ usage?: InferenceUsage;
193
+ /** The model the route ran on, when PAL picked it. */
194
+ model?: string;
179
195
  }
180
196
 
181
197
  function schemaProblem(
@@ -200,8 +216,9 @@ function plannedRoute(opts: InferenceOptions): { route: string; model: string |
200
216
  export async function inference(opts: InferenceOptions): Promise<InferenceResult> {
201
217
  const started = performance.now();
202
218
  const result = await checkedInference(opts);
203
- logInferenceCall(opts, plannedRoute(opts), result, performance.now() - started);
204
- return result;
219
+ const planned = plannedRoute(opts);
220
+ logInferenceCall(opts, planned, result, performance.now() - started);
221
+ return { ...result, model: planned.model ?? undefined };
205
222
  }
206
223
 
207
224
  async function checkedInference(opts: InferenceOptions): Promise<InferenceResult> {
@@ -253,7 +270,13 @@ async function routeInference(opts: InferenceOptions): Promise<InferenceResult>
253
270
  const bin = getCodexBinary();
254
271
  if (bin) {
255
272
  logDebug("inference", `${tag} route=codex-spawn agent=${agent}`);
256
- return inferenceViaCliSpawn(bin, buildCodexArgs(opts), buildCliPrompt(opts), opts);
273
+ return inferenceViaCliSpawn(
274
+ bin,
275
+ buildCodexArgs(opts),
276
+ buildCliPrompt(opts),
277
+ opts,
278
+ CODEX_REPLY
279
+ );
257
280
  }
258
281
  }
259
282
  if (isCodex() && hasOpenAiKey()) {
@@ -275,7 +298,8 @@ async function routeInference(opts: InferenceOptions): Promise<InferenceResult>
275
298
  bin,
276
299
  buildCopilotArgs(opts),
277
300
  buildCliPrompt(opts),
278
- opts
301
+ opts,
302
+ COPILOT_REPLY
279
303
  );
280
304
  }
281
305
  }
@@ -390,7 +414,7 @@ export function buildClaudeArgs(
390
414
  modelFor("claude-spawn", opts),
391
415
  ...claudeTools(opts),
392
416
  "--output-format",
393
- "text",
417
+ "json",
394
418
  "--setting-sources",
395
419
  "",
396
420
  ];
@@ -398,6 +422,14 @@ export function buildClaudeArgs(
398
422
  return args;
399
423
  }
400
424
 
425
+ const CLAUDE_REPLY: CliReply = {
426
+ text: (raw) => {
427
+ const result = claudeResult(raw);
428
+ return result?.is_error ? "" : String(result?.result ?? "").trim();
429
+ },
430
+ usage: claudeSpawnUsage,
431
+ };
432
+
401
433
  type ClaudeAuthMode = "token" | "native";
402
434
 
403
435
  function claudeAuthMode(): ClaudeAuthMode {
@@ -434,7 +466,7 @@ async function inferenceViaClaudeSpawn(
434
466
  ): Promise<InferenceResult> {
435
467
  const mode = claudeAuthMode();
436
468
  const spawnClaude = (args: string[]) =>
437
- inferenceViaCliSpawn(bin, args, opts.user, opts, undefined, {
469
+ inferenceViaCliSpawn(bin, args, opts.user, opts, CLAUDE_REPLY, {
438
470
  env: credentialOverridesFor(mode),
439
471
  logTag: `auth=${mode}`,
440
472
  });
@@ -461,6 +493,7 @@ async function inferenceViaClaudeSpawn(
461
493
  * --ignore-rules → no execpolicy .rules files load
462
494
  * --sandbox read-only → child cannot execute shell commands even if it tries
463
495
  * --ephemeral → no session persistence; one-shot only
496
+ * --json → NDJSON events: the reply and the turn's token usage
464
497
  *
465
498
  * Codex has no --system-prompt equivalent, so system + user + JSON-schema become
466
499
  * one prompt. That prompt goes in on stdin, not as a positional argument, because
@@ -484,10 +517,26 @@ export function buildCodexArgs(opts: InferenceOptions): string[] {
484
517
  "--sandbox",
485
518
  "read-only",
486
519
  "--ephemeral",
520
+ "--json",
487
521
  ...(opts.web ? ["-c", "web_search=live"] : []),
488
522
  ];
489
523
  }
490
524
 
525
+ interface CodexItem {
526
+ type?: string;
527
+ text?: string;
528
+ }
529
+
530
+ const CODEX_REPLY: CliReply = {
531
+ text: (raw) =>
532
+ jsonLines(raw)
533
+ .filter((event) => event.type === "item.completed")
534
+ .map((event) => event.item as CodexItem)
535
+ .findLast((item) => item?.type === "agent_message")
536
+ ?.text?.trim() ?? "",
537
+ usage: codexSpawnUsage,
538
+ };
539
+
491
540
  /**
492
541
  * Build the argv for `opencode run …` from inference options. Pure.
493
542
  *
@@ -529,7 +578,7 @@ async function inferenceViaOpencodeSpawn(
529
578
  buildOpencodeArgs(opts),
530
579
  buildCliPrompt(opts),
531
580
  opts,
532
- extractOpencodeText,
581
+ { text: extractOpencodeText, usage: opencodeSpawnUsage },
533
582
  { cwd: dir, env: { XDG_CONFIG_HOME: dir, ...OPENCODE_WITHOUT_EXTERNAL_CONTEXT } }
534
583
  );
535
584
  } finally {
@@ -600,7 +649,7 @@ async function inferenceViaAntigravitySpawn(
600
649
  buildAntigravityArgs(opts),
601
650
  streamJsonPrompt(buildCliPrompt(opts)),
602
651
  opts,
603
- extractAntigravityText,
652
+ { text: extractAntigravityText, usage: antigravitySpawnUsage },
604
653
  { cwd: dir, afterRun: removeSpawnedConversation }
605
654
  );
606
655
  } finally {
@@ -622,6 +671,7 @@ async function inferenceViaAntigravitySpawn(
622
671
  * naming an unknown tool hides every tool
623
672
  * --deny-tool=… → backstop: deny rules win over any allow rule
624
673
  * --silent → keeps the tool-filter notices out of stdout
674
+ * --output-format json → JSONL events: the reply and the prompt's token usage
625
675
  * web: only web_fetch, pre-approved for every URL; Copilot has no search tool,
626
676
  * so it fetches search pages itself
627
677
  *
@@ -638,12 +688,27 @@ export function buildCopilotArgs(opts: InferenceOptions): string[] {
638
688
  "--no-auto-update",
639
689
  "--no-color",
640
690
  "--silent",
691
+ "--output-format",
692
+ "json",
641
693
  ...copilotTools(opts),
642
694
  "--deny-tool=shell",
643
695
  "--deny-tool=write",
644
696
  ];
645
697
  }
646
698
 
699
+ interface CopilotMessage {
700
+ content?: string;
701
+ }
702
+
703
+ const COPILOT_REPLY: CliReply = {
704
+ text: (raw) =>
705
+ jsonLines(raw)
706
+ .filter((event) => event.type === "assistant.message")
707
+ .map((event) => (event.data as CopilotMessage | undefined)?.content?.trim())
708
+ .findLast(Boolean) ?? "",
709
+ usage: copilotSpawnUsage,
710
+ };
711
+
647
712
  function copilotTools(opts: InferenceOptions): string[] {
648
713
  return opts.web
649
714
  ? ["--available-tools=web_fetch", "--allow-tool=web_fetch", "--allow-all-urls"]
@@ -831,6 +896,14 @@ async function singleCliAttempt(
831
896
  });
832
897
  }
833
898
 
899
+ /** How to read a CLI's stdout: the reply's text, and what the call cost. */
900
+ interface CliReply {
901
+ text: (rawStdout: string) => string;
902
+ usage: (rawStdout: string) => InferenceUsage | undefined;
903
+ }
904
+
905
+ const PLAIN_REPLY: CliReply = { text: (raw) => raw, usage: () => undefined };
906
+
834
907
  interface SpawnPlace {
835
908
  cwd?: string;
836
909
  env?: Record<string, string | undefined>;
@@ -849,7 +922,7 @@ async function inferenceViaCliSpawn(
849
922
  args: string[],
850
923
  stdinInput: string,
851
924
  opts: InferenceOptions,
852
- extractText?: (rawStdout: string) => string,
925
+ reply: CliReply = PLAIN_REPLY,
853
926
  place: SpawnPlace = {}
854
927
  ): Promise<InferenceResult> {
855
928
  const timeout = opts.timeout ?? 15000;
@@ -887,12 +960,13 @@ async function inferenceViaCliSpawn(
887
960
 
888
961
  place.afterRun?.(attempt.stdout);
889
962
  const elapsedMs = Date.now() - started;
963
+ const usage = reply.usage(attempt.stdout);
890
964
  const finish = (result: InferenceResult): InferenceResult => {
891
965
  logDebug(
892
966
  "inference:spawn",
893
967
  `${tag} done binary=${binaryName} success=${result.success} bytes=${result.output?.length ?? 0} elapsedMs=${elapsedMs}`
894
968
  );
895
- return result;
969
+ return { ...result, usage };
896
970
  };
897
971
 
898
972
  if (attempt.timedOut) {
@@ -908,7 +982,7 @@ async function inferenceViaCliSpawn(
908
982
  }
909
983
  const rawText = attempt.stdout.trim();
910
984
  if (!rawText) return finish({ success: false });
911
- const text = extractText ? extractText(rawText) : rawText;
985
+ const text = reply.text(rawText);
912
986
  if (!text) {
913
987
  // Extraction returned empty — the binary succeeded but our extractor found
914
988
  // no usable text. Log the raw stdout so we can see what was actually emitted.
@@ -973,13 +1047,7 @@ async function inferenceViaApi(opts: InferenceOptions): Promise<InferenceResult>
973
1047
  }
974
1048
 
975
1049
  const data = (await response.json()) as Record<string, unknown>;
976
- const rawUsage = data?.usage as
977
- | { input_tokens?: number; output_tokens?: number }
978
- | undefined;
979
- const usage =
980
- rawUsage?.input_tokens != null && rawUsage?.output_tokens != null
981
- ? { inputTokens: rawUsage.input_tokens, outputTokens: rawUsage.output_tokens }
982
- : undefined;
1050
+ const usage = data?.usage ? anthropicUsage(data.usage as TranscriptUsage) : undefined;
983
1051
 
984
1052
  const content = data?.content as Array<{ text?: string }> | undefined;
985
1053
  const text = content?.[0]?.text?.trim();
@@ -1044,16 +1112,7 @@ async function inferenceViaOpenAiApi(opts: InferenceOptions): Promise<InferenceR
1044
1112
  }
1045
1113
 
1046
1114
  const data = (await response.json()) as Record<string, unknown>;
1047
- const rawUsage = data?.usage as
1048
- | { prompt_tokens?: number; completion_tokens?: number }
1049
- | undefined;
1050
- const usage =
1051
- rawUsage?.prompt_tokens != null && rawUsage?.completion_tokens != null
1052
- ? {
1053
- inputTokens: rawUsage.prompt_tokens,
1054
- outputTokens: rawUsage.completion_tokens,
1055
- }
1056
- : undefined;
1115
+ const usage = data?.usage ? openAiUsage(data.usage as OpenAiUsage) : undefined;
1057
1116
 
1058
1117
  const choices = data?.choices as
1059
1118
  | Array<{ message?: { content?: string } }>
@@ -1,11 +1,11 @@
1
1
  /**
2
- * Append-only JSONL logger for Haiku token usage.
2
+ * Append-only JSONL logger for inference token usage.
3
3
  * Writes to memory/signals/token-usage.jsonl
4
4
  */
5
5
 
6
6
  import { appendFileSync } from "node:fs";
7
7
  import { resolve } from "node:path";
8
- import { HAIKU_MODEL } from "./models";
8
+ import type { InferenceUsage } from "./inference-usage";
9
9
  import { ensureDir, paths } from "./paths";
10
10
 
11
11
  type TokenCaller =
@@ -23,6 +23,8 @@ type TokenCaller =
23
23
  | "agenda-waiting-on"
24
24
  | "agenda-moves";
25
25
 
26
+ const UNKNOWN_MODEL = "unknown";
27
+
26
28
  interface TokenUsageEntry {
27
29
  ts: string;
28
30
  caller: TokenCaller;
@@ -33,15 +35,16 @@ interface TokenUsageEntry {
33
35
 
34
36
  export function logTokenUsage(
35
37
  caller: TokenCaller,
36
- usage: { inputTokens: number; outputTokens: number },
37
- model?: string
38
+ call: { usage?: InferenceUsage; model?: string }
38
39
  ): void {
40
+ const { inputTokens, outputTokens } = call.usage ?? {};
41
+ if (inputTokens === undefined || outputTokens === undefined) return;
39
42
  const entry: TokenUsageEntry = {
40
43
  ts: new Date().toISOString(),
41
44
  caller,
42
- model: model ?? HAIKU_MODEL,
43
- inputTokens: usage.inputTokens,
44
- outputTokens: usage.outputTokens,
45
+ model: call.model ?? UNKNOWN_MODEL,
46
+ inputTokens,
47
+ outputTokens,
45
48
  };
46
49
 
47
50
  const dir = ensureDir(paths.signals());
@@ -19,7 +19,6 @@ import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
19
19
  import { resolve } from "node:path";
20
20
  import { parseArgs } from "node:util";
21
21
  import { inference } from "../hooks/lib/inference";
22
- import { SONNET_MODEL } from "../hooks/lib/models";
23
22
  import { ensureDir, paths } from "../hooks/lib/paths";
24
23
  import { identity as loadSettingsIdentity } from "../hooks/lib/settings";
25
24
  import { logTokenUsage } from "../hooks/lib/token-usage";
@@ -90,7 +89,7 @@ async function composeSelfModel(days: number): Promise<string> {
90
89
  caller: "self-model",
91
90
  });
92
91
 
93
- if (result.usage) logTokenUsage("self-model", result.usage, SONNET_MODEL);
92
+ logTokenUsage("self-model", result);
94
93
  if (result.success && result.output) {
95
94
  return result.output.trimEnd() + metaFooter(data);
96
95
  }