portable-agent-layer 0.100.0 → 0.101.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -86,6 +86,7 @@ pal cli status # check your setup
86
86
  | `pal cli analyze [--actionable]` | Learning analysis: rating trends, failure patterns, graduation candidates |
87
87
  | `pal cli usage` | Summarize token usage and estimated cost |
88
88
  | `pal cli cost` | What each month was paid against its API-equivalent cost, per agent |
89
+ | `pal cli wrapped [--year <yyyy>]` | A year of your work in numbers, read from the event log |
89
90
  | `pal cli content preserve` | Copy every Claude Code transcript, gzipped, into `memory/content/` before Claude Code deletes it (30 days by default). Runs on its own whenever a session closes; the content store never leaves the machine, so `export` skips it |
90
91
  | `pal cli events discord-nag <sent\|answered>` | For a bot outside PAL: record a nag it sent (`--kind <kind>`), or the answer to one and how long it took (`--after-sec <n>`), in the local event log |
91
92
  | `pal cli infer` | One model call for scripts and bots, on the active agent's route: prompt on stdin, reply on stdout. `--tier small\|medium`, `--system <file>`, `--schema <file>` for a JSON reply checked against it, `--timeout <seconds>`. The call loads no PAL hooks or context; exits 1 with the reason when no route answers |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.100.0",
3
+ "version": "0.101.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/cost.ts CHANGED
@@ -1,15 +1,11 @@
1
- import { AGENT_REGISTRY, type AgentName } from "../hooks/lib/agent-registry";
1
+ import { labelOfRuntime } from "../hooks/lib/agent-registry";
2
2
  import { eventsSince } from "../hooks/lib/events";
3
3
  import { leaf } from "../tools/lib/command";
4
4
  import { costLines, costMonths } from "../tools/lib/cost-report";
5
5
 
6
- function labelOf(runtime: string): string {
7
- return AGENT_REGISTRY[runtime as AgentName]?.label ?? runtime;
8
- }
9
-
10
6
  function printCost(): undefined {
11
7
  const months = costMonths(eventsSince("model_call", new Date(0)));
12
- for (const line of costLines(months, labelOf)) console.log(line);
8
+ for (const line of costLines(months, labelOfRuntime)) console.log(line);
13
9
  }
14
10
 
