portable-agent-layer 0.71.0 → 0.72.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 (45) hide show
  1. package/package.json +1 -1
  2. package/src/cli/migrate.ts +1 -1
  3. package/src/cli/skill.ts +1 -1
  4. package/src/hooks/CompactRecover.ts +28 -86
  5. package/src/hooks/LedgerUnapplied.ts +3 -28
  6. package/src/hooks/LoadContext.ts +33 -60
  7. package/src/hooks/SecurityValidator.ts +16 -109
  8. package/src/hooks/handlers/failure-principle.ts +19 -44
  9. package/src/hooks/handlers/session-intelligence.ts +13 -70
  10. package/src/hooks/lib/capture-store.ts +103 -0
  11. package/src/hooks/lib/compact-recall.ts +89 -0
  12. package/src/hooks/lib/failure-principle.ts +98 -0
  13. package/src/hooks/lib/ledger-hook.ts +35 -0
  14. package/src/hooks/lib/ledger.ts +48 -1
  15. package/src/hooks/lib/security-gate.ts +159 -0
  16. package/src/hooks/lib/session-context.ts +74 -0
  17. package/src/tools/agent/algorithm-reflect.ts +28 -97
  18. package/src/tools/agent/analyze.ts +19 -120
  19. package/src/tools/agent/handoff-note.ts +29 -77
  20. package/src/tools/agent/project.ts +13 -134
  21. package/src/tools/agent/relationship-note.ts +27 -46
  22. package/src/tools/agent/synthesize.ts +1 -1
  23. package/src/tools/agent/thread.ts +43 -123
  24. package/src/tools/control-room/data.ts +2 -2
  25. package/src/tools/control-room/matrix.ts +1 -1
  26. package/src/tools/control-room/ui/ledger.tsx +2 -1
  27. package/src/tools/ledger/view.ts +3 -0
  28. package/src/tools/lib/algorithm-reflect.ts +84 -0
  29. package/src/tools/lib/analyze-report.ts +120 -0
  30. package/src/tools/lib/handoff-note.ts +88 -0
  31. package/src/tools/lib/note-flags.ts +59 -0
  32. package/src/tools/lib/project-isc.ts +151 -0
  33. package/src/tools/lib/relationship-reflect.ts +402 -0
  34. package/src/tools/lib/self-model.ts +499 -0
  35. package/src/tools/lib/session-usage.ts +216 -0
  36. package/src/tools/lib/skill-doctor.ts +457 -0
  37. package/src/tools/lib/thread.ts +119 -0
  38. package/src/tools/lib/token-report.ts +173 -0
  39. package/src/tools/lib/transcript-usage.ts +42 -0
  40. package/src/tools/lib/usage-buckets.ts +329 -0
  41. package/src/tools/relationship-reflect.ts +48 -412
  42. package/src/tools/self-model.ts +76 -558
  43. package/src/tools/session-summary.ts +8 -215
  44. package/src/tools/skill-doctor.ts +9 -444
  45. package/src/tools/token-cost.ts +18 -428
