portable-agent-layer 0.103.1 → 0.104.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.
@@ -15,8 +15,7 @@ memory/
15
15
  │ ├── pending-failure.json # Deferred failure capture
16
16
  │ ├── captured-learnings.json # Session-intelligence dedup map
17
17
  │ ├── session-names.json # Generated session titles
18
- │ ├── debug.log[.1-.5] # Rotated hook execution logs
19
- │ └── token-usage.jsonl # Per-call inference token usage
18
+ │ └── debug.log[.1-.5] # Rotated hook execution logs
20
19
  │
21
20
  ├── signals/
22
21
  │ ├── ratings.jsonl # Append-only rating signals (explicit + implicit)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.103.1",
3
+ "version": "0.104.1",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,6 @@ import { logDebug, logError } from "../lib/log";
20
20
  import { readAllProjects } from "../lib/projects";
21
21
  import { isServesKind, SERVES_KINDS, setServes } from "../lib/serves";
22
22
  import { readTelosGoals } from "../lib/telos-goals";
23
- import { logTokenUsage } from "../lib/token-usage";
24
23
 
25
24
  const FRESH_HOURS = 6;
26
25
  const MAX_PROJECTS_PER_GUESS = 40;
@@ -181,7 +180,6 @@ async function guessMissingServes(sessionId?: string): Promise<number> {
181
180
  caller: "agenda-serves",
182
181
  sessionId,
183
182
  });
184
- logTokenUsage("agenda-serves", result);
185
183
  if (!result.success || !result.output) return 0;
186
184
 
187
185
  const parsed = parsePayload<{
@@ -255,7 +253,6 @@ async function refreshGoalLinks(
255
253
  caller: "agenda-goal-links",
256
254
  sessionId,
257
255
  });
258
- logTokenUsage("agenda-goal-links", result);
259
256
  if (!result.success || !result.output) return "failed";
260
257
 
261
258
  const parsed = parsePayload<{ links: { goalId: string; projects: unknown }[] }>(
@@ -309,7 +306,6 @@ async function extractWaitingOn(sessionId?: string): Promise<number> {
309
306
  caller: "agenda-waiting-on",
310
307
  sessionId,
311
308
  });
312
- logTokenUsage("agenda-waiting-on", result);
313
309
  if (!result.success || !result.output) continue;
314
310
 
315
311
  const parsed = parsePayload<{ question: string }>(result.output, "agenda:waiting-on");
@@ -355,7 +351,6 @@ async function writeMoves(sessionId?: string): Promise<boolean> {
355
351
  caller: "agenda-moves",
356
352
  sessionId,
357
353
  });
358
- logTokenUsage("agenda-moves", result);
359
354
  if (!result.success || !result.output) return false;
360
355
 