15
11
  export const costCommand = leaf({
package/src/cli/tree.ts CHANGED
@@ -28,6 +28,7 @@ import { serverCommand } from "./server";
28
28
  import { skillCommand } from "./skill";
29
29
  import { subagentCommand } from "./subagent";
30
30
  import { usageCommand } from "./usage";
31
+ import { wrappedCommand } from "./wrapped";
31
32
 
32
33
  type Handler = Leaf["run"];
33
34
  export type DebugState = "on" | "off" | "status";
@@ -155,6 +156,7 @@ export function cliTree(admin: AdminHandlers): Group {
155
156
  ...adminCommands(admin),
156
157
  usage: usageCommand,
157
158
  cost: costCommand,
159
+ wrapped: wrappedCommand,
158
160
  content: contentCommand,
159
161
  events: eventsCommand,
160
162
  infer: inferCommand,
@@ -0,0 +1,50 @@
1
+ import { readSkillIndex } from "../hooks/lib/skill-match";
2
+ import { leaf, UsageError } from "../tools/lib/command";
3
+ import { wrappedAgents } from "../tools/lib/wrapped/agents";
4
+ import { wrappedHabits } from "../tools/lib/wrapped/habits";
5
+ import { wrappedPal } from "../tools/lib/wrapped/pal";
6
+ import { wrappedRecords } from "../tools/lib/wrapped/records";
7
+ import {
8
+ agentsLines,
9
+ habitsLines,
10
+ palLines,
11
+ recordsLines,
12
+ totalsLines,
13
+ } from "../tools/lib/wrapped/render";
14
+ import { wrappedTotals } from "../tools/lib/wrapped/totals";
15
+ import { eventsOfYear } from "../tools/lib/wrapped/year";
16
+
17
+ function yearFrom(value: string | undefined): number {
18
+ if (value === undefined) return new Date().getFullYear();
19
+ if (!/^\d{4}$/.test(value)) throw new UsageError(`--year takes a year, got: ${value}`);
20
+ return Number(value);
21
+ }
22
+
23
+ function printWrapped(year: number): undefined {
24
+ const events = eventsOfYear(year);
25
+ console.log(`\n PAL Wrapped ${year}`);
26
+ if (events.length === 0) {
27
+ console.log(`\n No events logged in ${year}\n`);
28
+ return;
29
+ }
30
+ const lines = [
31
+ ...totalsLines(wrappedTotals(events)),
32
+ ...recordsLines(wrappedRecords(events)),
33
+ ...habitsLines(wrappedHabits(events)),
34
+ ...agentsLines(wrappedAgents(events)),
35
+ ...palLines(wrappedPal(events, readSkillIndex())),
36
+ ];
37
+ for (const line of lines) console.log(line);
38
+ }
39
+
40
+ export const wrappedCommand = leaf({
41
+ summary: "A year of your work in numbers, read from the event log",
42
+ options: {
43
+ year: {
44
+ type: "string",
45
+ value: "<yyyy>",
46
+ description: "Which year (default: this one)",
47
+ },
48
+ },
49
+ run: ({ values }) => printWrapped(yearFrom(values.year)),
50
+ });
@@ -86,3 +86,7 @@ export function skillsDirOf(agent: AgentName): string {
86
86
  export function isAgentName(value: string): value is AgentName {
87
87
  return Object.hasOwn(AGENT_REGISTRY, value);
88
88
  }
89
+
90
+ export function labelOfRuntime(runtime: string): string {
91
+ return isAgentName(runtime) ? AGENT_REGISTRY[runtime].label : runtime;
92
+ }
@@ -322,7 +322,7 @@ export function eventsInMonth(type: EventType, month: Date): LoggedEvent[] {
322
322
  return parseLines(file).filter((e) => e.type === type);
323
323
  }
324
324
 
325
- export function eventsSince(type: EventType, since: Date): LoggedEvent[] {
325
+ export function allEventsSince(since: Date): LoggedEvent[] {
326
326
  const dir = eventsDir();
327
327
  const sinceTs = since.toISOString();
328
328
  const fromMonth = monthOf(sinceTs);
@@ -330,5 +330,9 @@ export function eventsSince(type: EventType, since: Date): LoggedEvent[] {
330
330
  .filter((f) => f.endsWith(".jsonl") && f.slice(0, 7) >= fromMonth)
331
331
  .sort()
332
332
  .flatMap((f) => parseLines(resolve(dir, f)))
333
- .filter((e) => e.type === type && e.ts >= sinceTs);
333
+ .filter((e) => e.ts >= sinceTs);
334
+ }
335
+
336
+ export function eventsSince(type: EventType, since: Date): LoggedEvent[] {
337
+ return allEventsSince(since).filter((e) => e.type === type);
334
338
  }
@@ -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 {
@@ -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
+ }
@@ -0,0 +1,145 @@
1
+ import { labelOfRuntime } from "../../../hooks/lib/agent-registry";
2
+ import type { CostTotals } from "../cost-report";
3
+ import { fmtDuration } from "../session-usage";
4
+ import { fmtCost } from "../token-report";
5
+ import type { RuntimeCost, WrappedAgents } from "./agents";
6
+ import type { WrappedHabits } from "./habits";
7
+ import type { SkillsNeverUsed, WrappedPal } from "./pal";
8
+ import type { Best, WrappedRecords } from "./records";
9
+ import type { WrappedTotals } from "./totals";
10
+ import type { Tally } from "./year";
11
+
12
+ const count = (n: number) => n.toLocaleString("en-US");
13
+
14
+ function row(label: string, value: string): string {
15
+ return ` ${label.padEnd(20)} ${value}`;
16
+ }
17
+
18
+ function recordRow(
19
+ label: string,
20
+ record: Best | null,
21
+ format: (value: number) => string
22
+ ): string {
23
+ const value =
24
+ record === null ? "unknown" : `${format(record.value)} (${record.label})`;
25
+ return ` ${label.padEnd(36)} ${value}`;
26
+ }
27
+
28
+ const days = (n: number) => `${count(n)} ${n === 1 ? "day" : "days"}`;
29
+
30
+ export function recordsLines(records: WrappedRecords): string[] {
31
+ return [
32
+ "\n Records\n",
33
+ recordRow(
34
+ "most projects registered in a day",
35
+ records.projectsRegisteredInADay,
36
+ count
37
+ ),
38
+ recordRow("most commits in a day", records.commitsInADay, count),
39
+ recordRow("longest project", records.longestProject, days),
40
+ recordRow("longest streak", records.longestStreak, days),
41
+ recordRow("longest agent-alone run", records.longestAgentRun, (sec) =>
42
+ fmtDuration(sec * 1000)
43
+ ),
44
+ recordRow("most expensive turn", records.mostExpensiveTurn, fmtCost),
45
+ recordRow(
46
+ "biggest tool burst",
47
+ records.biggestToolBurst,
48
+ (n) => `${count(n)} tool calls`
49
+ ),
50
+ ];
51
+ }
52
+
53
+ const percent = (share: number | null) =>
54
+ share === null ? "unknown" : `${Math.round(share * 100)}%`;
55
+
56
+ function tallyLabel(tallies: Tally[]): string {
57
+ if (tallies.length === 0) return "unknown";
58
+ return tallies.map((t) => `${t.name} ${count(t.count)}`).join(" · ");
59
+ }
60
+
61
+ export function habitsLines(habits: WrappedHabits): string[] {
62
+ const hour = habits.mostActiveHour;
63
+ return [
64
+ "\n Habits\n",
65
+ row(
66
+ "most active hour",
67
+ hour === null ? "unknown" : `${hour.label} (active on ${days(hour.value)})`
68
+ ),
69
+ row("at night (22-06)", percent(habits.nightShare)),
70
+ row("on weekends", percent(habits.weekendShare)),
71
+ row("messages by channel", tallyLabel(habits.channels)),
72
+ row("skimmed replies", percent(habits.skimRate)),
73
+ row("corrections", percent(habits.correctionRate)),
74
+ ];
75
+ }
76
+
77
+ function neverUsedLines(never: SkillsNeverUsed | null): string[] {
78
+ if (never === null) return [row("skills never used", "unknown")];
79
+ const summary = `${count(never.names.length)} of ${count(never.installed)} since ${never.since}`;
80
+ const names = never.names.length > 0 ? [row("", never.names.join(", "))] : [];
81
+ return [row("skills never used", summary), ...names];
82
+ }
83
+
84
+ /** Nothing logs a steering rule being approved yet. */
85
+ export function palLines(pal: WrappedPal): string[] {
86
+ const onTime = pal.promisesOnTime;
87
+ return [
88
+ "\n PAL\n",
89
+ row("skills used", tallyLabel(pal.skillsUsed)),
90
+ ...neverUsedLines(pal.skillsNeverUsed),
91
+ row("rules approved", "unknown"),
92
+ row(
93
+ "promises on time",
94
+ onTime === null ? "unknown" : `${count(onTime)} of ${count(pal.promisesKept)}`
95
+ ),
96
+ ];
97
+ }
98
+
99
+ function tokensLabel(totals: WrappedTotals): string {
100
+ const unknown = totals.callsWithoutTokens;
101
+ return unknown > 0
102
+ ? `${count(totals.tokens)} + ${count(unknown)} calls without counts`
103
+ : count(totals.tokens);
104
+ }
105
+
106
+ function apiEquivalentLabel(cost: CostTotals): string {
107
+ const priced = fmtCost(cost.apiEquivalent);
108
+ return cost.unpriced > 0
109
+ ? `${priced} + ${count(cost.unpriced)} unpriced calls`
110
+ : priced;
111
+ }
112
+
113
+ const paidLabel = (paid: number | null) => (paid === null ? "unknown" : fmtCost(paid));
114
+
115
+ function runtimeCostLabel({ runtime, calls, cost }: RuntimeCost): string {
116
+ const api = cost.unpriced === calls ? "unknown" : apiEquivalentLabel(cost);
117
+ return `${labelOfRuntime(runtime)} API-equivalent ${api}, paid ${paidLabel(cost.paid)}`;
118
+ }
119
+
120
+ const labelled = (tallies: Tally[]) =>
121
+ tallies.map((t) => ({ ...t, name: labelOfRuntime(t.name) }));
122
+
123
+ /** No agent reports which of Cursor's lines a human typed yet. */
124
+ export function agentsLines(agents: WrappedAgents): string[] {
125
+ const [first, ...rest] = agents.costs.map(runtimeCostLabel);
126
+ return [
127
+ "\n Agents\n",
128
+ row("messages by agent", tallyLabel(labelled(agents.messages))),
129
+ row("cost by agent", first ?? "unknown"),
130
+ ...rest.map((label) => row("", label)),
131
+ row("Cursor human vs AI", "unknown"),
132
+ ];
133
+ }
134
+
135
+ export function totalsLines(totals: WrappedTotals): string[] {
136
+ return [
137
+ "\n Totals\n",
138
+ row("projects", count(totals.projects)),
139
+ row("sessions", count(totals.sessions)),
140
+ row("commits", count(totals.commits)),
141
+ row("tokens", tokensLabel(totals)),
142
+ row("API-equivalent", apiEquivalentLabel(totals.cost)),
143
+ row("paid", paidLabel(totals.cost.paid)),
144
+ ];
145
+ }
@@ -0,0 +1,55 @@
1
+ import type { LoggedEvent } from "../../../hooks/lib/events";
2
+ import { type CostTotals, costMonths, costTotals } from "../cost-report";
3
+ import { numberOf, ofType } from "./year";
4
+
5
+ export interface WrappedTotals {
6
+ projects: number;
7
+ sessions: number;
8
+ commits: number;
9
+ tokens: number;
10
+ callsWithoutTokens: number;
11
+ cost: CostTotals;
12
+ }
13
+
14
+ const TOKEN_FIELDS = [
15
+ "inputTokens",
16
+ "outputTokens",
17
+ "cacheWrite5m",
18
+ "cacheWrite1h",
19
+ "cacheRead",
20
+ ] as const;
21
+
22
+ function tokensOf(call: LoggedEvent): number | null {
23
+ const known = TOKEN_FIELDS.map((f) => numberOf(call[f])).filter((n) => n !== null);
24
+ return known.length === 0 ? null : known.reduce((sum, n) => sum + n, 0);
25
+ }
26
+
27
+ function distinct(values: (string | null)[]): number {
28
+ return new Set(values.filter((v) => v !== null)).size;
29
+ }
30
+
31
+ const isSpawnedCall = (e: LoggedEvent) =>
32
+ e.type === "model_call" && e.entrypoint === "sdk-cli";
33
+
34
+ const showsTheUser = (e: LoggedEvent) =>
35
+ e.type === "turn" || (e.type === "model_call" && !isSpawnedCall(e));
36
+
37
+ /** PAL's own background inference runs as SDK sessions nobody typed into. */
38
+ function sessionsOfTheUser(events: LoggedEvent[]): number {
39
+ const spawned = new Set(events.filter(isSpawnedCall).map((e) => e.session));
40
+ for (const e of events.filter(showsTheUser)) spawned.delete(e.session);
41
+ return distinct(events.map((e) => e.session).filter((s) => !spawned.has(s)));
42
+ }
43
+
44
+ export function wrappedTotals(events: LoggedEvent[]): WrappedTotals {
45
+ const calls = ofType(events, "model_call");
46
+ const tokens = calls.map(tokensOf);
47
+ return {
48
+ projects: distinct(events.map((e) => e.project)),
49
+ sessions: sessionsOfTheUser(events),
50
+ commits: ofType(events, "commit").length,
51
+ tokens: tokens.reduce<number>((sum, n) => sum + (n ?? 0), 0),
52
+ callsWithoutTokens: tokens.filter((n) => n === null).length,
53
+ cost: costTotals(costMonths(calls)),
54
+ };
55
+ }
@@ -0,0 +1,45 @@
1
+ import {
2
+ allEventsSince,
3
+ type EventType,
4
+ type LoggedEvent,
5
+ } from "../../../hooks/lib/events";
6
+ import { localDay } from "../../../hooks/lib/wall-clock";
7
+
8
+ export function dayOf(event: LoggedEvent): string {
9
+ return localDay(new Date(event.ts));
10
+ }
11
+
12
+ export function ofType(events: LoggedEvent[], type: EventType): LoggedEvent[] {
13
+ return events.filter((e) => e.type === type);
14
+ }
15
+
16
+ export const isUserActivity = (e: LoggedEvent) =>
17
+ e.type === "commit" ||
18
+ e.type === "turn" ||
19
+ (e.type === "model_call" && e.entrypoint !== "sdk-cli");
20
+
21
+ export interface Tally {
22
+ name: string;
23
+ count: number;
24
+ }
25
+
26
+ /** Most frequent first, ties by name. */
27
+ export function tally<T>(items: T[], nameOf: (item: T) => string): Tally[] {
28
+ const counts = new Map<string, number>();
29
+ for (const item of items) {
30
+ const name = nameOf(item);
31
+ counts.set(name, (counts.get(name) ?? 0) + 1);
32
+ }
33
+ return [...counts]
34
+ .map(([name, count]) => ({ name, count }))
35
+ .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
36
+ }
37
+
38
+ export function numberOf(value: unknown): number | null {
39
+ return typeof value === "number" ? value : null;
40
+ }
41
+
42
+ export function eventsOfYear(year: number): LoggedEvent[] {
43
+ const dayBeforeInUtc = new Date(Date.UTC(year - 1, 11, 31));
44
+ return allEventsSince(dayBeforeInUtc).filter((e) => dayOf(e).startsWith(`${year}-`));
45
+ }