@@ -0,0 +1,499 @@
1
+ /**
2
+ * The deterministic half of self-model synthesis: what is read, how it is
3
+ * summarised, and what the model is asked.
4
+ *
5
+ * The tool around this is only ever spawned, so none of it was reachable from a
6
+ * test — not the rating trend, not the note grammar, not the guard that keeps a
7
+ * failed synthesis from being fed back into the next prompt as if it were a
8
+ * model. Every reader here takes the path to read and the clock to read it
9
+ * against, which is what makes a fixed corpus at a fixed date assertable.
10
+ */
11
+
12
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
13
+ import { resolve } from "node:path";
14
+
15
+ const SELF_MODEL_TTL_MS = 24 * 60 * 60 * 1000;
16
+ const DAY_MS = 24 * 60 * 60 * 1000;
17
+
18
+ const LOW_RATING = 3;
19
+ const HIGH_RATING = 8;
20
+ const OPINION_FLOOR = 0.6;
21
+ const TREND_DELTA = 0.5;
22
+ const TREND_MIN_HALF = 3;
23
+
24
+ const MONTH_DIR = /^\d{4}-\d{2}$/;
25
+ const OPINION_NOTE = /^O\(c=([\d.]+)\):\s*(.+)$/;
26
+ const WISDOM_NOTE = /^W:\s*(.+)$/;
27
+ const SESSION_NOTE = /^Session:\s*(.+)$/;
28
+ const CRYSTAL_TAIL = /\s*\[CRYSTAL:.*$/;
29
+ const LIST_MARKER = /^-\s*/;
30
+ const META_FOOTER = /\n\n\*\d+ ratings[^\n]*\n?$/;
31
+
32
+ /** The marker a fallback carries so the next run never mistakes it for a model. */
33
+ export const FAILED_SYNTHESIS = "Synthesis failed — raw data below";
34
+
35
+ export interface Opinion {
36
+ id: string;
37
+ statement: string;
38
+ confidence: number;
39
+ category: string;
40
+ evidence: { date: string; type: string; source: string }[];
41
+ created: string;
42
+ updated: string;
43
+ }
44
+
45
+ export interface Rating {
46
+ ts: string;
47
+ type: string;
48
+ rating: number;
49
+ context: string;
50
+ source: string;
51
+ }
52
+
53
+ export interface GraduatedPattern {
54
+ pattern: string;
55
+ domain: string;
56
+ confidence: number;
57
+ occurrences: number;
58
+ sources: string[];
59
+ graduatedAt: string;
60
+ }
61
+
62
+ export interface AlgorithmReflection {
63
+ timestamp: string;
64
+ cwd?: string;
65
+ task: string;
66
+ criteria_count: number;
67
+ criteria_passed: number;
68
+ criteria_failed: number;
69
+ sentiment: number;
70
+ q1: string;
71
+ q2: string;
72
+ q3: string;
73
+ }
74
+
75
+ export interface RelationshipNote {
76
+ type: "O" | "W" | "Session";
77
+ content: string;
78
+ confidence?: number;
79
+ date: string;
80
+ }
81
+
82
+ export interface WisdomFrame {
83
+ domain: string;
84
+ principles: string[];
85
+ }
86
+
87
+ export interface RatingSummary {
88
+ count: number;
89
+ avg: number;
90
+ recentAvg: number;
91
+ lowCount: number;
92
+ highCount: number;
93
+ trend: "improving" | "declining" | "stable";
94
+ recentContexts: string[];
95
+ }
96
+
97
+ export interface SelfModelData {
98
+ days: number;
99
+ now: string;
100
+ sessionCount: number;
101
+ opinions: Opinion[];
102
+ ratings: RatingSummary;
103
+ wisdomFrames: WisdomFrame[];
104
+ graduated: GraduatedPattern[];
105
+ reflections: AlgorithmReflection[];
106
+ behaviorNotes: string[];
107
+ wisdomNotes: string[];
108
+ selfObservations: string[];
109
+ algorithmObservations: string[];
110
+ passRate: number;
111
+ avgSentiment: number;
112
+ }
113
+
114
+ /** Where each slice of the corpus lives, so the readers never consult globals. */
115
+ export interface SelfModelSources {
116
+ opinionsFile: string;
117
+ ratingsFile: string;
118
+ wisdomDir: string;
119
+ graduatedFile: string;
120
+ reflectionsFile: string;
121
+ relationshipDir: string;
122
+ sessionDir: string;
123
+ }
124
+
125
+ export function readJsonl<T>(path: string): T[] {
126
+ if (!existsSync(path)) return [];
127
+ try {
128
+ return readFileSync(path, "utf-8")
129
+ .split("\n")
130
+ .filter((line) => line.trim())
131
+ .map((line) => JSON.parse(line) as T);
132
+ } catch {
133
+ return [];
134
+ }
135
+ }
136
+
137
+ function safeReadJson<T>(path: string, fallback: T): T {
138
+ if (!existsSync(path)) return fallback;
139
+ try {
140
+ return JSON.parse(readFileSync(path, "utf-8")) as T;
141
+ } catch {
142
+ return fallback;
143
+ }
144
+ }
145
+
146
+ function safeReaddir(dir: string): string[] {
147
+ try {
148
+ return readdirSync(dir);
149
+ } catch {
150
+ return [];
151
+ }
152
+ }
153
+
154
+ export function daysAgo(days: number, now: Date = new Date()): Date {
155
+ return new Date(now.getTime() - days * DAY_MS);
156
+ }
157
+
158
+ export function round1(n: number): number {
159
+ return Math.round(n * 10) / 10;
160
+ }
161
+
162
+ const isoDay = (date: Date) => date.toISOString().slice(0, 10);
163
+
164
+ /** The guard that stops a synthesis running more than once a day. */
165
+ export function synthesisIsDue(meta: string | null, now: Date): boolean {
166
+ if (meta === null) return true;
167
+ try {
168
+ const { timestamp } = JSON.parse(meta) as { timestamp: string };
169
+ return now.getTime() - new Date(timestamp).getTime() > SELF_MODEL_TTL_MS;
170
+ } catch {
171
+ return true;
172
+ }
173
+ }
174
+
175
+ /** The archive is filed under the model it replaces, not the day it is replaced. */
176
+ export function archiveDateOf(meta: { timestamp?: string }, now: Date): string {
177
+ return meta.timestamp ? meta.timestamp.slice(0, 10) : isoDay(now);
178
+ }
179
+
180
+ export function readOpinions(opinionsFile: string): Opinion[] {
181
+ const data = safeReadJson<{ opinions?: Opinion[] }>(opinionsFile, { opinions: [] });
182
+ return (data.opinions ?? []).sort((a, b) => b.confidence - a.confidence);
183
+ }
184
+
185
+ const emptyRatingSummary = (): RatingSummary => ({
186
+ count: 0,
187
+ avg: 0,
188
+ recentAvg: 0,
189
+ lowCount: 0,
190
+ highCount: 0,
191
+ trend: "stable",
192
+ recentContexts: [],
193
+ });
194
+
195
+ const mean = (ratings: Rating[]) =>
196
+ ratings.reduce((sum, r) => sum + r.rating, 0) / ratings.length;
197
+
198
+ /**
199
+ * Compares the older half of the window against the newer one. Fewer than three
200
+ * ratings a side is noise, so the trend stays flat rather than swinging on one.
201
+ */
202
+ function trendOf(ratings: Rating[]): RatingSummary["trend"] {
203
+ const mid = Math.floor(ratings.length / 2);
204
+ if (mid < TREND_MIN_HALF) return "stable";
205
+ const delta = mean(ratings.slice(mid)) - mean(ratings.slice(0, mid));
206
+ if (delta > TREND_DELTA) return "improving";
207
+ if (delta < -TREND_DELTA) return "declining";
208
+ return "stable";
209
+ }
210
+
211
+ export function summarizeRatings(all: Rating[], since: Date): RatingSummary {
212
+ const ratings = all.filter((r) => new Date(r.ts) >= since);
213
+ if (ratings.length === 0) return emptyRatingSummary();
214
+
215
+ return {
216
+ count: ratings.length,
217
+ avg: round1(mean(ratings)),
218
+ recentAvg: round1(mean(ratings.slice(-10))),
219
+ lowCount: ratings.filter((r) => r.rating <= LOW_RATING).length,
220
+ highCount: ratings.filter((r) => r.rating >= HIGH_RATING).length,
221
+ trend: trendOf(ratings),
222
+ recentContexts: ratings
223
+ .filter((r) => r.rating <= LOW_RATING && r.context)
224
+ .slice(-5)
225
+ .map((r) => r.context),
226
+ };
227
+ }
228
+
229
+ export function readRatings(ratingsFile: string, since: Date): RatingSummary {
230
+ return summarizeRatings(readJsonl<Rating>(ratingsFile), since);
231
+ }
232
+
233
+ export function crystallizedPrinciples(content: string): string[] {
234
+ return content
235
+ .split("\n")
236
+ .filter((line) => line.includes("[CRYSTAL:"))
237
+ .map((line) => line.replace(LIST_MARKER, "").replace(CRYSTAL_TAIL, "").trim());
238
+ }
239
+
240
+ export function readWisdomFrames(wisdomDir: string): WisdomFrame[] {
241
+ const frames: WisdomFrame[] = [];
242
+
243
+ for (const file of safeReaddir(wisdomDir).filter((f) => f.endsWith(".md"))) {
244
+ const principles = crystallizedPrinciples(
245
+ readFileSync(resolve(wisdomDir, file), "utf-8")
246
+ );
247
+ if (principles.length > 0) {
248
+ frames.push({ domain: file.replace(/\.md$/, ""), principles });
249
+ }
250
+ }
251
+
252
+ return frames;
253
+ }
254
+
255
+ export function readGraduatedPatterns(graduatedFile: string): GraduatedPattern[] {
256
+ return (
257
+ safeReadJson<{ graduated?: GraduatedPattern[] }>(graduatedFile, { graduated: [] })
258
+ .graduated ?? []
259
+ );
260
+ }
261
+
262
+ export function readAlgorithmReflections(
263
+ reflectionsFile: string,
264
+ since: Date
265
+ ): AlgorithmReflection[] {
266
+ return readJsonl<AlgorithmReflection>(reflectionsFile).filter(
267
+ (r) => new Date(r.timestamp) >= since
268
+ );
269
+ }
270
+
271
+ export function parseRelationshipNotes(
272
+ content: string,
273
+ date: string
274
+ ): RelationshipNote[] {
275
+ const notes: RelationshipNote[] = [];
276
+
277
+ for (const line of content.split("\n")) {
278
+ const trimmed = line.trim();
279
+ if (!trimmed.startsWith("- ")) continue;
280
+ const body = trimmed.substring(2);
281
+
282
+ const opinion = OPINION_NOTE.exec(body);
283
+ if (opinion) {
284
+ notes.push({
285
+ type: "O",
286
+ confidence: Number.parseFloat(opinion[1]),
287
+ content: opinion[2],
288
+ date,
289
+ });
290
+ continue;
291
+ }
292
+
293
+ const wisdom = WISDOM_NOTE.exec(body);
294
+ if (wisdom) {
295
+ notes.push({ type: "W", content: wisdom[1], date });
296
+ continue;
297
+ }
298
+
299
+ const session = SESSION_NOTE.exec(body);
300
+ if (session) notes.push({ type: "Session", content: session[1], date });
301
+ }
302
+
303
+ return notes;
304
+ }
305
+
306
+ export function readRelationshipNotes(
307
+ relationshipDir: string,
308
+ since: Date
309
+ ): RelationshipNote[] {
310
+ const sinceDay = isoDay(since);
311
+ const notes: RelationshipNote[] = [];
312
+
313
+ for (const monthDir of safeReaddir(relationshipDir).filter((d) => MONTH_DIR.test(d))) {
314
+ const monthPath = resolve(relationshipDir, monthDir);
315
+ for (const file of safeReaddir(monthPath).filter((f) => f.endsWith(".md"))) {
316
+ const date = file.replace(/\.md$/, "");
317
+ if (date < sinceDay) continue;
318
+ notes.push(
319
+ ...parseRelationshipNotes(readFileSync(resolve(monthPath, file), "utf-8"), date)
320
+ );
321
+ }
322
+ }
323
+
324
+ return notes;
325
+ }
326
+
327
+ /** Session transcripts are filed year/month/YYYYMMDD-*.md, so the day is the filename. */
328
+ export function countSessions(sessionDir: string, since: Date): number {
329
+ const sinceDay = isoDay(since);
330
+ let count = 0;
331
+
332
+ for (const year of safeReaddir(sessionDir)) {
333
+ for (const month of safeReaddir(resolve(sessionDir, year))) {
334
+ const days = safeReaddir(resolve(sessionDir, year, month)).filter((f) =>
335
+ f.endsWith(".md")
336
+ );
337
+ for (const file of days) {
338
+ const stamp = file.slice(0, 8);
339
+ const day = `${stamp.slice(0, 4)}-${stamp.slice(4, 6)}-${stamp.slice(6, 8)}`;
340
+ if (day >= sinceDay) count++;
341
+ }
342
+ }
343
+ }
344
+
345
+ return count;
346
+ }
347
+
348
+ export function reflectionStats(reflections: AlgorithmReflection[]): {
349
+ passRate: number;
350
+ avgSentiment: number;
351
+ } {
352
+ if (reflections.length === 0) return { passRate: 0, avgSentiment: 0 };
353
+ const criteria = reflections.reduce((sum, r) => sum + r.criteria_count, 0);
354
+ const passed = reflections.reduce((sum, r) => sum + r.criteria_passed, 0);
355
+ return {
356
+ passRate: criteria > 0 ? Math.round((passed / criteria) * 100) : 0,
357
+ avgSentiment: round1(
358
+ reflections.reduce((sum, r) => sum + r.sentiment, 0) / reflections.length
359
+ ),
360
+ };
361
+ }
362
+
363
+ export function gatherData(
364
+ sources: SelfModelSources,
365
+ days: number,
366
+ now: Date = new Date()
367
+ ): SelfModelData {
368
+ const since = daysAgo(days, now);
369
+ const reflections = readAlgorithmReflections(sources.reflectionsFile, since);
370
+ const notes = readRelationshipNotes(sources.relationshipDir, since);
371
+
372
+ return {
373
+ days,
374
+ now: isoDay(now),
375
+ sessionCount: countSessions(sources.sessionDir, since),
376
+ opinions: readOpinions(sources.opinionsFile),
377
+ ratings: readRatings(sources.ratingsFile, since),
378
+ wisdomFrames: readWisdomFrames(sources.wisdomDir),
379
+ graduated: readGraduatedPatterns(sources.graduatedFile),
380
+ reflections,
381
+ behaviorNotes: notes.filter((n) => n.type === "Session").map((n) => n.content),
382
+ wisdomNotes: notes.filter((n) => n.type === "W").map((n) => n.content),
383
+ selfObservations: reflections.map((r) => r.q1).filter(Boolean),
384
+ algorithmObservations: reflections.map((r) => r.q2).filter(Boolean),
385
+ ...reflectionStats(reflections),
386
+ };
387
+ }
388
+
389
+ function section(heading: string, items: string[]): string[] {
390
+ return items.length > 0 ? [heading, ...items] : [];
391
+ }
392
+
393
+ export function formatDataForInference(
394
+ data: SelfModelData,
395
+ principalName: string
396
+ ): string {
397
+ const confident = data.opinions.filter((o) => o.confidence >= OPINION_FLOOR);
398
+
399
+ return [
400
+ `## Raw Data — ${data.days}-day window, ${data.now}`,
401
+ `Sessions: ${data.sessionCount}`,
402
+ `Ratings: ${data.ratings.count} total, ${data.ratings.avg}/10 avg, recent ${data.ratings.recentAvg}/10, trend ${data.ratings.trend}`,
403
+ `${data.ratings.highCount} high (8+), ${data.ratings.lowCount} low (<=3)`,
404
+ ...(data.opinions.length > 0
405
+ ? [
406
+ `\n### Opinions about ${principalName} (confidence-scored)`,
407
+ ...confident.map(
408
+ (o) => `- [${o.category}] ${o.statement} (${Math.round(o.confidence * 100)}%)`
409
+ ),
410
+ ]
411
+ : []),
412
+ ...section(
413
+ "\n### Crystallized Principles",
414
+ data.wisdomFrames.flatMap((f) => f.principles.map((p) => `- [${f.domain}] ${p}`))
415
+ ),
416
+ ...section(
417
+ "\n### Graduated Failure Patterns",
418
+ data.graduated.map((g) => `- [${g.domain}] ${g.pattern} (${g.occurrences}x)`)
419
+ ),
420
+ ...section(
421
+ "\n### Recent Frustration Signals (rated <=3)",
422
+ data.ratings.recentContexts.map((ctx) => `- "${ctx}"`)
423
+ ),
424
+ ...section(
425
+ "\n### Self-Observations (Q1 from algorithm reflections)",
426
+ data.selfObservations.slice(-8).map((obs) => `- ${obs}`)
427
+ ),
428
+ ...section(
429
+ "\n### Algorithm Observations (Q2 from reflections)",
430
+ data.algorithmObservations.slice(-5).map((obs) => `- ${obs}`)
431
+ ),
432
+ ...section(
433
+ "\n### Behavioral Notes (from relationship tracking)",
434
+ data.behaviorNotes.slice(-8).map((note) => `- ${note}`)
435
+ ),
436
+ ...section(
437
+ "\n### World/Context Notes",
438
+ data.wisdomNotes.slice(-5).map((note) => `- ${note}`)
439
+ ),
440
+ ...(data.reflections.length > 0
441
+ ? [
442
+ `\n### Algorithm Performance: ${data.passRate}% pass rate, ${data.avgSentiment}/10 sentiment, ${data.reflections.length} reflections`,
443
+ ]
444
+ : []),
445
+ ].join("\n");
446
+ }
447
+
448
+ export function buildPrompt(aiName: string, principalName: string): string {
449
+ return `You are writing a self-model for an AI assistant named ${aiName}. You ARE ${aiName}. Write in first person.
450
+
451
+ You will receive structured data about your performance, your user's preferences, and behavioral patterns over a time window.
452
+
453
+ Produce a short, actionable self-model — not a data dump. Every sentence must change behavior, not just describe it.
454
+
455
+ ## Required Sections
456
+
457
+ **# Self-Model — ${aiName}**
458
+ Include synthesis date and window.
459
+
460
+ **## Who ${principalName} Is**
461
+ One paragraph. Synthesize the opinions and behavioral notes into a working portrait — how ${principalName} thinks, communicates, and what frustrates him. Do not list raw opinion statements. Write it as understanding, not inventory.
462
+
463
+ **## My Priority Right Now**
464
+ One sentence. The single most impactful behavioral change to make immediately, derived from the failure patterns and trajectory. Specific and actionable — not "be more careful" but "before generating output that names a command or path, verify it exists."
465
+
466
+ ## Rules
467
+ - First person, present tense
468
+ - No raw numbers anywhere — a footer carries them
469
+ - Under 150 words total
470
+ - Do not add extra sections
471
+ - Do not write a footer or meta line — one is appended automatically after your output`;
472
+ }
473
+
474
+ /**
475
+ * A failed synthesis is a raw data dump, not a model. Feeding one back in bloats
476
+ * the prompt and drives the next run into the same timeout, so it is dropped.
477
+ */
478
+ export function previousModelForPrompt(previous: string): string {
479
+ if (previous.includes(FAILED_SYNTHESIS)) return "";
480
+ return previous.replace(META_FOOTER, "").trimEnd();
481
+ }
482
+
483
+ export function inferenceUserContent(rawData: string, previous: string): string {
484
+ const prior = previousModelForPrompt(previous);
485
+ if (!prior) return rawData;
486
+ return `${rawData}\n\n---\n\n## Previous Self-Model (compare against this — what changed?)\n\n${prior}`;
487
+ }
488
+
489
+ /** The numbers the prompt forbids in the body, appended once underneath it. */
490
+ export function metaFooter(data: SelfModelData, now: Date = new Date()): string {
491
+ return (
492
+ `\n\n*${data.ratings.count} ratings · ${data.sessionCount} sessions · ` +
493
+ `${data.reflections.length} reflections · window: ${isoDay(daysAgo(data.days, now))} → ${data.now}*`
494
+ );
495
+ }
496
+
497
+ export function failedSynthesisModel(aiName: string, rawData: string): string {
498
+ return `# Self-Model — ${aiName}\n*${FAILED_SYNTHESIS}*\n\n${rawData}`;
499
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * What a finished Claude Code session cost, read back off its transcript.
3
+ *
4
+ * The `pal` wrapper spawns the tool around this after the agent exits, so none
5
+ * of it was reachable from a test: not the search for the session's file, not
6
+ * the token arithmetic, not the formatting.
7
+ */
8
+
9
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { resolve } from "node:path";
12
+ import { costOfUsage } from "../../hooks/lib/models";
13
+ import { cacheWritesOf, type TranscriptUsage } from "./transcript-usage";
14
+
15
+ export interface SessionUsage {
16
+ input: number;
17
+ output: number;
18
+ cacheWrite5m: number;
19
+ cacheWrite1h: number;
20
+ cacheRead: number;
21
+ cost: number;
22
+ calls: number;
23
+ models: Set<string>;
24
+ durationMs: number;
25
+ }
26
+
27
+ export interface SessionFile {
28
+ filepath: string;
29
+ project: string;
30
+ }
31
+
32
+ export function claudeProjectsDir(): string {
33
+ return resolve(homedir(), ".claude", "projects");
34
+ }
35
+
36
+ function projectDirsIn(claudeDir: string) {
37
+ return readdirSync(claudeDir, { withFileTypes: true }).filter((entry) =>
38
+ entry.isDirectory()
39
+ );
40
+ }
41
+
42
+ function projectNameOf(dirName: string): string {
43
+ return dirName.split("-").pop() ?? dirName;
44
+ }
45
+
46
+ /**
47
+ * The most recently written transcript, for when no file is named for the
48
+ * session. Claude Code renames a session's file when it is resumed, so the id
49
+ * on the command line does not always still exist as a filename.
50
+ */
51
+ function mostRecentTranscript(claudeDir: string): SessionFile | null {
52
+ let latest: { file: SessionFile; mtime: number } | null = null;
53
+
54
+ for (const dir of projectDirsIn(claudeDir)) {
55
+ const projPath = resolve(claudeDir, dir.name);
56
+ let files: string[];
57
+ try {
58
+ files = readdirSync(projPath).filter((name) => name.endsWith(".jsonl"));
59
+ } catch {
60
+ continue;
61
+ }
62
+
63
+ for (const name of files) {
64
+ const filepath = resolve(projPath, name);
65
+ try {
66
+ const mtime = Bun.file(filepath).lastModified;
67
+ if (!latest || mtime > latest.mtime) {
68
+ latest = { file: { filepath, project: projectNameOf(dir.name) }, mtime };
69
+ }
70
+ } catch {}
71
+ }
72
+ }
73
+
74
+ return latest?.file ?? null;
75
+ }
76
+
77
+ export function findSessionFile(
78
+ sessionId: string,
79
+ claudeDir: string
80
+ ): SessionFile | null {
81
+ if (!existsSync(claudeDir)) return null;
82
+
83
+ for (const dir of projectDirsIn(claudeDir)) {
84
+ const filepath = resolve(claudeDir, dir.name, `${sessionId}.jsonl`);
85
+ if (existsSync(filepath)) {
86
+ return { filepath, project: projectNameOf(dir.name) };
87
+ }
88
+ }
89
+
90
+ return mostRecentTranscript(claudeDir);
91
+ }
92
+
93
+ interface TranscriptLine {
94
+ type?: string;
95
+ timestamp?: string;
96
+ sessionId?: string;
97
+ message?: { model?: string; usage?: TranscriptUsage };
98
+ }
99
+
100
+ function emptyUsage(): SessionUsage {
101
+ return {
102
+ input: 0,
103
+ output: 0,
104
+ cacheWrite5m: 0,
105
+ cacheWrite1h: 0,
106
+ cacheRead: 0,
107
+ cost: 0,
108
+ calls: 0,
109
+ models: new Set(),
110
+ durationMs: 0,
111
+ };
112
+ }
113
+
114
+ export function accumulateUsage(transcript: string, sessionId: string): SessionUsage {
115
+ const total = emptyUsage();
116
+ let firstTs = "";
117
+ let lastTs = "";
118
+
119
+ for (const line of transcript.split("\n")) {
120
+ if (!line) continue;
121
+
122
+ let entry: TranscriptLine;
123
+ try {
124
+ entry = JSON.parse(line) as TranscriptLine;
125
+ } catch {
126
+ continue;
127
+ }
128
+
129
+ if (entry.sessionId !== sessionId) continue;
130
+
131
+ if (entry.timestamp) {
132
+ if (!firstTs) firstTs = entry.timestamp;
133
+ lastTs = entry.timestamp;
134
+ }
135
+
136
+ if (entry.type !== "assistant") continue;
137
+ const usage = entry.message?.usage;
138
+ const model = entry.message?.model;
139
+ if (!usage || !model) continue;
140
+
141
+ const input = usage.input_tokens ?? 0;
142
+ const output = usage.output_tokens ?? 0;
143
+ const cacheRead = usage.cache_read_input_tokens ?? 0;
144
+ const { cacheWrite5m, cacheWrite1h } = cacheWritesOf(usage);
145
+
146
+ total.cost += costOfUsage(model, {
147
+ input,
148
+ output,
149
+ cacheWrite5m,
150
+ cacheWrite1h,
151
+ cacheRead,
152
+ });
153
+ total.input += input;
154
+ total.output += output;
155
+ total.cacheWrite5m += cacheWrite5m;
156
+ total.cacheWrite1h += cacheWrite1h;
157
+ total.cacheRead += cacheRead;
158
+ total.calls++;
159
+ total.models.add(model);
160
+ }
161
+
162
+ if (firstTs && lastTs) {
163
+ total.durationMs = new Date(lastTs).getTime() - new Date(firstTs).getTime();
164
+ }
165
+
166
+ return total;
167
+ }
168
+
169
+ function parseSession(filepath: string, sessionId: string): SessionUsage {
170
+ return accumulateUsage(readFileSync(filepath, "utf-8"), sessionId);
171
+ }
172
+
173
+ export function fmtTokens(n: number): string {
174
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
175
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
176
+ return String(n);
177
+ }
178
+
179
+ /** Two decimals reads as money; a sub-dollar session needs four to say anything. */
180
+ export function fmtCost(n: number): string {
181
+ if (n >= 1) return `$${n.toFixed(2)}`;
182
+ return `$${n.toFixed(4)}`;
183
+ }
184
+
185
+ export function fmtDuration(ms: number): string {
186
+ const mins = Math.floor(ms / 60_000);
187
+ if (mins < 1) return "<1m";
188
+ if (mins < 60) return `${mins}m`;
189
+ const hrs = Math.floor(mins / 60);
190
+ const rem = mins % 60;
191
+ return rem > 0 ? `${hrs}h ${rem}m` : `${hrs}h`;
192
+ }
193
+
194
+ export function totalTokens(usage: SessionUsage): number {
195
+ return (
196
+ usage.input + usage.output + usage.cacheWrite5m + usage.cacheWrite1h + usage.cacheRead
197
+ );
198
+ }
199
+
200
+ const DIM = "\x1b[2m";
201
+ const RESET = "\x1b[0m";
202
+ const CYAN = "\x1b[36m";
203
+
204
+ export function summaryLine(project: string, usage: SessionUsage): string {
205
+ const models = [...usage.models].map((m) => m.replace("claude-", "")).join(", ");
206
+ return `\n${DIM}Session: ${project} · ${models} · ${fmtDuration(usage.durationMs)} · ${fmtTokens(totalTokens(usage))} tokens · ${usage.calls} calls · ${CYAN}${fmtCost(usage.cost)}${RESET}`;
207
+ }
208
+
209
+ /** Nothing to say when the session was not found, or made no model calls at all. */
210
+ export function sessionSummary(sessionId: string, claudeDir: string): string | null {
211
+ if (!sessionId) return null;
212
+ const file = findSessionFile(sessionId, claudeDir);
213
+ if (!file) return null;
214
+ const usage = parseSession(file.filepath, sessionId);
215
+ return usage.calls === 0 ? null : summaryLine(file.project, usage);
216
+ }