361
356
  const parsed = parsePayload<{ moves: (AgendaMove & { project?: unknown })[] }>(
@@ -23,7 +23,6 @@ import { newResearchTargets, researchNewEntities } from "../lib/entity-research"
23
23
  import { canInfer, inference } from "../lib/inference";
24
24
  import { logDebug, logError } from "../lib/log";
25
25
  import { identity, isEnabled, isOptedIn } from "../lib/settings";
26
- import { logTokenUsage } from "../lib/token-usage";
27
26
  import { parseMessages } from "../lib/transcript";
28
27
 
29
28
  function sourceId(sessionId: string, count: number): string {
@@ -56,7 +55,6 @@ export async function extractSessionEntities(
56
55
  caller: "entity-extraction",
57
56
  sessionId,
58
57
  });
59
- logTokenUsage("entity-extraction", result);
60
58
  if (!result.success || !result.output) return;
61
59
 
62
60
  const source = sourceId(sessionId, count);
@@ -43,7 +43,6 @@ import {
43
43
  } from "../lib/rule-drafter";
44
44
  import { emitRating } from "../lib/signals";
45
45
  import { now } from "../lib/time";
46
- import { logTokenUsage } from "../lib/token-usage";
47
46
 
48
47
  /** Read cached last assistant response (written by StopOrchestrator), looked up by session */
49
48
  function getLastResponse(sessionId?: string): string {
@@ -224,13 +223,11 @@ function handleImplicitReaction(message: string, sentAt: Date, sessionId?: strin
224
223
 
225
224
  async function labelReaction(reply: string, message: string, sessionId?: string) {
226
225
  const result = await inference(reactionRequest(reply, message, sessionId));
227
- logTokenUsage("rating", result);
228
226
  return result.success ? parseReactionLabel(result.output) : null;
229
227
  }
230
228
 
231
229
  async function checkCorrection(reply: string, message: string, sessionId?: string) {
232
230
  const result = await inference(correctionCheckRequest(reply, message, sessionId));
233
- logTokenUsage("rating", result);
234
231
  return result.success ? parseCorrectionCheck(result.output) : null;
235
232
  }
236
233
 
@@ -249,7 +246,6 @@ async function draftRuleCandidate(sessionId?: string): Promise<void> {
249
246
  const known = knownRules();
250
247
  if (!canRepeat(corrections) && !canWiden(corrections, known)) return;
251
248
  const result = await inference(drafterRequest(corrections, known, sessionId));
252
- logTokenUsage("rule-drafter", result);
253
249
  const candidate = result.success ? parseDraft(result.output, corrections, known) : null;
254
250
  if (candidate) recordCandidate(candidate, turns);
255
251
  else logDebug("rule-drafter", `no candidate: ${result.output ?? result.error ?? ""}`);
@@ -27,7 +27,6 @@ import { logDebug, logError } from "../lib/log";
27
27
  import { ensureDir, paths } from "../lib/paths";
28
28
  import { sessionDir } from "../lib/session-dir";
29
29
  import { fileTimestamp, monthPath } from "../lib/time";
30
- import { logTokenUsage } from "../lib/token-usage";
31
30
  import {
32
31
  extractContent,
33
32
  extractLastAssistant,
@@ -162,8 +161,6 @@ export async function captureSessionIntelligence(
162
161
  sessionId,
163
162
  });
164
163
 
165
- logTokenUsage("session-intelligence", result);
166
-
167
164
  if (result.success && result.output) {
168
165
  output = JSON.parse(result.output) as IntelligenceOutput;
169
166
  }
@@ -18,7 +18,6 @@ import {
18
18
  readSessionNames,
19
19
  writeSessionName,
20
20
  } from "../lib/session-names";
21
- import { logTokenUsage } from "../lib/token-usage";
22
21
 
23
22
  const NAME_PROMPT =
24
23
  "You generate concise 4-word session titles for AI assistant sessions. " +
@@ -76,7 +75,6 @@ async function upgradeWithInference(
76
75
  sessionId,
77
76
  });
78
77
 
79
- logTokenUsage("session-name", result);
80
78
  if (!result.success || !result.output) return;
81
79
 
82
80
  const label = labelFromModel(result.output);
@@ -9,24 +9,11 @@ import { writeFile } from "node:fs/promises";
9
9
  import { resolve } from "node:path";
10
10
  import { assets, ensureDir, paths } from "../lib/paths";
11
11
 
12
- interface TokenSummary {
13
- input: number;
14
- output: number;
15
- }
16
-
17
- interface TokenUsage {
18
- today: TokenSummary;
19
- week: TokenSummary;
20
- month: TokenSummary;
21
- total: TokenSummary;
22
- }
23
-
24
12
  interface Counts {
25
13
  signals: number;
26
14
  telos: number;
27
15
  skills: number;
28
16
  sessions: number;
29
- tokens: TokenUsage;
30
17
  updatedAt: string;
31
18
  }
32
19
 
@@ -49,60 +36,6 @@ function countMdFiles(dir: string): number {
49
36
  }
50
37
  }
51
38
 
52
- function getTokenUsage(): TokenUsage {
53
- const today: TokenSummary = { input: 0, output: 0 };
54
- const week: TokenSummary = { input: 0, output: 0 };
55
- const month: TokenSummary = { input: 0, output: 0 };
56
- const total: TokenSummary = { input: 0, output: 0 };
57
-
58
- const filepath = resolve(paths.signals(), "token-usage.jsonl");
59
- if (!existsSync(filepath)) return { today, week, month, total };
60
-
61
- try {
62
- const content = readFileSync(filepath, "utf-8").trim();
63
- if (!content) return { today, week, month, total };
64
-
65
- const now = new Date();
66
- const todayPrefix = now.toISOString().slice(0, 10);
67
- const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
68
- const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString();
69
-
70
- for (const line of content.split("\n")) {
71
- try {
72
- const entry = JSON.parse(line) as {
73
- ts?: string;
74
- inputTokens?: number;
75
- outputTokens?: number;
76
- };
77
- const input = entry.inputTokens ?? 0;
78
- const output = entry.outputTokens ?? 0;
79
- const ts = entry.ts ?? "";
80
-
81
- total.input += input;
82
- total.output += output;
83
- if (ts >= monthAgo) {
84
- month.input += input;
85
- month.output += output;
86
- }
87
- if (ts >= weekAgo) {
88
- week.input += input;
89
- week.output += output;
90
- }
91
- if (ts.startsWith(todayPrefix)) {
92
- today.input += input;
93
- today.output += output;
94
- }
95
- } catch {
96
- /* skip malformed line */
97
- }
98
- }
99
- } catch {
100
- /* file read error */
101
- }
102
-
103
- return { today, week, month, total };
104
- }
105
-
106
39
  function getCounts(): Counts {
107
40
  const signalsDir = paths.signals();
108
41
  const signals = countJsonlLines(resolve(signalsDir, "ratings.jsonl"));
@@ -133,14 +66,11 @@ function getCounts(): Counts {
133
66
  /* skip */
134
67
  }
135
68
 
136
- const tokens = getTokenUsage();
137
-
138
69
  return {
139
70
  signals,
140
71
  telos,
141
72
  skills,
142
73
  sessions,
143
- tokens,
144
74
  updatedAt: new Date().toISOString(),
145
75
  };
146
76
  }
@@ -14,7 +14,6 @@ import {
14
14
  } from "./entity-extraction";
15
15
  import type { inference } from "./inference";
16
16
  import { logDebug, logError } from "./log";
17
- import { logTokenUsage } from "./token-usage";
18
17
 
19
18
  export interface ResearchTarget {
20
19
  kind: ExtractedEntity["kind"];
@@ -180,7 +179,6 @@ export async function researchEntity(
180
179
  caller: "entity-research",
181
180
  web: true,
182
181
  });
183
- logTokenUsage("entity-research", result);
184
182
  const profile = result.success ? parseProfile(result.output) : null;
185
183
  if (!profile) return { found: false, reason: result.error ?? "no usable reply" };
186
184
  if (profile.match !== "sure")
@@ -193,13 +193,63 @@ export function copilotSpawnUsage(raw: string): InferenceUsage | undefined {
193
193
  );
194
194
  const models = checkpoint ? copilotModelCaches(checkpoint) : [];
195
195
  if (models.length === 0) return undefined;
196
- const prompt = sum(models.map((m) => m.prompt_tokens));
197
196
  const cacheRead = sum(models.map((m) => m.cache_read));
198
197
  const cacheWrite = sum(models.map((m) => m.cache_write));
199
198
  return {
200
- inputTokens:
201
- prompt === undefined ? undefined : prompt - (cacheRead ?? 0) - (cacheWrite ?? 0),
199
+ inputTokens: uncachedInput(
200
+ sum(models.map((m) => m.prompt_tokens)),
201
+ cacheRead,
202
+ cacheWrite
203
+ ),
204
+ cacheWrite5m: cacheWrite,
205
+ cacheRead,
206
+ };
207
+ }
208
+
209
+ function uncachedInput(
210
+ prompt: number | undefined,
211
+ cacheRead: number | undefined,
212
+ cacheWrite: number | undefined
213
+ ): number | undefined {
214
+ return prompt === undefined ? undefined : prompt - (cacheRead ?? 0) - (cacheWrite ?? 0);
215
+ }
216
+
217
+ const COPILOT_SESSION_ID = /^[\w-]+$/;
218
+
219
+ export function copilotSessionId(raw: string): string | undefined {
220
+ const id = jsonLines(raw).findLast((event) => event.type === "result")?.sessionId;
221
+ return typeof id === "string" && COPILOT_SESSION_ID.test(id) ? id : undefined;
222
+ }
223
+
224
+ interface CopilotRunUsage {
225
+ inputTokens?: number;
226
+ outputTokens?: number;
227
+ cacheReadTokens?: number;
228
+ cacheWriteTokens?: number;
229
+ reasoningTokens?: number;
230
+ }
231
+
232
+ /** A run's session.shutdown carries all of its usage, output included; inputTokens folds in cache reads and writes. */
233
+ export function copilotShutdownUsage(eventsJsonl: string): InferenceUsage | undefined {
234
+ const shutdown = jsonLines(eventsJsonl).findLast(
235
+ (event) => event.type === "session.shutdown"
236
+ );
237
+ const data = shutdown?.data as
238
+ | { modelMetrics?: Record<string, { usage?: CopilotRunUsage }> }
239
+ | undefined;
240
+ const runs = Object.values(data?.modelMetrics ?? {}).map((model) => model.usage ?? {});
241
+ if (runs.length === 0) return undefined;
242
+ const cacheRead = sum(runs.map((u) => u.cacheReadTokens));
243
+ const cacheWrite = sum(runs.map((u) => u.cacheWriteTokens));
244
+ return {
245
+ inputTokens: uncachedInput(
246
+ sum(runs.map((u) => u.inputTokens)),
247
+ cacheRead,
248
+ cacheWrite
249
+ ),
250
+ outputTokens: sum(runs.map((u) => u.outputTokens)),
202
251
  cacheWrite5m: cacheWrite,
203
252
  cacheRead,
253
+ thinkingTokens: sum(runs.map((u) => u.reasoningTokens)),
204
254
  };
205
255
  }
@@ -20,6 +20,7 @@
20
20
  * yet wired and currently fall through to the API path.
21
21
  */
22
22
 
23
+ import { readFileSync } from "node:fs";
23
24
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
24
25
  import { tmpdir } from "node:os";
25
26
  import { basename, join } from "node:path";
@@ -48,6 +49,8 @@ import {
48
49
  claudeResult,
49
50
  claudeSpawnUsage,
50
51
  codexSpawnUsage,
52
+ copilotSessionId,
53
+ copilotShutdownUsage,
51
54
  copilotSpawnUsage,
52
55
  type InferenceUsage,
53
56
  jsonLines,
@@ -64,6 +67,7 @@ import {
64
67
  } from "./models";
65
68
  import { opencodeTierModel, writeInstructionFreeConfig } from "./opencode-config";
66
69
  import { withPalEnv } from "./pal-env";
70
+ import { platform } from "./paths";
67
71
  import { buildSpawnGuardEnv, getInferenceDepth, SPAWN_GUARD_ENV } from "./spawn-guard";
68
72
  import { findBinaryOnPath } from "./which";
69
73
 
@@ -703,6 +707,26 @@ const COPILOT_REPLY: CliReply = {
703
707
  usage: copilotSpawnUsage,
704
708
  };
705
709
 
710
+ function copilotSessionUsage(home: string, raw: string): InferenceUsage | undefined {
711
+ const id = copilotSessionId(raw);
712
+ if (!id) return undefined;
713
+ try {
714
+ return copilotShutdownUsage(
715
+ readFileSync(join(home, "session-state", id, "events.jsonl"), "utf-8")
716
+ );
717
+ } catch {
718
+ return undefined;
719
+ }
720
+ }
721
+
722
+ /** stdout reports only the prompt side; the run's own session file in its home has the rest. */
723
+ function copilotReply(home: string): CliReply {
724
+ return {
725
+ ...COPILOT_REPLY,
726
+ usage: (raw) => copilotSessionUsage(home, raw) ?? copilotSpawnUsage(raw),
727
+ };
728
+ }
729
+
706
730
  const COPILOT_TOKEN_ENV = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"];
707
731
 
708
732
  /** A /login account lives in COPILOT_HOME's config.json; only a token variable logs in without it. */
@@ -715,19 +739,21 @@ async function inferenceViaCopilotSpawn(
715
739
  bin: string,
716
740
  opts: InferenceOptions
717
741
  ): Promise<InferenceResult> {
718
- const spawn = (place?: SpawnPlace) =>
742
+ const spawn = (home: string, place?: SpawnPlace) =>
719
743
  inferenceViaCliSpawn(
720
744
  bin,
721
745
  buildCopilotArgs(opts),
722
746
  buildCliPrompt(opts),
723
747
  opts,
724
- COPILOT_REPLY,
748
+ copilotReply(home),
725
749
  place
726
750
  );
727
- if (!copilotLogsInWithoutHome(withPalEnv(process.env))) return spawn();
751
+ const env = withPalEnv(process.env);
752
+ if (!copilotLogsInWithoutHome(env))
753
+ return spawn(env.COPILOT_HOME || platform.copilotDir());
728
754
  const dir = await mkdtemp(join(tmpdir(), "pal-copilot-"));
729
755
  try {
730
- return await spawn({ cwd: dir, env: { COPILOT_HOME: dir } });
756
+ return await spawn(dir, { cwd: dir, env: { COPILOT_HOME: dir } });
731
757
  } finally {
732
758
  await rm(dir, { recursive: true, force: true });
733
759
  }
@@ -122,7 +122,6 @@ const HOOK_MANAGED_FILES = [
122
122
  "last-responses.json",
123
123
  "signal-cache.json",
124
124
  "pending-failure.json",
125
- "token-usage.jsonl",
126
125
  "graduated.json",
127
126
  "update-available.json",
128
127
  "opinions.json",
@@ -34,10 +34,12 @@ interface PreserveLocations {
34
34
 
35
35
  type Manifest = Record<string, string>;
36
36
 
37
+ export const transcriptArchive = () => resolve(paths.content(), "claude");
38
+
37
39
  function defaultLocations(): PreserveLocations {
38
40
  return {
39
41
  source: resolve(platform.claudeDir(), "projects"),
40
- target: resolve(paths.content(), "claude"),
42
+ target: transcriptArchive(),
41
43
  manifest: resolve(paths.state(), "transcript-copies.json"),
42
44
  };
43
45
  }
@@ -218,13 +218,14 @@ interface CopilotEvent {
218
218
  };
219
219
  }
220
220
 
221
- /** Copilot counts cache reads inside inputTokens. */
221
+ /** Copilot counts cache reads and writes inside inputTokens. */
222
222
  function copilotTokens(usage: CopilotModelMetrics["usage"] = {}): Tokens {
223
223
  const cacheRead = usage.cacheReadTokens ?? 0;
224
+ const cacheWrite = usage.cacheWriteTokens ?? 0;
224
225
  return {
225
- input: (usage.inputTokens ?? 0) - cacheRead,
226
+ input: (usage.inputTokens ?? 0) - cacheRead - cacheWrite,
226
227
  output: usage.outputTokens ?? 0,
227
- cacheWrite5m: usage.cacheWriteTokens ?? 0,
228
+ cacheWrite5m: cacheWrite,
228
229
  cacheWrite1h: 0,
229
230
  cacheRead,
230
231
  };
@@ -4,8 +4,9 @@
4
4
  * rather than finding it themselves, which is what makes them testable.
5
5
  */
6
6
 
7
- import { existsSync, readdirSync, readFileSync } from "node:fs";
8
- import { resolve } from "node:path";
7
+ import { type Dirent, existsSync, readdirSync, readFileSync } from "node:fs";
8
+ import { relative, resolve } from "node:path";
9
+ import type { LoggedEvent } from "../../hooks/lib/events";
9
10
  import { costOfUsage } from "../../hooks/lib/models";
10
11
  import { cacheWritesOf, finalPerMessage, type TranscriptUsage } from "./transcript-usage";
11
12
 
@@ -193,10 +194,18 @@ interface TranscriptLine {
193
194
  * wrote — those sit one level down and are billed to the same project, so
194
195
  * missing them undercounts every session that spawned one.
195
196
  */
196
- function transcriptsIn(projPath: string): string[] {
197
+ function entriesOf(dir: string): Dirent[] {
198
+ try {
199
+ return readdirSync(dir, { withFileTypes: true });
200
+ } catch {
201
+ return [];
202
+ }
203
+ }
204
+
205
+ function transcriptsIn(projPath: string, extension = ".jsonl"): string[] {
197
206
  const files: string[] = [];
198
- for (const entry of readdirSync(projPath, { withFileTypes: true })) {
199
- if (entry.isFile() && entry.name.endsWith(".jsonl")) {
207
+ for (const entry of entriesOf(projPath)) {
208
+ if (entry.isFile() && entry.name.endsWith(extension)) {
200
209
  files.push(resolve(projPath, entry.name));
201
210
  continue;
202
211
  }
@@ -204,7 +213,7 @@ function transcriptsIn(projPath: string): string[] {
204
213
  const subagentsDir = resolve(projPath, entry.name, "subagents");
205
214
  try {
206
215
  for (const sub of readdirSync(subagentsDir)) {
207
- if (sub.endsWith(".jsonl")) files.push(resolve(subagentsDir, sub));
216
+ if (sub.endsWith(extension)) files.push(resolve(subagentsDir, sub));
208
217
  }
209
218
  } catch {
210
219
  /* no subagents dir */
@@ -255,30 +264,64 @@ function pricedCallOf(line: string): PricedCall | null {
255
264
  };
256
265
  }
257
266
 
267
+ const projectDirsIn = (dir: string) =>
268
+ entriesOf(dir)
269
+ .filter((entry) => entry.isDirectory())
270
+ .map((entry) => entry.name);
271
+
272
+ const GZIP = ".gz";
273
+
274
+ /** Claude Code deletes a transcript after cleanupPeriodDays; PAL's gzipped copy outlives it. */
275
+ function deletedTranscriptsIn(archivedProject: string, liveProject: string): string[] {
276
+ return transcriptsIn(archivedProject, `.jsonl${GZIP}`).filter((copy) => {
277
+ const original = relative(archivedProject, copy).slice(0, -GZIP.length);
278
+ return !existsSync(resolve(liveProject, original));
279
+ });
280
+ }
281
+
282
+ function transcriptText(filepath: string): string | null {
283
+ try {
284
+ const raw = readFileSync(filepath);
285
+ return filepath.endsWith(GZIP)
286
+ ? new TextDecoder().decode(Bun.gunzipSync(raw))
287
+ : raw.toString("utf-8");
288
+ } catch {
289
+ return null;
290
+ }
291
+ }
292
+
293
+ function projectTranscripts(
294
+ claudeDir: string,
295
+ archiveDir: string | undefined,
296
+ dirName: string
297
+ ) {
298
+ const live = resolve(claudeDir, dirName);
299
+ return [
300
+ ...transcriptsIn(live),
301
+ ...(archiveDir ? deletedTranscriptsIn(resolve(archiveDir, dirName), live) : []),
302
+ ];
303
+ }
304
+
258
305
  export function readClaudeCode(
259
306
  claudeDir: string,
260
307
  projectFilter?: string,
261
- now: Date = new Date()
308
+ now: Date = new Date(),
309
+ archiveDir?: string
262
310
  ): AgentUsage {
263
311
  const tally = usageTally(projectFilter, now);
264
- if (!existsSync(claudeDir)) return tally.usage;
265
312
  const calls: (PricedCall & { project: string })[] = [];
266
-
267
- const projectDirs = readdirSync(claudeDir, { withFileTypes: true })
268
- .filter((entry) => entry.isDirectory())
269
- .map((entry) => entry.name);
313
+ const projectDirs = new Set([
314
+ ...projectDirsIn(claudeDir),
315
+ ...(archiveDir ? projectDirsIn(archiveDir) : []),
316
+ ]);
270
317
 
271
318
  for (const dirName of projectDirs) {
272
319
  const projName = projectNameOf(dirName);
273
320
  if (typeof projectFilter === "string" && !projName.includes(projectFilter)) continue;
274
321
 
275
- for (const filepath of transcriptsIn(resolve(claudeDir, dirName))) {
276
- let content: string;
277
- try {
278
- content = readFileSync(filepath, "utf-8");
279
- } catch {
280
- continue;
281
- }
322
+ for (const filepath of projectTranscripts(claudeDir, archiveDir, dirName)) {
323
+ const content = transcriptText(filepath);
324
+ if (content === null) continue;
282
325
 
283
326
  for (const line of content.split("\n")) {
284
327
  // Cheap reject before the parse: most lines are not model turns.
@@ -307,27 +350,27 @@ export interface PalInferenceUsage {
307
350
  byCaller: Record<string, Bucket>;
308
351
  }
309
352
 
310
- interface InferenceLine {
311
- ts: string;
312
- caller: string;
313
- model: string;
314
- inputTokens: number;
315
- outputTokens: number;
316
- }
353
+ const countOf = (value: unknown) => (typeof value === "number" ? value : null);
317
354
 
318
- /** PAL's own calls are plain prompts: no cache to write and none to read. */
319
- function inferenceTokens(entry: InferenceLine): Tokens {
355
+ /** A call whose input or output went unreported has no tokens to count. */
356
+ function inferenceTokens(call: LoggedEvent): Tokens | null {
357
+ const input = countOf(call.inputTokens);
358
+ const output = countOf(call.outputTokens);
359
+ if (input === null || output === null) return null;
320
360
  return {
321
- input: entry.inputTokens,
322
- output: entry.outputTokens,
323
- cacheWrite5m: 0,
324
- cacheWrite1h: 0,
325
- cacheRead: 0,
361
+ input,
362
+ output,
363
+ cacheWrite5m: countOf(call.cacheWrite5m) ?? 0,
364
+ cacheWrite1h: countOf(call.cacheWrite1h) ?? 0,
365
+ cacheRead: countOf(call.cacheRead) ?? 0,
326
366
  };
327
367
  }
328
368
 
369
+ const textOr = (value: unknown, fallback: string) =>
370
+ typeof value === "string" ? value : fallback;
371
+
329
372
  export function readPalInference(
330
- filepath: string,
373
+ events: LoggedEvent[],
331
374
  now: Date = new Date()
332
375
  ): PalInferenceUsage {
333
376
  const horizons = horizonsFrom(now);
@@ -336,33 +379,20 @@ export function readPalInference(
336
379
  byModel: {},
337
380
  byCaller: {},
338
381
  };
339
- if (!existsSync(filepath)) return result;
340
-
341
- const content = readFileSync(filepath, "utf-8").trim();
342
- if (!content) return result;
343
382
 
344
- for (const line of content.split("\n")) {
345
- let entry: InferenceLine;
346
- try {
347
- entry = JSON.parse(line) as InferenceLine;
348
- } catch {
349
- continue;
350
- }
351
- const tokens = inferenceTokens(entry);
383
+ for (const call of events.filter((e) => e.type === "inference_call")) {
384
+ const tokens = inferenceTokens(call);
385
+ if (tokens === null) continue;
386
+ const model = textOr(call.model, "unknown");
387
+ const caller = textOr(call.caller, "anonymous");
352
388
 
353
- addToTimeBuckets(result.buckets, entry.ts, entry.model, tokens, horizons);
389
+ addToTimeBuckets(result.buckets, call.ts, model, tokens, horizons);
354
390
 
355
- result.byModel[entry.model] ??= emptyTimeBuckets();
356
- addToTimeBuckets(
357
- result.byModel[entry.model],
358
- entry.ts,
359
- entry.model,
360
- tokens,
361
- horizons
362
- );
391
+ result.byModel[model] ??= emptyTimeBuckets();
392
+ addToTimeBuckets(result.byModel[model], call.ts, model, tokens, horizons);
363
393
 
364
- result.byCaller[entry.caller] ??= emptyBucket();
365
- addToBucket(result.byCaller[entry.caller], entry.model, tokens, entry.ts);
394
+ result.byCaller[caller] ??= emptyBucket();
395
+ addToBucket(result.byCaller[caller], model, tokens, call.ts);
366
396
  }
367
397
 
368
398
  return result;
@@ -21,7 +21,6 @@ import { parseArgs } from "node:util";
21
21
  import { inference } from "../hooks/lib/inference";
22
22
  import { ensureDir, paths } from "../hooks/lib/paths";
23
23
  import { identity as loadSettingsIdentity } from "../hooks/lib/settings";
24
- import { logTokenUsage } from "../hooks/lib/token-usage";
25
24
  import {
26
25
  archiveDateOf,
27
26
  buildPrompt,
@@ -89,7 +88,6 @@ async function composeSelfModel(days: number): Promise<string> {
89
88
  caller: "self-model",
90
89
  });
91
90
 
92
- logTokenUsage("self-model", result);
93
91
  if (result.success && result.output) {
94
92
  return result.output.trimEnd() + metaFooter(data);
95
93
  }
@@ -6,7 +6,9 @@
6
6
  import { spawnSync } from "node:child_process";
7
7
  import { resolve } from "node:path";
8
8
  import { AGENT_REGISTRY, type AgentName } from "../hooks/lib/agent-registry";
9
- import { palHome, platform } from "../hooks/lib/paths";
9
+ import { eventsSince } from "../hooks/lib/events";
10
+ import { platform } from "../hooks/lib/paths";
11
+ import { transcriptArchive } from "../hooks/lib/transcript-preserve";
10
12
  import { findBinaryOnPath } from "../hooks/lib/which";
11
13
  import {
12
14
  opencodeDatabase,
@@ -31,7 +33,12 @@ function agentReports(project: string | undefined): AgentReport[] {
31
33
  });
32
34
  return [
33
35
  report("claude", () =>
34
- readClaudeCode(resolve(platform.claudeDir(), "projects"), project)
36
+ readClaudeCode(
37
+ resolve(platform.claudeDir(), "projects"),
38
+ project,
39
+ new Date(),
40
+ transcriptArchive()
41
+ )
35
42
  ),
36
43
  report("codex", () => readCodex(platform.codexDir(), project)),
37
44
  report("opencode", () => readOpencode(opencodeDatabase(), project)),
@@ -60,7 +67,7 @@ function rtkGain(): RtkGain {
60
67
  export function collectUsage(project: string | undefined): UsageData {
61
68
  return {
62
69
  agents: agentReports(project),
63
- pal: readPalInference(resolve(palHome(), "memory", "signals", "token-usage.jsonl")),
70
+ pal: readPalInference(eventsSince("inference_call", new Date(0))),
64
71
  rtk: rtkGain(),
65
72
  untracked: installedUntracked(),
66
73
  };
@@ -1,53 +0,0 @@
1
- /**
2
- * Append-only JSONL logger for inference token usage.
3
- * Writes to memory/signals/token-usage.jsonl
4
- */
5
-
6
- import { appendFileSync } from "node:fs";
7
- import { resolve } from "node:path";
8
- import type { InferenceUsage } from "./inference-usage";
9
- import { ensureDir, paths } from "./paths";
10
-
11
- type TokenCaller =
12
- | "rating"
13
- | "rule-drafter"
14
- | "failure"
15
- | "session-name"
16
- | "session-intelligence"
17
- | "entity-extraction"
18
- | "entity-research"
19
- | "relationship"
20
- | "self-model"
21
- | "agenda-serves"
22
- | "agenda-goal-links"
23
- | "agenda-waiting-on"
24
- | "agenda-moves";
25
-
26
- const UNKNOWN_MODEL = "unknown";
27
-
28
- interface TokenUsageEntry {
29
- ts: string;
30
- caller: TokenCaller;
31
- model: string;
32
- inputTokens: number;
33
- outputTokens: number;
34
- }
35
-
36
- export function logTokenUsage(
37
- caller: TokenCaller,
38
- call: { usage?: InferenceUsage; model?: string }
39
- ): void {
40
- const { inputTokens, outputTokens } = call.usage ?? {};
41
- if (inputTokens === undefined || outputTokens === undefined) return;
42
- const entry: TokenUsageEntry = {
43
- ts: new Date().toISOString(),
44
- caller,
45
- model: call.model ?? UNKNOWN_MODEL,
46
- inputTokens,
47
- outputTokens,
48
- };
49
-
50
- const dir = ensureDir(paths.signals());
51
- const filepath = resolve(dir, "token-usage.jsonl");
52
- appendFileSync(filepath, `${JSON.stringify(entry)}\n`, "utf-8");
53
- }