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,173 @@
1
+ /**
2
+ * The usage report, as lines rather than as console output.
3
+ *
4
+ * Every judgement about what the report shows — which sections appear at all,
5
+ * how a model is named, what "no data" looks like — was written straight into
6
+ * console.log inside a spawned tool, so none of it could be read back.
7
+ */
8
+
9
+ import {
10
+ type Bucket,
11
+ type ClaudeCodeUsage,
12
+ grandTotal,
13
+ type PalInferenceUsage,
14
+ totalTokens,
15
+ } from "./usage-buckets";
16
+
17
+ export interface RtkSummary {
18
+ total_commands: number;
19
+ total_saved: number;
20
+ avg_savings_pct: number;
21
+ }
22
+
23
+ /**
24
+ * `installed: false` means rtk isn't on PATH; `summary: null` with
25
+ * `installed: true` means rtk is present but has nothing to report — the two
26
+ * cases print differently.
27
+ */
28
+ export interface RtkGain {
29
+ installed: boolean;
30
+ summary: RtkSummary | null;
31
+ }
32
+
33
+ const DEFAULT_LABEL_WIDTH = 14;
34
+
35
+ export function fmt(n: number): string {
36
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
37
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
38
+ return n.toLocaleString("en-US");
39
+ }
40
+
41
+ /** Two decimals reads as money; a sub-dollar total needs four to say anything. */
42
+ export function fmtCost(n: number): string {
43
+ if (n >= 1) return `$${n.toFixed(2)}`;
44
+ return `$${n.toFixed(4)}`;
45
+ }
46
+
47
+ export function rowLine(
48
+ label: string,
49
+ b: Bucket,
50
+ labelWidth = DEFAULT_LABEL_WIDTH
51
+ ): string {
52
+ const tokens = fmt(totalTokens(b)).padStart(8);
53
+ const calls = fmt(b.calls).padStart(5);
54
+ const cost = fmtCost(b.cost).padStart(8);
55
+ return ` ${label.padEnd(labelWidth)} ${tokens} tok ${calls} calls ${cost}`;
56
+ }
57
+
58
+ export function detailedLine(
59
+ label: string,
60
+ b: Bucket,
61
+ labelWidth = DEFAULT_LABEL_WIDTH
62
+ ): string {
63
+ const input = fmt(b.input).padStart(8);
64
+ const output = fmt(b.output).padStart(8);
65
+ const write5m = fmt(b.cacheWrite5m).padStart(7);
66
+ const write1h = fmt(b.cacheWrite1h).padStart(7);
67
+ const read = fmt(b.cacheRead).padStart(8);
68
+ const cost = fmtCost(b.cost).padStart(8);
69
+ return ` ${label.padEnd(labelWidth)} ${input} in ${output} out ${write5m} cw5m ${write1h} cw1h ${read} cr ${cost}`;
70
+ }
71
+
72
+ function windowLines(buckets: {
73
+ today: Bucket;
74
+ week: Bucket;
75
+ month: Bucket;
76
+ total: Bucket;
77
+ }): string[] {
78
+ return [
79
+ rowLine("Today", buckets.today),
80
+ rowLine("7d", buckets.week),
81
+ rowLine("30d", buckets.month),
82
+ rowLine("Total", buckets.total),
83
+ ];
84
+ }
85
+
86
+ /** Costliest first — the point of the section is what to look at. */
87
+ function byCost<T>(entries: [string, T][], costOf: (value: T) => number): [string, T][] {
88
+ return entries.sort((a, b) => costOf(b[1]) - costOf(a[1]));
89
+ }
90
+
91
+ function byModelLines(byModel: ClaudeCodeUsage["byModel"]): string[] {
92
+ const sorted = byCost(Object.entries(byModel), (bucket) => bucket.cost);
93
+ if (sorted.length === 0) return [];
94
+ const names = sorted.map(([model]) => model.replace("claude-", ""));
95
+ const width = Math.max(DEFAULT_LABEL_WIDTH, ...names.map((name) => name.length + 2));
96
+ return [
97
+ "\n By Model (all time)\n",
98
+ ...sorted.map(([, bucket], i) => detailedLine(names[i], bucket, width)),
99
+ ];
100
+ }
101
+
102
+ /** One project is the project you are in; a breakdown of it says nothing new. */
103
+ function byProjectLines(byProject: ClaudeCodeUsage["byProject"]): string[] {
104
+ const entries = Object.entries(byProject);
105
+ if (entries.length <= 1) return [];
106
+ const sorted = byCost(entries, (buckets) => buckets.total.cost);
107
+ return [
108
+ "\n By Project (all time)\n",
109
+ ...sorted.map(([project, buckets]) => rowLine(project, buckets.total)),
110
+ ];
111
+ }
112
+
113
+ function inferenceLabel(model: string): string {
114
+ if (model.includes("haiku")) return "Haiku";
115
+ if (model.includes("sonnet")) return "Sonnet";
116
+ return model.replace("claude-", "");
117
+ }
118
+
119
+ function palInferenceLines(byModel: PalInferenceUsage["byModel"]): string[] {
120
+ const lines: string[] = [];
121
+ for (const [model, buckets] of Object.entries(byModel)) {
122
+ if (buckets.total.calls === 0) continue;
123
+ lines.push(`\n PAL Inference (${inferenceLabel(model)})\n`, ...windowLines(buckets));
124
+ }
125
+ return lines;
126
+ }
127
+
128
+ export function rtkLines(gain: RtkGain): string[] {
129
+ const heading = "\n rtk Compression\n";
130
+ if (!gain.installed) return [heading, " rtk not installed"];
131
+ const summary = gain.summary;
132
+ if (!summary || summary.total_commands === 0) {
133
+ return [heading, " rtk installed — no savings recorded yet"];
134
+ }
135
+ const saved = fmt(summary.total_saved).padStart(8);
136
+ const pct = summary.avg_savings_pct.toFixed(1);
137
+ const commands = fmt(summary.total_commands);
138
+ return [
139
+ heading,
140
+ ` Tokens saved ${saved} tok ${pct}% avg across ${commands} commands`,
141
+ ];
142
+ }
143
+
144
+ /** Null for anything rtk did not answer cleanly — the report says so either way. */
145
+ export function parseRtkSummary(
146
+ status: number | null,
147
+ stdout: string
148
+ ): RtkSummary | null {
149
+ if (status !== 0 || !stdout) return null;
150
+ try {
151
+ const parsed = JSON.parse(stdout) as { summary?: RtkSummary };
152
+ return parsed.summary ?? null;
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+
158
+ export function usageLines(
159
+ claudeCode: ClaudeCodeUsage,
160
+ pal: PalInferenceUsage,
161
+ rtk: RtkGain
162
+ ): string[] {
163
+ const grand = grandTotal([claudeCode.buckets.total, pal.buckets.total]);
164
+ return [
165
+ "\n Claude Code Usage\n",
166
+ ...windowLines(claudeCode.buckets),
167
+ ...byModelLines(claudeCode.byModel),
168
+ ...byProjectLines(claudeCode.byProject),
169
+ ...palInferenceLines(pal.byModel),
170
+ ...rtkLines(rtk),
171
+ `\n Grand Total: ${fmtCost(grand.cost)}\n`,
172
+ ];
173
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The usage record a Claude Code transcript carries, and the one thing that is
3
+ * not obvious about reading it.
4
+ *
5
+ * Shared by the two tools that price a transcript — the per-session summary and
6
+ * the usage report — because reading it differently in the two would mean the
7
+ * same session cost two different amounts depending on which was asked.
8
+ */
9
+
10
+ export interface TranscriptUsage {
11
+ input_tokens?: number;
12
+ output_tokens?: number;
13
+ cache_creation_input_tokens?: number;
14
+ cache_read_input_tokens?: number;
15
+ cache_creation?: {
16
+ ephemeral_5m_input_tokens?: number;
17
+ ephemeral_1h_input_tokens?: number;
18
+ };
19
+ }
20
+
21
+ export interface CacheWrites {
22
+ cacheWrite5m: number;
23
+ cacheWrite1h: number;
24
+ }
25
+
26
+ /**
27
+ * Older transcripts report one cache-write total; newer ones break it into the
28
+ * two TTLs, which are priced differently. A transcript carrying the breakdown
29
+ * still carries the old total, so reading both would count those tokens twice —
30
+ * and the older total is billed as 5m, which is what it was.
31
+ */
32
+ export function cacheWritesOf(usage: TranscriptUsage): CacheWrites {
33
+ const fiveMinute = usage.cache_creation?.ephemeral_5m_input_tokens;
34
+ const oneHour = usage.cache_creation?.ephemeral_1h_input_tokens;
35
+ const hasBreakdown = fiveMinute !== undefined || oneHour !== undefined;
36
+ return {
37
+ cacheWrite5m: hasBreakdown
38
+ ? (fiveMinute ?? 0)
39
+ : (usage.cache_creation_input_tokens ?? 0),
40
+ cacheWrite1h: oneHour ?? 0,
41
+ };
42
+ }
@@ -0,0 +1,329 @@
1
+ /**
2
+ * What has been spent, across the two places PAL can learn it from: Claude Code's
3
+ * own transcripts and PAL's inference log.
4
+ *
5
+ * The tool around this is spawned, so the arithmetic that decides what a month
6
+ * costs was never checked. Both readers take the directory to read rather than
7
+ * finding it themselves, which is the only thing that made them testable.
8
+ */
9
+
10
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
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 Bucket {
16
+ input: number;
17
+ output: number;
18
+ cacheWrite5m: number;
19
+ cacheWrite1h: number;
20
+ cacheRead: number;
21
+ cost: number;
22
+ calls: number;
23
+ }
24
+
25
+ export interface TimeBuckets {
26
+ today: Bucket;
27
+ week: Bucket;
28
+ month: Bucket;
29
+ total: Bucket;
30
+ }
31
+
32
+ /** The tokens of one call, before they are priced. */
33
+ export interface Tokens {
34
+ input: number;
35
+ output: number;
36
+ cacheWrite5m: number;
37
+ cacheWrite1h: number;
38
+ cacheRead: number;
39
+ }
40
+
41
+ export interface Horizons {
42
+ todayPrefix: string;
43
+ weekAgo: string;
44
+ monthAgo: string;
45
+ }
46
+
47
+ const DAY_MS = 24 * 60 * 60 * 1000;
48
+
49
+ export function emptyBucket(): Bucket {
50
+ return {
51
+ input: 0,
52
+ output: 0,
53
+ cacheWrite5m: 0,
54
+ cacheWrite1h: 0,
55
+ cacheRead: 0,
56
+ cost: 0,
57
+ calls: 0,
58
+ };
59
+ }
60
+
61
+ export function emptyTimeBuckets(): TimeBuckets {
62
+ return {
63
+ today: emptyBucket(),
64
+ week: emptyBucket(),
65
+ month: emptyBucket(),
66
+ total: emptyBucket(),
67
+ };
68
+ }
69
+
70
+ /**
71
+ * The cutoffs, as strings, because an ISO timestamp sorts lexically and
72
+ * comparing strings avoids parsing a Date per transcript line.
73
+ */
74
+ export function horizonsFrom(now: Date): Horizons {
75
+ return {
76
+ todayPrefix: now.toISOString().slice(0, 10),
77
+ weekAgo: new Date(now.getTime() - 7 * DAY_MS).toISOString(),
78
+ monthAgo: new Date(now.getTime() - 30 * DAY_MS).toISOString(),
79
+ };
80
+ }
81
+
82
+ export function addToBucket(bucket: Bucket, model: string, tokens: Tokens): void {
83
+ bucket.input += tokens.input;
84
+ bucket.output += tokens.output;
85
+ bucket.cacheWrite5m += tokens.cacheWrite5m;
86
+ bucket.cacheWrite1h += tokens.cacheWrite1h;
87
+ bucket.cacheRead += tokens.cacheRead;
88
+ bucket.cost += costOfUsage(model, tokens);
89
+ bucket.calls++;
90
+ }
91
+
92
+ /**
93
+ * The windows nest: everything in today is also in the week, the month and the
94
+ * total, so a call is added to every window it falls inside rather than to one.
95
+ */
96
+ export function addToTimeBuckets(
97
+ buckets: TimeBuckets,
98
+ ts: string,
99
+ model: string,
100
+ tokens: Tokens,
101
+ horizons: Horizons
102
+ ): void {
103
+ addToBucket(buckets.total, model, tokens);
104
+ if (ts >= horizons.monthAgo) addToBucket(buckets.month, model, tokens);
105
+ if (ts >= horizons.weekAgo) addToBucket(buckets.week, model, tokens);
106
+ if (ts.startsWith(horizons.todayPrefix)) addToBucket(buckets.today, model, tokens);
107
+ }
108
+
109
+ export function totalTokens(bucket: Bucket): number {
110
+ return (
111
+ bucket.input +
112
+ bucket.output +
113
+ bucket.cacheWrite5m +
114
+ bucket.cacheWrite1h +
115
+ bucket.cacheRead
116
+ );
117
+ }
118
+
119
+ /** Claude Code names a project directory after its path; the last segment is the repo. */
120
+ export function projectNameOf(dirName: string): string {
121
+ const segments = dirName.replace(/^-/, "").split("-");
122
+ return segments.length > 1 ? segments.slice(-1)[0] : dirName;
123
+ }
124
+
125
+ export interface ClaudeCodeUsage {
126
+ buckets: TimeBuckets;
127
+ byModel: Record<string, Bucket>;
128
+ byProject: Record<string, TimeBuckets>;
129
+ }
130
+
131
+ interface TranscriptLine {
132
+ type?: string;
133
+ timestamp?: string;
134
+ message?: { model?: string; usage?: TranscriptUsage };
135
+ }
136
+
137
+ /**
138
+ * Every transcript under a project directory, including the ones a subagent
139
+ * wrote — those sit one level down and are billed to the same project, so
140
+ * missing them undercounts every session that spawned one.
141
+ */
142
+ function transcriptsIn(projPath: string): string[] {
143
+ const files: string[] = [];
144
+ for (const entry of readdirSync(projPath, { withFileTypes: true })) {
145
+ if (entry.isFile() && entry.name.endsWith(".jsonl")) {
146
+ files.push(resolve(projPath, entry.name));
147
+ continue;
148
+ }
149
+ if (!entry.isDirectory()) continue;
150
+ const subagentsDir = resolve(projPath, entry.name, "subagents");
151
+ try {
152
+ for (const sub of readdirSync(subagentsDir)) {
153
+ if (sub.endsWith(".jsonl")) files.push(resolve(subagentsDir, sub));
154
+ }
155
+ } catch {
156
+ /* no subagents dir */
157
+ }
158
+ }
159
+ return files;
160
+ }
161
+
162
+ interface PricedCall {
163
+ ts: string;
164
+ model: string;
165
+ tokens: Tokens;
166
+ }
167
+
168
+ /** Null for anything that is not a priced assistant turn. */
169
+ function pricedCallOf(line: string): PricedCall | null {
170
+ let entry: TranscriptLine;
171
+ try {
172
+ entry = JSON.parse(line) as TranscriptLine;
173
+ } catch {
174
+ return null;
175
+ }
176
+ if (entry.type !== "assistant") return null;
177
+
178
+ const usage = entry.message?.usage;
179
+ const model = entry.message?.model;
180
+ const ts = entry.timestamp;
181
+ if (!usage || !model || !ts) return null;
182
+
183
+ return {
184
+ ts,
185
+ model,
186
+ tokens: {
187
+ input: usage.input_tokens ?? 0,
188
+ output: usage.output_tokens ?? 0,
189
+ cacheRead: usage.cache_read_input_tokens ?? 0,
190
+ ...cacheWritesOf(usage),
191
+ },
192
+ };
193
+ }
194
+
195
+ export function readClaudeCode(
196
+ claudeDir: string,
197
+ projectFilter?: string,
198
+ now: Date = new Date()
199
+ ): ClaudeCodeUsage {
200
+ const horizons = horizonsFrom(now);
201
+ const result: ClaudeCodeUsage = {
202
+ buckets: emptyTimeBuckets(),
203
+ byModel: {},
204
+ byProject: {},
205
+ };
206
+ if (!existsSync(claudeDir)) return result;
207
+
208
+ const projectDirs = readdirSync(claudeDir, { withFileTypes: true })
209
+ .filter((entry) => entry.isDirectory())
210
+ .map((entry) => entry.name);
211
+
212
+ for (const dirName of projectDirs) {
213
+ const projName = projectNameOf(dirName);
214
+ if (typeof projectFilter === "string" && !projName.includes(projectFilter)) continue;
215
+
216
+ for (const filepath of transcriptsIn(resolve(claudeDir, dirName))) {
217
+ let content: string;
218
+ try {
219
+ content = readFileSync(filepath, "utf-8");
220
+ } catch {
221
+ continue;
222
+ }
223
+
224
+ for (const line of content.split("\n")) {
225
+ // Cheap reject before the parse: most lines are not model turns.
226
+ if (!line.includes('"usage"')) continue;
227
+ const call = pricedCallOf(line);
228
+ if (!call) continue;
229
+
230
+ addToTimeBuckets(result.buckets, call.ts, call.model, call.tokens, horizons);
231
+
232
+ result.byModel[call.model] ??= emptyBucket();
233
+ addToBucket(result.byModel[call.model], call.model, call.tokens);
234
+
235
+ result.byProject[projName] ??= emptyTimeBuckets();
236
+ addToTimeBuckets(
237
+ result.byProject[projName],
238
+ call.ts,
239
+ call.model,
240
+ call.tokens,
241
+ horizons
242
+ );
243
+ }
244
+ }
245
+ }
246
+
247
+ return result;
248
+ }
249
+
250
+ export interface PalInferenceUsage {
251
+ buckets: TimeBuckets;
252
+ byModel: Record<string, TimeBuckets>;
253
+ byCaller: Record<string, Bucket>;
254
+ }
255
+
256
+ interface InferenceLine {
257
+ ts: string;
258
+ caller: string;
259
+ model: string;
260
+ inputTokens: number;
261
+ outputTokens: number;
262
+ }
263
+
264
+ /** PAL's own calls are plain prompts: no cache to write and none to read. */
265
+ function inferenceTokens(entry: InferenceLine): Tokens {
266
+ return {
267
+ input: entry.inputTokens,
268
+ output: entry.outputTokens,
269
+ cacheWrite5m: 0,
270
+ cacheWrite1h: 0,
271
+ cacheRead: 0,
272
+ };
273
+ }
274
+
275
+ export function readPalInference(
276
+ filepath: string,
277
+ now: Date = new Date()
278
+ ): PalInferenceUsage {
279
+ const horizons = horizonsFrom(now);
280
+ const result: PalInferenceUsage = {
281
+ buckets: emptyTimeBuckets(),
282
+ byModel: {},
283
+ byCaller: {},
284
+ };
285
+ if (!existsSync(filepath)) return result;
286
+
287
+ const content = readFileSync(filepath, "utf-8").trim();
288
+ if (!content) return result;
289
+
290
+ for (const line of content.split("\n")) {
291
+ let entry: InferenceLine;
292
+ try {
293
+ entry = JSON.parse(line) as InferenceLine;
294
+ } catch {
295
+ continue;
296
+ }
297
+ const tokens = inferenceTokens(entry);
298
+
299
+ addToTimeBuckets(result.buckets, entry.ts, entry.model, tokens, horizons);
300
+
301
+ result.byModel[entry.model] ??= emptyTimeBuckets();
302
+ addToTimeBuckets(
303
+ result.byModel[entry.model],
304
+ entry.ts,
305
+ entry.model,
306
+ tokens,
307
+ horizons
308
+ );
309
+
310
+ result.byCaller[entry.caller] ??= emptyBucket();
311
+ addToBucket(result.byCaller[entry.caller], entry.model, tokens);
312
+ }
313
+
314
+ return result;
315
+ }
316
+
317
+ export function grandTotal(buckets: Bucket[]): Bucket {
318
+ const grand = emptyBucket();
319
+ for (const bucket of buckets) {
320
+ grand.input += bucket.input;
321
+ grand.output += bucket.output;
322
+ grand.cacheWrite5m += bucket.cacheWrite5m;
323
+ grand.cacheWrite1h += bucket.cacheWrite1h;
324
+ grand.cacheRead += bucket.cacheRead;
325
+ grand.cost += bucket.cost;
326
+ grand.calls += bucket.calls;
327
+ }
328
+ return grand;
329
+ }