portable-agent-layer 0.99.2 → 0.101.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/README.md CHANGED
@@ -86,6 +86,7 @@ pal cli status # check your setup
86
86
  | `pal cli analyze [--actionable]` | Learning analysis: rating trends, failure patterns, graduation candidates |
87
87
  | `pal cli usage` | Summarize token usage and estimated cost |
88
88
  | `pal cli cost` | What each month was paid against its API-equivalent cost, per agent |
89
+ | `pal cli wrapped [--year <yyyy>]` | A year of your work in numbers, read from the event log |
89
90
  | `pal cli content preserve` | Copy every Claude Code transcript, gzipped, into `memory/content/` before Claude Code deletes it (30 days by default). Runs on its own whenever a session closes; the content store never leaves the machine, so `export` skips it |
90
91
  | `pal cli events discord-nag <sent\|answered>` | For a bot outside PAL: record a nag it sent (`--kind <kind>`), or the answer to one and how long it took (`--after-sec <n>`), in the local event log |
91
92
  | `pal cli infer` | One model call for scripts and bots, on the active agent's route: prompt on stdin, reply on stdout. `--tier small\|medium`, `--system <file>`, `--schema <file>` for a JSON reply checked against it, `--timeout <seconds>`. The call loads no PAL hooks or context; exits 1 with the reason when no route answers |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.99.2",
3
+ "version": "0.101.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",
package/src/cli/cost.ts CHANGED
@@ -1,15 +1,11 @@
1
- import { AGENT_REGISTRY, type AgentName } from "../hooks/lib/agent-registry";
1
+ import { labelOfRuntime } from "../hooks/lib/agent-registry";
2
2
  import { eventsSince } from "../hooks/lib/events";
3
3
  import { leaf } from "../tools/lib/command";
4
4
  import { costLines, costMonths } from "../tools/lib/cost-report";
5
5
 
6
- function labelOf(runtime: string): string {
7
- return AGENT_REGISTRY[runtime as AgentName]?.label ?? runtime;
8
- }
9
-
10
6
  function printCost(): undefined {
11
7
  const months = costMonths(eventsSince("model_call", new Date(0)));
12
- for (const line of costLines(months, labelOf)) console.log(line);
8
+ for (const line of costLines(months, labelOfRuntime)) console.log(line);
13
9
  }
14
10
 
15
11
  export const costCommand = leaf({
package/src/cli/tree.ts CHANGED
@@ -28,6 +28,7 @@ import { serverCommand } from "./server";
28
28
  import { skillCommand } from "./skill";
29
29
  import { subagentCommand } from "./subagent";
30
30
  import { usageCommand } from "./usage";
31
+ import { wrappedCommand } from "./wrapped";
31
32
 
32
33
  type Handler = Leaf["run"];
33
34
  export type DebugState = "on" | "off" | "status";
@@ -155,6 +156,7 @@ export function cliTree(admin: AdminHandlers): Group {
155
156
  ...adminCommands(admin),
156
157
  usage: usageCommand,
157
158
  cost: costCommand,
159
+ wrapped: wrappedCommand,
158
160
  content: contentCommand,
159
161
  events: eventsCommand,
160
162
  infer: inferCommand,
@@ -0,0 +1,50 @@
1
+ import { readSkillIndex } from "../hooks/lib/skill-match";
2
+ import { leaf, UsageError } from "../tools/lib/command";
3
+ import { wrappedAgents } from "../tools/lib/wrapped/agents";
4
+ import { wrappedHabits } from "../tools/lib/wrapped/habits";
5
+ import { wrappedPal } from "../tools/lib/wrapped/pal";
6
+ import { wrappedRecords } from "../tools/lib/wrapped/records";
7
+ import {
8
+ agentsLines,
9
+ habitsLines,
10
+ palLines,
11
+ recordsLines,
12
+ totalsLines,
13
+ } from "../tools/lib/wrapped/render";
14
+ import { wrappedTotals } from "../tools/lib/wrapped/totals";
15
+ import { eventsOfYear } from "../tools/lib/wrapped/year";
16
+
17
+ function yearFrom(value: string | undefined): number {
18
+ if (value === undefined) return new Date().getFullYear();
19
+ if (!/^\d{4}$/.test(value)) throw new UsageError(`--year takes a year, got: ${value}`);
20
+ return Number(value);
21
+ }
22
+
23
+ function printWrapped(year: number): undefined {
24
+ const events = eventsOfYear(year);
25
+ console.log(`\n PAL Wrapped ${year}`);
26
+ if (events.length === 0) {
27
+ console.log(`\n No events logged in ${year}\n`);
28
+ return;
29
+ }
30
+ const lines = [
31
+ ...totalsLines(wrappedTotals(events)),
32
+ ...recordsLines(wrappedRecords(events)),
33
+ ...habitsLines(wrappedHabits(events)),
34
+ ...agentsLines(wrappedAgents(events)),
35
+ ...palLines(wrappedPal(events, readSkillIndex())),
36
+ ];
37
+ for (const line of lines) console.log(line);
38
+ }
39
+
40
+ export const wrappedCommand = leaf({
41
+ summary: "A year of your work in numbers, read from the event log",
42
+ options: {
43
+ year: {
44
+ type: "string",
45
+ value: "<yyyy>",
46
+ description: "Which year (default: this one)",
47
+ },
48
+ },
49
+ run: ({ values }) => printWrapped(yearFrom(values.year)),
50
+ });
@@ -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);
@@ -86,3 +86,7 @@ export function skillsDirOf(agent: AgentName): string {
86
86
  export function isAgentName(value: string): value is AgentName {
87
87
  return Object.hasOwn(AGENT_REGISTRY, value);
88
88
  }
89
+
90
+ export function labelOfRuntime(runtime: string): string {
91
+ return isAgentName(runtime) ? AGENT_REGISTRY[runtime].label : runtime;
92
+ }
@@ -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"],
@@ -322,7 +322,7 @@ export function eventsInMonth(type: EventType, month: Date): LoggedEvent[] {
322
322
  return parseLines(file).filter((e) => e.type === type);
323
323
  }
324
324
 
325
- export function eventsSince(type: EventType, since: Date): LoggedEvent[] {
325
+ export function allEventsSince(since: Date): LoggedEvent[] {
326
326
  const dir = eventsDir();
327
327
  const sinceTs = since.toISOString();
328
328
  const fromMonth = monthOf(sinceTs);
@@ -330,5 +330,9 @@ export function eventsSince(type: EventType, since: Date): LoggedEvent[] {
330
330
  .filter((f) => f.endsWith(".jsonl") && f.slice(0, 7) >= fromMonth)
331
331
  .sort()
332
332
  .flatMap((f) => parseLines(resolve(dir, f)))
333
- .filter((e) => e.type === type && e.ts >= sinceTs);
333
+ .filter((e) => e.ts >= sinceTs);
334
+ }
335
+
336
+ export function eventsSince(type: EventType, since: Date): LoggedEvent[] {
337
+ return allEventsSince(since).filter((e) => e.type === type);
334
338
  }
@@ -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
+ }