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.
@@ -11,7 +11,7 @@
11
11
  * 4. otherwise → { success: false }
12
12
  *
13
13
  * The claude-spawn path mirrors PAI/TOOLS/Inference.ts in spawn args:
14
- * --print --tools '' --setting-sources '' --output-format text --system-prompt …
14
+ * --print --tools '' --setting-sources '' --output-format json --system-prompt …
15
15
  * These flags prevent the spawned subprocess from loading PAL hooks or making
16
16
  * tool calls — the primary recursion defense. PAL's spawn-guard env sentinel
17
17
  * is the secondary belt-and-suspenders layer (see lib/spawn-guard.ts).
@@ -24,6 +24,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
24
24
  import { tmpdir } from "node:os";
25
25
  import { basename, join } from "node:path";
26
26
  import { z } from "zod";
27
+ import type { TranscriptUsage } from "../../tools/lib/transcript-usage";
27
28
  import {
28
29
  getActiveAgent,
29
30
  isAntigravity,
@@ -41,6 +42,19 @@ import {
41
42
  writeInferenceAgent,
42
43
  } from "./antigravity-inference";
43
44
  import { logInferenceCall, SCHEMA_MISMATCH } from "./inference-events";
45
+ import {
46
+ anthropicUsage,
47
+ antigravitySpawnUsage,
48
+ claudeResult,
49
+ claudeSpawnUsage,
50
+ codexSpawnUsage,
51
+ copilotSpawnUsage,
52
+ type InferenceUsage,
53
+ jsonLines,
54
+ type OpenAiUsage,
55
+ openAiUsage,
56
+ opencodeSpawnUsage,
57
+ } from "./inference-usage";
44
58
  import { logDebug, logError } from "./log";
45
59
  import {
46
60
  type FixedModelRoute,
@@ -175,7 +189,9 @@ interface InferenceResult {
175
189
  output?: string;
176
190
  /** First line of a failed CLI's error output. */
177
191
  error?: string;
178
- usage?: { inputTokens: number; outputTokens: number };
192
+ usage?: InferenceUsage;
193
+ /** The model the route ran on, when PAL picked it. */
194
+ model?: string;
179
195
  }
180
196
 
181
197
  function schemaProblem(
@@ -200,8 +216,9 @@ function plannedRoute(opts: InferenceOptions): { route: string; model: string |
200
216
  export async function inference(opts: InferenceOptions): Promise<InferenceResult> {
201
217
  const started = performance.now();
202
218
  const result = await checkedInference(opts);
203
- logInferenceCall(opts, plannedRoute(opts), result, performance.now() - started);
204
- return result;
219
+ const planned = plannedRoute(opts);
220
+ logInferenceCall(opts, planned, result, performance.now() - started);
221
+ return { ...result, model: planned.model ?? undefined };
205
222
  }
206
223
 
207
224
  async function checkedInference(opts: InferenceOptions): Promise<InferenceResult> {
@@ -253,7 +270,13 @@ async function routeInference(opts: InferenceOptions): Promise<InferenceResult>
253
270
  const bin = getCodexBinary();
254
271
  if (bin) {
255
272
  logDebug("inference", `${tag} route=codex-spawn agent=${agent}`);
256
- return inferenceViaCliSpawn(bin, buildCodexArgs(opts), buildCliPrompt(opts), opts);
273
+ return inferenceViaCliSpawn(
274
+ bin,
275
+ buildCodexArgs(opts),
276
+ buildCliPrompt(opts),
277
+ opts,
278
+ CODEX_REPLY
279
+ );
257
280
  }
258
281
  }
259
282
  if (isCodex() && hasOpenAiKey()) {
@@ -275,7 +298,8 @@ async function routeInference(opts: InferenceOptions): Promise<InferenceResult>
275
298
  bin,
276
299
  buildCopilotArgs(opts),
277
300
  buildCliPrompt(opts),
278
- opts
301
+ opts,
302
+ COPILOT_REPLY
279
303
  );
280
304
  }
281
305
  }
@@ -390,7 +414,7 @@ export function buildClaudeArgs(
390
414
  modelFor("claude-spawn", opts),
391
415
  ...claudeTools(opts),
392
416
  "--output-format",
393
- "text",
417
+ "json",
394
418
  "--setting-sources",
395
419
  "",
396
420
  ];
@@ -398,6 +422,14 @@ export function buildClaudeArgs(
398
422
  return args;
399
423
  }
400
424
 
425
+ const CLAUDE_REPLY: CliReply = {
426
+ text: (raw) => {
427
+ const result = claudeResult(raw);
428
+ return result?.is_error ? "" : String(result?.result ?? "").trim();
429
+ },
430
+ usage: claudeSpawnUsage,
431
+ };
432
+
401
433
  type ClaudeAuthMode = "token" | "native";
402
434
 
403
435
  function claudeAuthMode(): ClaudeAuthMode {
@@ -434,7 +466,7 @@ async function inferenceViaClaudeSpawn(
434
466
  ): Promise<InferenceResult> {
435
467
  const mode = claudeAuthMode();
436
468
  const spawnClaude = (args: string[]) =>
437
- inferenceViaCliSpawn(bin, args, opts.user, opts, undefined, {
469
+ inferenceViaCliSpawn(bin, args, opts.user, opts, CLAUDE_REPLY, {
438
470
  env: credentialOverridesFor(mode),
439
471
  logTag: `auth=${mode}`,
440
472
  });
@@ -461,6 +493,7 @@ async function inferenceViaClaudeSpawn(
461
493
  * --ignore-rules → no execpolicy .rules files load
462
494
  * --sandbox read-only → child cannot execute shell commands even if it tries
463
495
  * --ephemeral → no session persistence; one-shot only
496
+ * --json → NDJSON events: the reply and the turn's token usage
464
497
  *
465
498
  * Codex has no --system-prompt equivalent, so system + user + JSON-schema become
466
499
  * one prompt. That prompt goes in on stdin, not as a positional argument, because
@@ -484,10 +517,26 @@ export function buildCodexArgs(opts: InferenceOptions): string[] {
484
517
  "--sandbox",
485
518
  "read-only",
486
519
  "--ephemeral",
520
+ "--json",
487
521
  ...(opts.web ? ["-c", "web_search=live"] : []),
488
522
  ];
489
523
  }
490
524
 
525
+ interface CodexItem {
526
+ type?: string;
527
+ text?: string;
528
+ }
529
+
530
+ const CODEX_REPLY: CliReply = {
531
+ text: (raw) =>
532
+ jsonLines(raw)
533
+ .filter((event) => event.type === "item.completed")
534
+ .map((event) => event.item as CodexItem)
535
+ .findLast((item) => item?.type === "agent_message")
536
+ ?.text?.trim() ?? "",
537
+ usage: codexSpawnUsage,
538
+ };
539
+
491
540
  /**
492
541
  * Build the argv for `opencode run …` from inference options. Pure.
493
542
  *
@@ -529,7 +578,7 @@ async function inferenceViaOpencodeSpawn(
529
578
  buildOpencodeArgs(opts),
530
579
  buildCliPrompt(opts),
531
580
  opts,
532
- extractOpencodeText,
581
+ { text: extractOpencodeText, usage: opencodeSpawnUsage },
533
582
  { cwd: dir, env: { XDG_CONFIG_HOME: dir, ...OPENCODE_WITHOUT_EXTERNAL_CONTEXT } }
534
583
  );
535
584
  } finally {
@@ -600,7 +649,7 @@ async function inferenceViaAntigravitySpawn(
600
649
  buildAntigravityArgs(opts),
601
650
  streamJsonPrompt(buildCliPrompt(opts)),
602
651
  opts,
603
- extractAntigravityText,
652
+ { text: extractAntigravityText, usage: antigravitySpawnUsage },
604
653
  { cwd: dir, afterRun: removeSpawnedConversation }
605
654
  );
606
655
  } finally {
@@ -622,6 +671,7 @@ async function inferenceViaAntigravitySpawn(
622
671
  * naming an unknown tool hides every tool
623
672
  * --deny-tool=… → backstop: deny rules win over any allow rule
624
673
  * --silent → keeps the tool-filter notices out of stdout
674
+ * --output-format json → JSONL events: the reply and the prompt's token usage
625
675
  * web: only web_fetch, pre-approved for every URL; Copilot has no search tool,
626
676
  * so it fetches search pages itself
627
677
  *
@@ -638,12 +688,27 @@ export function buildCopilotArgs(opts: InferenceOptions): string[] {
638
688
  "--no-auto-update",
639
689
  "--no-color",
640
690
  "--silent",
691
+ "--output-format",
692
+ "json",
641
693
  ...copilotTools(opts),
642
694
  "--deny-tool=shell",
643
695
  "--deny-tool=write",
644
696
  ];
645
697
  }
646
698
 
699
+ interface CopilotMessage {
700
+ content?: string;
701
+ }
702
+
703
+ const COPILOT_REPLY: CliReply = {
704
+ text: (raw) =>
705
+ jsonLines(raw)
706
+ .filter((event) => event.type === "assistant.message")
707
+ .map((event) => (event.data as CopilotMessage | undefined)?.content?.trim())
708
+ .findLast(Boolean) ?? "",
709
+ usage: copilotSpawnUsage,
710
+ };
711
+
647
712
  function copilotTools(opts: InferenceOptions): string[] {
648
713
  return opts.web
649
714
  ? ["--available-tools=web_fetch", "--allow-tool=web_fetch", "--allow-all-urls"]
@@ -831,6 +896,14 @@ async function singleCliAttempt(
831
896
  });
832
897
  }
833
898
 
899
+ /** How to read a CLI's stdout: the reply's text, and what the call cost. */
900
+ interface CliReply {
901
+ text: (rawStdout: string) => string;
902
+ usage: (rawStdout: string) => InferenceUsage | undefined;
903
+ }
904
+
905
+ const PLAIN_REPLY: CliReply = { text: (raw) => raw, usage: () => undefined };
906
+
834
907
  interface SpawnPlace {
835
908
  cwd?: string;
836
909
  env?: Record<string, string | undefined>;
@@ -849,7 +922,7 @@ async function inferenceViaCliSpawn(
849
922
  args: string[],
850
923
  stdinInput: string,
851
924
  opts: InferenceOptions,
852
- extractText?: (rawStdout: string) => string,
925
+ reply: CliReply = PLAIN_REPLY,
853
926
  place: SpawnPlace = {}
854
927
  ): Promise<InferenceResult> {
855
928
  const timeout = opts.timeout ?? 15000;
@@ -887,12 +960,13 @@ async function inferenceViaCliSpawn(
887
960
 
888
961
  place.afterRun?.(attempt.stdout);
889
962
  const elapsedMs = Date.now() - started;
963
+ const usage = reply.usage(attempt.stdout);
890
964
  const finish = (result: InferenceResult): InferenceResult => {
891
965
  logDebug(
892
966
  "inference:spawn",
893
967
  `${tag} done binary=${binaryName} success=${result.success} bytes=${result.output?.length ?? 0} elapsedMs=${elapsedMs}`
894
968
  );
895
- return result;
969
+ return { ...result, usage };
896
970
  };
897
971
 
898
972
  if (attempt.timedOut) {
@@ -908,7 +982,7 @@ async function inferenceViaCliSpawn(
908
982
  }
909
983
  const rawText = attempt.stdout.trim();
910
984
  if (!rawText) return finish({ success: false });
911
- const text = extractText ? extractText(rawText) : rawText;
985
+ const text = reply.text(rawText);
912
986
  if (!text) {
913
987
  // Extraction returned empty — the binary succeeded but our extractor found
914
988
  // no usable text. Log the raw stdout so we can see what was actually emitted.
@@ -973,13 +1047,7 @@ async function inferenceViaApi(opts: InferenceOptions): Promise<InferenceResult>
973
1047
  }
974
1048
 
975
1049
  const data = (await response.json()) as Record<string, unknown>;
976
- const rawUsage = data?.usage as
977
- | { input_tokens?: number; output_tokens?: number }
978
- | undefined;
979
- const usage =
980
- rawUsage?.input_tokens != null && rawUsage?.output_tokens != null
981
- ? { inputTokens: rawUsage.input_tokens, outputTokens: rawUsage.output_tokens }
982
- : undefined;
1050
+ const usage = data?.usage ? anthropicUsage(data.usage as TranscriptUsage) : undefined;
983
1051
 
984
1052
  const content = data?.content as Array<{ text?: string }> | undefined;
985
1053
  const text = content?.[0]?.text?.trim();
@@ -1044,16 +1112,7 @@ async function inferenceViaOpenAiApi(opts: InferenceOptions): Promise<InferenceR
1044
1112
  }
1045
1113
 
1046
1114
  const data = (await response.json()) as Record<string, unknown>;
1047
- const rawUsage = data?.usage as
1048
- | { prompt_tokens?: number; completion_tokens?: number }
1049
- | undefined;
1050
- const usage =
1051
- rawUsage?.prompt_tokens != null && rawUsage?.completion_tokens != null
1052
- ? {
1053
- inputTokens: rawUsage.prompt_tokens,
1054
- outputTokens: rawUsage.completion_tokens,
1055
- }
1056
- : undefined;
1115
+ const usage = data?.usage ? openAiUsage(data.usage as OpenAiUsage) : undefined;
1057
1116
 
1058
1117
  const choices = data?.choices as
1059
1118
  | Array<{ message?: { content?: string } }>
@@ -45,7 +45,7 @@ function normalize(text: string): string {
45
45
  .trim()} `;
46
46
  }
47
47
 
48
- function readSkillIndex(): SkillIndex | null {
48
+ export function readSkillIndex(): SkillIndex | null {
49
49
  const path = resolve(paths.state(), "skill-index.json");
50
50
  if (!existsSync(path)) return null;
51
51
  try {
@@ -1,11 +1,11 @@
1
1
  /**
2
- * Append-only JSONL logger for Haiku token usage.
2
+ * Append-only JSONL logger for inference token usage.
3
3
  * Writes to memory/signals/token-usage.jsonl
4
4
  */
5
5
 
6
6
  import { appendFileSync } from "node:fs";
7
7
  import { resolve } from "node:path";
8
- import { HAIKU_MODEL } from "./models";
8
+ import type { InferenceUsage } from "./inference-usage";
9
9
  import { ensureDir, paths } from "./paths";
10
10
 
11
11
  type TokenCaller =
@@ -23,6 +23,8 @@ type TokenCaller =
23
23
  | "agenda-waiting-on"
24
24
  | "agenda-moves";
25
25
 
26
+ const UNKNOWN_MODEL = "unknown";
27
+
26
28
  interface TokenUsageEntry {
27
29
  ts: string;
28
30
  caller: TokenCaller;
@@ -33,15 +35,16 @@ interface TokenUsageEntry {
33
35
 
34
36
  export function logTokenUsage(
35
37
  caller: TokenCaller,
36
- usage: { inputTokens: number; outputTokens: number },
37
- model?: string
38
+ call: { usage?: InferenceUsage; model?: string }
38
39
  ): void {
40
+ const { inputTokens, outputTokens } = call.usage ?? {};
41
+ if (inputTokens === undefined || outputTokens === undefined) return;
39
42
  const entry: TokenUsageEntry = {
40
43
  ts: new Date().toISOString(),
41
44
  caller,
42
- model: model ?? HAIKU_MODEL,
43
- inputTokens: usage.inputTokens,
44
- outputTokens: usage.outputTokens,
45
+ model: call.model ?? UNKNOWN_MODEL,
46
+ inputTokens,
47
+ outputTokens,
45
48
  };
46
49
 
47
50
  const dir = ensureDir(paths.signals());
@@ -19,17 +19,25 @@ const FIELDS = {
19
19
  hourCycle: "h23",
20
20
  } as const;
21
21
 
22
+ const formatters = new Map<string, Intl.DateTimeFormat>();
23
+
24
+ function formatterFor(timeZone: string): Intl.DateTimeFormat {
25
+ const cached = formatters.get(timeZone);
26
+ if (cached) return cached;
27
+ const formatter = new Intl.DateTimeFormat("en-US", { ...FIELDS, timeZone });
28
+ formatters.set(timeZone, formatter);
29
+ return formatter;
30
+ }
31
+
22
32
  function fieldsIn(now: Date, timeZone: string): Record<string, string> {
23
- const parts = new Intl.DateTimeFormat("en-US", { ...FIELDS, timeZone }).formatToParts(
24
- now
25
- );
33
+ const parts = formatterFor(timeZone).formatToParts(now);
26
34
  return Object.fromEntries(parts.map((p) => [p.type, p.value]));
27
35
  }
28
36
 
29
37
  /** The IANA name Intl settles on, or null when it does not recognise the input. */
30
38
  export function canonicalTimeZone(timeZone: string): string | null {
31
39
  try {
32
- return new Intl.DateTimeFormat("en-US", { timeZone }).resolvedOptions().timeZone;
40
+ return formatterFor(timeZone).resolvedOptions().timeZone;
33
41
  } catch {
34
42
  return null;
35
43
  }
@@ -24,7 +24,7 @@ export type CostMonths = Map<string, Map<string, AgentMonth>>;
24
24
  const numberOf = (value: unknown) => (typeof value === "number" ? value : 0);
25
25
 
26
26
  /** Claude Code at its dated rates, opencode at the cost it recorded, anything else unknown. */
27
- function apiPriceOf(event: LoggedEvent): number | null {
27
+ export function apiPriceOf(event: LoggedEvent): number | null {
28
28
  if (event.runtime === "opencode")
29
29
  return typeof event.costUsd === "number" ? event.costUsd : null;
30
30
  if (event.runtime !== "claude" || typeof event.model !== "string") return null;
@@ -93,6 +93,25 @@ function paidFor(month: AgentMonth): number | null {
93
93
  return paid;
94
94
  }
95
95
 
96
+ export interface CostTotals {
97
+ apiEquivalent: number;
98
+ unpriced: number;
99
+ paid: number | null;
100
+ }
101
+
102
+ /** Paid is null when any agent-month's payment is unknown. */
103
+ export function costTotals(months: CostMonths): CostTotals {
104
+ const agentMonths = [...months.values()].flatMap((agents) => [...agents.values()]);
105
+ const paids = agentMonths.map(paidFor);
106
+ return {
107
+ apiEquivalent: agentMonths.reduce((sum, m) => sum + m.apiEquivalent, 0),
108
+ unpriced: agentMonths.reduce((sum, m) => sum + m.unpriced, 0),
109
+ paid: paids.includes(null)
110
+ ? null
111
+ : paids.reduce<number>((sum, p) => sum + (p ?? 0), 0),
112
+ };
113
+ }
114
+
96
115
  /** Null unless both sides are known and something was paid. */
97
116
  function leverageOf(month: AgentMonth): number | null {
98
117
  const paid = paidFor(month);
@@ -0,0 +1,30 @@
1
+ import type { LoggedEvent } from "../../../hooks/lib/events";
2
+ import { type CostTotals, costMonths, costTotals } from "../cost-report";
3
+ import { ofType, type Tally, tally } from "./year";
4
+
5
+ export interface RuntimeCost {
6
+ runtime: string;
7
+ calls: number;
8
+ cost: CostTotals;
9
+ }
10
+
11
+ export interface WrappedAgents {
12
+ messages: Tally[];
13
+ costs: RuntimeCost[];
14
+ }
15
+
16
+ const runtimeOf = (e: LoggedEvent) => e.runtime;
17
+
18
+ function costOf(runtime: string, calls: LoggedEvent[]): RuntimeCost {
19
+ const own = calls.filter((c) => c.runtime === runtime);
20
+ return { runtime, calls: own.length, cost: costTotals(costMonths(own)) };
21
+ }
22
+
23
+ /** Shares are counted on the user's messages: sessions are minutes long on one agent and days long on another. */
24
+ export function wrappedAgents(events: LoggedEvent[]): WrappedAgents {
25
+ const calls = ofType(events, "model_call");
26
+ return {
27
+ messages: tally(ofType(events, "turn"), runtimeOf),
28
+ costs: tally(calls, runtimeOf).map(({ name }) => costOf(name, calls)),
29
+ };
30
+ }
@@ -0,0 +1,65 @@
1
+ import type { LoggedEvent } from "../../../hooks/lib/events";
2
+ import { localClock } from "../../../hooks/lib/wall-clock";
3
+ import type { Best } from "./records";
4
+ import { dayOf, isUserActivity, ofType, type Tally, tally } from "./year";
5
+
6
+ export interface WrappedHabits {
7
+ mostActiveHour: Best | null;
8
+ nightShare: number | null;
9
+ weekendShare: number | null;
10
+ channels: Tally[];
11
+ skimRate: number | null;
12
+ correctionRate: number | null;
13
+ }
14
+
15
+ interface HourSlot {
16
+ hour: number;
17
+ weekday: string;
18
+ }
19
+
20
+ const NIGHT_FROM = 22;
21
+ const NIGHT_UNTIL = 6;
22
+ const WEEKEND = new Set(["Sat", "Sun"]);
23
+
24
+ /** One slot per local hour with any of the user's own activity, however busy it was. */
25
+ function activeSlots(events: LoggedEvent[]): HourSlot[] {
26
+ const slots = new Map<string, HourSlot>();
27
+ for (const e of events.filter(isUserActivity)) {
28
+ const clock = localClock(new Date(e.ts));
29
+ slots.set(`${dayOf(e)} ${clock.hour}`, clock);
30
+ }
31
+ return [...slots.values()];
32
+ }
33
+
34
+ function shareOf<T>(items: T[], matches: (item: T) => boolean): number | null {
35
+ return items.length === 0 ? null : items.filter(matches).length / items.length;
36
+ }
37
+
38
+ function mostActiveHour(slots: HourSlot[]): Best | null {
39
+ const perHour = new Map<number, number>();
40
+ for (const { hour } of slots) perHour.set(hour, (perHour.get(hour) ?? 0) + 1);
41
+ const [top] = [...perHour].sort(([ha, a], [hb, b]) => b - a || ha - hb);
42
+ return top ? { value: top[1], label: `${String(top[0]).padStart(2, "0")}:00` } : null;
43
+ }
44
+
45
+ const isNight = ({ hour }: HourSlot) => hour >= NIGHT_FROM || hour < NIGHT_UNTIL;
46
+
47
+ const channelOf = (turn: LoggedEvent) =>
48
+ typeof turn.channel === "string" ? turn.channel : "terminal";
49
+
50
+ /** Skimming is only measured on a turn that answered a reply. */
51
+ export function wrappedHabits(events: LoggedEvent[]): WrappedHabits {
52
+ const slots = activeSlots(events);
53
+ const turns = ofType(events, "turn");
54
+ return {
55
+ mostActiveHour: mostActiveHour(slots),
56
+ nightShare: shareOf(slots, isNight),
57
+ weekendShare: shareOf(slots, (s) => WEEKEND.has(s.weekday)),
58
+ channels: tally(turns, channelOf),
59
+ skimRate: shareOf(
60
+ turns.filter((t) => t.reply !== null && t.reply !== undefined),
61
+ (t) => t.skimmed === true
62
+ ),
63
+ correctionRate: shareOf(turns, (t) => t.corrected === true),
64
+ };
65
+ }
@@ -0,0 +1,53 @@
1
+ import type { LoggedEvent } from "../../../hooks/lib/events";
2
+ import type { SkillIndex } from "../../../hooks/lib/skill-match";
3
+ import { dayOf, numberOf, ofType, type Tally, tally } from "./year";
4
+
5
+ export interface SkillsNeverUsed {
6
+ names: string[];
7
+ installed: number;
8
+ since: string;
9
+ }
10
+
11
+ export interface WrappedPal {
12
+ skillsUsed: Tally[];
13
+ skillsNeverUsed: SkillsNeverUsed | null;
14
+ promisesOnTime: number | null;
15
+ promisesKept: number;
16
+ }
17
+
18
+ const skillOf = (call: LoggedEvent) => String(call.skill);
19
+
20
+ /** Only as far back as tool calls were logged, and only when the installed skills are known. */
21
+ function neverUsed(
22
+ calls: LoggedEvent[],
23
+ used: Tally[],
24
+ index: SkillIndex | null
25
+ ): SkillsNeverUsed | null {
26
+ const [first] = [...calls].sort((a, b) => a.ts.localeCompare(b.ts));
27
+ if (index === null || first === undefined) return null;
28
+ const installed = Object.keys(index.skills).sort();
29
+ const names = installed.filter((name) => !used.some((u) => u.name === name));
30
+ return { names, installed: installed.length, since: dayOf(first) };
31
+ }
32
+
33
+ /** A promise kept by its due time; one closed any other way never counts. */
34
+ function promisesKeptOnTime(events: LoggedEvent[]) {
35
+ const kept = ofType(events, "promise").filter(
36
+ (p) => p.change === "done" && numberOf(p.lateSec) !== null
37
+ );
38
+ const onTime = kept.filter((p) => Number(p.lateSec) <= 0).length;
39
+ return { promisesOnTime: kept.length === 0 ? null : onTime, promisesKept: kept.length };
40
+ }
41
+
42
+ export function wrappedPal(events: LoggedEvent[], index: SkillIndex | null): WrappedPal {
43
+ const calls = ofType(events, "tool_call");
44
+ const used = tally(
45
+ calls.filter((c) => typeof c.skill === "string"),
46
+ skillOf
47
+ );
48
+ return {
49
+ skillsUsed: used,
50
+ skillsNeverUsed: neverUsed(calls, used, index),
51
+ ...promisesKeptOnTime(events),
52
+ };
53
+ }
@@ -0,0 +1,137 @@
1
+ import type { LoggedEvent } from "../../../hooks/lib/events";
2
+ import { apiPriceOf } from "../cost-report";
3
+ import { dayOf, isUserActivity, numberOf, ofType } from "./year";
4
+
5
+ export interface Best {
6
+ value: number;
7
+ label: string;
8
+ }
9
+
10
+ export interface WrappedRecords {
11
+ projectsRegisteredInADay: Best | null;
12
+ commitsInADay: Best | null;
13
+ longestProject: Best | null;
14
+ longestStreak: Best | null;
15
+ longestAgentRun: Best | null;
16
+ mostExpensiveTurn: Best | null;
17
+ biggestToolBurst: Best | null;
18
+ }
19
+
20
+ const DAY_MS = 86_400_000;
21
+
22
+ function best(candidates: Best[]): Best | null {
23
+ return candidates.reduce<Best | null>(
24
+ (top, c) => (top === null || c.value > top.value ? c : top),
25
+ null
26
+ );
27
+ }
28
+
29
+ function countBy(events: LoggedEvent[], keyOf: (e: LoggedEvent) => string): Best[] {
30
+ const counts = new Map<string, number>();
31
+ for (const e of events) counts.set(keyOf(e), (counts.get(keyOf(e)) ?? 0) + 1);
32
+ return [...counts].map(([label, value]) => ({ label, value }));
33
+ }
34
+
35
+ function daysBetween(from: string, to: string): number {
36
+ return Math.round((Date.parse(to) - Date.parse(from)) / DAY_MS);
37
+ }
38
+
39
+ function longestProject(events: LoggedEvent[]): Best | null {
40
+ const daysOf = new Map<string, string[]>();
41
+ for (const e of events) {
42
+ if (e.project === null) continue;
43
+ daysOf.set(e.project, [...(daysOf.get(e.project) ?? []), dayOf(e)]);
44
+ }
45
+ return best(
46
+ [...daysOf].map(([project, days]) => {
47
+ const [first, ...rest] = days.sort();
48
+ return { label: project, value: daysBetween(first, rest.at(-1) ?? first) + 1 };
49
+ })
50
+ );
51
+ }
52
+
53
+ function longestStreak(events: LoggedEvent[]): Best | null {
54
+ const days = [...new Set(events.filter(isUserActivity).map(dayOf))].sort();
55
+ const runs: Best[] = [];
56
+ let start = 0;
57
+ for (let i = 1; i <= days.length; i++) {
58
+ if (i < days.length && daysBetween(days[i - 1], days[i]) === 1) continue;
59
+ runs.push({ value: i - start, label: `${days[start]} → ${days[i - 1]}` });
60
+ start = i;
61
+ }
62
+ return best(runs);
63
+ }
64
+
65
+ function longestAgentRun(events: LoggedEvent[]): Best | null {
66
+ return best(
67
+ ofType(events, "reply").flatMap((e) => {
68
+ const sec = numberOf(e.latencySec);
69
+ return sec === null ? [] : [{ value: sec, label: dayOf(e) }];
70
+ })
71
+ );
72
+ }
73
+
74
+ interface TurnSpan {
75
+ turn: LoggedEvent;
76
+ inside: LoggedEvent[];
77
+ }
78
+
79
+ /** Each user turn and what happened in its session until the next one. */
80
+ function turnSpans(events: LoggedEvent[]): TurnSpan[] {
81
+ const open = new Map<string, TurnSpan>();
82
+ const spans: TurnSpan[] = [];
83
+ const inTimeOrder = [...events].sort((a, b) => a.ts.localeCompare(b.ts));
84
+ for (const e of inTimeOrder) {
85
+ if (e.session === null) continue;
86
+ if (e.type === "turn") {
87
+ const span = { turn: e, inside: [] };
88
+ open.set(e.session, span);
89
+ spans.push(span);
90
+ } else open.get(e.session)?.inside.push(e);
91
+ }
92
+ return spans;
93
+ }
94
+
95
+ const whereAndWhen = (turn: LoggedEvent) =>
96
+ turn.project === null ? dayOf(turn) : `${dayOf(turn)}, ${turn.project}`;
97
+
98
+ /** A turn with any call PAL cannot price is left out rather than undercounted. */
99
+ function turnCost({ inside }: TurnSpan): number | null {
100
+ const prices = ofType(inside, "model_call").map(apiPriceOf);
101
+ if (prices.length === 0 || prices.includes(null)) return null;
102
+ return prices.reduce<number>((sum, p) => sum + (p ?? 0), 0);
103
+ }
104
+
105
+ function mostExpensiveTurn(spans: TurnSpan[]): Best | null {
106
+ return best(
107
+ spans.flatMap((span) => {
108
+ const cost = turnCost(span);
109
+ return cost === null ? [] : [{ value: cost, label: whereAndWhen(span.turn) }];
110
+ })
111
+ );
112
+ }
113
+
114
+ function biggestToolBurst(spans: TurnSpan[]): Best | null {
115
+ return best(
116
+ spans
117
+ .map((span) => ({
118
+ value: ofType(span.inside, "tool_call").length,
119
+ label: whereAndWhen(span.turn),
120
+ }))
121
+ .filter((b) => b.value > 0)
122
+ );
123
+ }
124
+
125
+ export function wrappedRecords(events: LoggedEvent[]): WrappedRecords {
126
+ const registered = ofType(events, "project").filter((e) => e.change === "registered");
127
+ const spans = turnSpans(events);
128
+ return {
129
+ projectsRegisteredInADay: best(countBy(registered, dayOf)),
130
+ commitsInADay: best(countBy(ofType(events, "commit"), dayOf)),
131
+ longestProject: longestProject(events),
132
+ longestStreak: longestStreak(events),
133
+ longestAgentRun: longestAgentRun(events),
134
+ mostExpensiveTurn: mostExpensiveTurn(spans),
135
+ biggestToolBurst: biggestToolBurst(spans),
136
+ };
137
+ }