portable-agent-layer 0.100.0 → 0.102.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 +2 -0
- package/package.json +1 -1
- package/src/cli/cost.ts +2 -6
- package/src/cli/patterns.ts +31 -0
- package/src/cli/tree.ts +4 -0
- package/src/cli/wrapped.ts +50 -0
- package/src/hooks/lib/agent-registry.ts +4 -0
- package/src/hooks/lib/events.ts +6 -2
- package/src/hooks/lib/skill-match.ts +1 -1
- package/src/hooks/lib/wall-clock.ts +12 -4
- package/src/tools/lib/cost-report.ts +20 -1
- package/src/tools/lib/patterns/activity.ts +41 -0
- package/src/tools/lib/patterns/days.ts +38 -0
- package/src/tools/lib/patterns/focus.ts +59 -0
- package/src/tools/lib/patterns/profiles.ts +59 -0
- package/src/tools/lib/patterns/render.ts +89 -0
- package/src/tools/lib/patterns/sprint.ts +58 -0
- package/src/tools/lib/report-rows.ts +14 -0
- package/src/tools/lib/wrapped/agents.ts +30 -0
- package/src/tools/lib/wrapped/habits.ts +72 -0
- package/src/tools/lib/wrapped/pal.ts +53 -0
- package/src/tools/lib/wrapped/records.ts +131 -0
- package/src/tools/lib/wrapped/render.ts +140 -0
- package/src/tools/lib/wrapped/totals.ts +55 -0
- package/src/tools/lib/wrapped/year.ts +51 -0
package/README.md
CHANGED
|
@@ -86,6 +86,8 @@ 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 |
|
|
90
|
+
| `pal cli patterns` | How you work across projects, read from the event log |
|
|
89
91
|
| `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
92
|
| `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
93
|
| `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
package/src/cli/cost.ts
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
|
-
import {
|
|
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,
|
|
8
|
+
for (const line of costLines(months, labelOfRuntime)) console.log(line);
|
|
13
9
|
}
|
|
14
10
|
|
|
15
11
|
export const costCommand = leaf({
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { allEventsSince } from "../hooks/lib/events";
|
|
2
|
+
import { localDay } from "../hooks/lib/wall-clock";
|
|
3
|
+
import { leaf } from "../tools/lib/command";
|
|
4
|
+
import { projectActivity } from "../tools/lib/patterns/activity";
|
|
5
|
+
import { contextSwitches, dayShape } from "../tools/lib/patterns/days";
|
|
6
|
+
import { focusBlocks } from "../tools/lib/patterns/focus";
|
|
7
|
+
import { projectProfiles } from "../tools/lib/patterns/profiles";
|
|
8
|
+
import {
|
|
9
|
+
daysLines,
|
|
10
|
+
focusLines,
|
|
11
|
+
momentumLines,
|
|
12
|
+
projectsLines,
|
|
13
|
+
} from "../tools/lib/patterns/render";
|
|
14
|
+
import { currentSprint, quietAfterBurst } from "../tools/lib/patterns/sprint";
|
|
15
|
+
|
|
16
|
+
function printPatterns(): undefined {
|
|
17
|
+
const activity = projectActivity(allEventsSince(new Date(0)));
|
|
18
|
+
console.log("\n PAL Patterns");
|
|
19
|
+
const lines = [
|
|
20
|
+
...momentumLines(currentSprint(activity), quietAfterBurst(activity, localDay())),
|
|
21
|
+
...daysLines(dayShape(activity), contextSwitches(activity)),
|
|
22
|
+
...focusLines(focusBlocks(activity)),
|
|
23
|
+
...projectsLines(projectProfiles(activity)),
|
|
24
|
+
];
|
|
25
|
+
for (const line of lines) console.log(line);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const patternsCommand = leaf({
|
|
29
|
+
summary: "How you work across projects, read from the event log",
|
|
30
|
+
run: () => printPatterns(),
|
|
31
|
+
});
|
package/src/cli/tree.ts
CHANGED
|
@@ -22,12 +22,14 @@ import { inferCommand } from "./infer";
|
|
|
22
22
|
import { knowledgeCommand } from "./knowledge";
|
|
23
23
|
import { ledgerCommand } from "./ledger-command";
|
|
24
24
|
import { runMigrate } from "./migrate";
|
|
25
|
+
import { patternsCommand } from "./patterns";
|
|
25
26
|
import { telosCommand, timezoneCommand } from "./personal-context";
|
|
26
27
|
import { ruleCommand } from "./rule";
|
|
27
28
|
import { serverCommand } from "./server";
|
|
28
29
|
import { skillCommand } from "./skill";
|
|
29
30
|
import { subagentCommand } from "./subagent";
|
|
30
31
|
import { usageCommand } from "./usage";
|
|
32
|
+
import { wrappedCommand } from "./wrapped";
|
|
31
33
|
|
|
32
34
|
type Handler = Leaf["run"];
|
|
33
35
|
export type DebugState = "on" | "off" | "status";
|
|
@@ -155,6 +157,8 @@ export function cliTree(admin: AdminHandlers): Group {
|
|
|
155
157
|
...adminCommands(admin),
|
|
156
158
|
usage: usageCommand,
|
|
157
159
|
cost: costCommand,
|
|
160
|
+
wrapped: wrappedCommand,
|
|
161
|
+
patterns: patternsCommand,
|
|
158
162
|
content: contentCommand,
|
|
159
163
|
events: eventsCommand,
|
|
160
164
|
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
|
+
}
|
package/src/hooks/lib/events.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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 =
|
|
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
|
|
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,41 @@
|
|
|
1
|
+
import type { LoggedEvent } from "../../../hooks/lib/events";
|
|
2
|
+
import { dayOf, isUserActivity } from "../wrapped/year";
|
|
3
|
+
|
|
4
|
+
export interface Activity {
|
|
5
|
+
ms: number;
|
|
6
|
+
day: string;
|
|
7
|
+
project: string;
|
|
8
|
+
event: LoggedEvent;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The user's own work that landed in a project, oldest first. */
|
|
12
|
+
export function projectActivity(events: LoggedEvent[]): Activity[] {
|
|
13
|
+
return events
|
|
14
|
+
.filter(isUserActivity)
|
|
15
|
+
.flatMap((event) =>
|
|
16
|
+
typeof event.project === "string"
|
|
17
|
+
? [{ ms: Date.parse(event.ts), day: dayOf(event), project: event.project, event }]
|
|
18
|
+
: []
|
|
19
|
+
)
|
|
20
|
+
.sort((a, b) => a.ms - b.ms);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const movesAttention = ({ event }: Activity) =>
|
|
24
|
+
event.type === "turn" || event.type === "commit";
|
|
25
|
+
|
|
26
|
+
export function projectsByDay(activity: Activity[]): Map<string, Set<string>> {
|
|
27
|
+
const byDay = new Map<string, Set<string>>();
|
|
28
|
+
for (const { day, project } of activity) {
|
|
29
|
+
byDay.set(day, (byDay.get(day) ?? new Set()).add(project));
|
|
30
|
+
}
|
|
31
|
+
return byDay;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Oldest day first, as the activity is. */
|
|
35
|
+
export function daysByProject(activity: Activity[]): Map<string, string[]> {
|
|
36
|
+
const byProject = new Map<string, Set<string>>();
|
|
37
|
+
for (const { day, project } of activity) {
|
|
38
|
+
byProject.set(project, (byProject.get(project) ?? new Set()).add(day));
|
|
39
|
+
}
|
|
40
|
+
return new Map([...byProject].map(([project, days]) => [project, [...days]]));
|
|
41
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Best } from "../wrapped/records";
|
|
2
|
+
import { type Activity, movesAttention, projectsByDay } from "./activity";
|
|
3
|
+
|
|
4
|
+
export interface DayShape {
|
|
5
|
+
oneProject: number;
|
|
6
|
+
manyProjects: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface Switches {
|
|
10
|
+
perDay: number;
|
|
11
|
+
mostInADay: Best;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function dayShape(activity: Activity[]): DayShape {
|
|
15
|
+
const sizes = [...projectsByDay(activity).values()].map((projects) => projects.size);
|
|
16
|
+
return {
|
|
17
|
+
oneProject: sizes.filter((n) => n === 1).length,
|
|
18
|
+
manyProjects: sizes.filter((n) => n > 1).length,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Model calls are left out: two sessions running side by side interleave theirs without the user moving. */
|
|
23
|
+
export function contextSwitches(activity: Activity[]): Switches | null {
|
|
24
|
+
const moves = activity.filter(movesAttention);
|
|
25
|
+
const perDay = new Map<string, number>();
|
|
26
|
+
moves.forEach((a, i) => {
|
|
27
|
+
const previous = moves[i - 1];
|
|
28
|
+
const switched = previous?.day === a.day && previous.project !== a.project;
|
|
29
|
+
perDay.set(a.day, (perDay.get(a.day) ?? 0) + (switched ? 1 : 0));
|
|
30
|
+
});
|
|
31
|
+
const counts = [...perDay];
|
|
32
|
+
if (counts.length === 0) return null;
|
|
33
|
+
const [day, most] = counts.reduce((top, c) => (c[1] > top[1] ? c : top));
|
|
34
|
+
return {
|
|
35
|
+
perDay: counts.reduce((sum, [, n]) => sum + n, 0) / counts.length,
|
|
36
|
+
mostInADay: { value: most, label: day },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Best } from "../wrapped/records";
|
|
2
|
+
import { type Activity, movesAttention } from "./activity";
|
|
3
|
+
|
|
4
|
+
export interface Focus {
|
|
5
|
+
blocks: number;
|
|
6
|
+
minutes: number;
|
|
7
|
+
longest: Best | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface Block {
|
|
11
|
+
first: Activity;
|
|
12
|
+
last: Activity;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const MINUTE_MS = 60_000;
|
|
16
|
+
const FOCUS_MINUTES = 90;
|
|
17
|
+
const MAX_GAP_MINUTES = 30;
|
|
18
|
+
|
|
19
|
+
const minutesOf = ({ first, last }: Block) => (last.ms - first.ms) / MINUTE_MS;
|
|
20
|
+
|
|
21
|
+
function continues(block: Block, next: Activity): boolean {
|
|
22
|
+
return (
|
|
23
|
+
next.project === block.last.project &&
|
|
24
|
+
(next.ms - block.last.ms) / MINUTE_MS <= MAX_GAP_MINUTES
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A parallel session's model calls in another project leave the block alone; the user moving there ends it. */
|
|
29
|
+
function blocksOf(activity: Activity[]): Block[] {
|
|
30
|
+
const blocks: Block[] = [];
|
|
31
|
+
for (const next of activity) {
|
|
32
|
+
const current = blocks.at(-1);
|
|
33
|
+
if (current && continues(current, next)) current.last = next;
|
|
34
|
+
else if (!current || current.last.project === next.project || movesAttention(next))
|
|
35
|
+
blocks.push({ first: next, last: next });
|
|
36
|
+
}
|
|
37
|
+
return blocks;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 90 minutes or more on one project with no gap over 30. */
|
|
41
|
+
export function focusBlocks(activity: Activity[]): Focus | null {
|
|
42
|
+
if (activity.length === 0) return null;
|
|
43
|
+
const focused = blocksOf(activity).filter((b) => minutesOf(b) >= FOCUS_MINUTES);
|
|
44
|
+
const longest = focused.reduce<Block | null>(
|
|
45
|
+
(top, b) => (top === null || minutesOf(b) > minutesOf(top) ? b : top),
|
|
46
|
+
null
|
|
47
|
+
);
|
|
48
|
+
return {
|
|
49
|
+
blocks: focused.length,
|
|
50
|
+
minutes: focused.reduce((sum, b) => sum + minutesOf(b), 0),
|
|
51
|
+
longest:
|
|
52
|
+
longest === null
|
|
53
|
+
? null
|
|
54
|
+
: {
|
|
55
|
+
value: minutesOf(longest),
|
|
56
|
+
label: `${longest.first.project}, ${longest.first.day}`,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { labelOfRuntime } from "../../../hooks/lib/agent-registry";
|
|
2
|
+
import { localClock } from "../../../hooks/lib/wall-clock";
|
|
3
|
+
import { channelOf } from "../wrapped/habits";
|
|
4
|
+
import { type Tally, tally } from "../wrapped/year";
|
|
5
|
+
import type { Activity } from "./activity";
|
|
6
|
+
|
|
7
|
+
export interface ProjectProfile {
|
|
8
|
+
project: string;
|
|
9
|
+
activeDays: number;
|
|
10
|
+
hour: string;
|
|
11
|
+
weekday: string;
|
|
12
|
+
agents: Tally[];
|
|
13
|
+
channels: Tally[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Slot {
|
|
17
|
+
hour: string;
|
|
18
|
+
weekday: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** One slot per local hour the project saw work, however busy it was. */
|
|
22
|
+
function slotsOf(activity: Activity[]): Slot[] {
|
|
23
|
+
const slots = new Map<string, Slot>();
|
|
24
|
+
for (const a of activity) {
|
|
25
|
+
const clock = localClock(new Date(a.ms));
|
|
26
|
+
const hour = `${String(clock.hour).padStart(2, "0")}:00`;
|
|
27
|
+
slots.set(`${a.day} ${hour}`, { hour, weekday: clock.weekday });
|
|
28
|
+
}
|
|
29
|
+
return [...slots.values()];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const topOf = (slots: Slot[], labelOf: (slot: Slot) => string) =>
|
|
33
|
+
tally(slots, labelOf)[0].name;
|
|
34
|
+
|
|
35
|
+
function profileOf(project: string, activity: Activity[]): ProjectProfile {
|
|
36
|
+
const slots = slotsOf(activity);
|
|
37
|
+
const turns = activity.filter((a) => a.event.type === "turn").map((a) => a.event);
|
|
38
|
+
return {
|
|
39
|
+
project,
|
|
40
|
+
activeDays: new Set(activity.map((a) => a.day)).size,
|
|
41
|
+
hour: topOf(slots, (s) => s.hour),
|
|
42
|
+
weekday: topOf(slots, (s) => s.weekday),
|
|
43
|
+
agents: tally(turns, (t) => labelOfRuntime(t.runtime)),
|
|
44
|
+
channels: tally(turns, channelOf),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Most worked-on first. */
|
|
49
|
+
export function projectProfiles(activity: Activity[]): ProjectProfile[] {
|
|
50
|
+
const byProject = new Map<string, Activity[]>();
|
|
51
|
+
for (const a of activity) {
|
|
52
|
+
const own = byProject.get(a.project) ?? [];
|
|
53
|
+
byProject.set(a.project, own);
|
|
54
|
+
own.push(a);
|
|
55
|
+
}
|
|
56
|
+
return [...byProject]
|
|
57
|
+
.map(([project, own]) => profileOf(project, own))
|
|
58
|
+
.sort((a, b) => b.activeDays - a.activeDays || a.project.localeCompare(b.project));
|
|
59
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { count, days, row, tallyLabel } from "../report-rows";
|
|
2
|
+
import type { DayShape, Switches } from "./days";
|
|
3
|
+
import type { Focus } from "./focus";
|
|
4
|
+
import type { ProjectProfile } from "./profiles";
|
|
5
|
+
import type { QuietProject, Sprint } from "./sprint";
|
|
6
|
+
|
|
7
|
+
function shapeLabel({ oneProject, manyProjects }: DayShape): string {
|
|
8
|
+
const total = oneProject + manyProjects;
|
|
9
|
+
if (total === 0) return "unknown";
|
|
10
|
+
return `${days(oneProject)} on one project, ${days(manyProjects)} on several (${Math.round((oneProject / total) * 100)}% focused)`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function switchesLabel(switches: Switches | null): string {
|
|
14
|
+
if (switches === null) return "unknown";
|
|
15
|
+
const { value, label } = switches.mostInADay;
|
|
16
|
+
return `${switches.perDay.toFixed(1)} a day, at most ${count(value)} (${label})`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function hoursAndMinutes(minutes: number): string {
|
|
20
|
+
const whole = Math.round(minutes);
|
|
21
|
+
return `${Math.floor(whole / 60)}h ${String(whole % 60).padStart(2, "0")}m`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function longestLabel(focus: Focus): string {
|
|
25
|
+
const longest = focus.longest;
|
|
26
|
+
return longest === null
|
|
27
|
+
? "none"
|
|
28
|
+
: `${hoursAndMinutes(longest.value)} (${longest.label})`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function focusLines(focus: Focus | null): string[] {
|
|
32
|
+
if (focus === null)
|
|
33
|
+
return ["\n Focus\n", row("focus blocks", "unknown"), row("longest", "unknown")];
|
|
34
|
+
return [
|
|
35
|
+
"\n Focus\n",
|
|
36
|
+
row(
|
|
37
|
+
"focus blocks",
|
|
38
|
+
`${count(focus.blocks)}, ${hoursAndMinutes(focus.minutes)} in all`
|
|
39
|
+
),
|
|
40
|
+
row("longest", longestLabel(focus)),
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function profileLines(profile: ProjectProfile): string[] {
|
|
45
|
+
return [
|
|
46
|
+
row(
|
|
47
|
+
profile.project,
|
|
48
|
+
`${days(profile.activeDays)}, most at ${profile.hour} on ${profile.weekday}`
|
|
49
|
+
),
|
|
50
|
+
row(" agents", tallyLabel(profile.agents)),
|
|
51
|
+
row(" channels", tallyLabel(profile.channels)),
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function projectsLines(profiles: ProjectProfile[]): string[] {
|
|
56
|
+
if (profiles.length === 0) return ["\n Projects\n", row("projects", "unknown")];
|
|
57
|
+
return ["\n Projects\n", ...profiles.flatMap(profileLines)];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function daysLines(shape: DayShape, switches: Switches | null): string[] {
|
|
61
|
+
return [
|
|
62
|
+
"\n Days\n",
|
|
63
|
+
row("project days", shapeLabel(shape)),
|
|
64
|
+
row("context switches", switchesLabel(switches)),
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function quietLabel(quiet: QuietProject[]): string {
|
|
69
|
+
if (quiet.length === 0) return "none";
|
|
70
|
+
return quiet.map((q) => `${q.project} (quiet ${days(q.quietDays)})`).join(" · ");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** With no activity at all, there is no telling which projects went quiet. */
|
|
74
|
+
export function momentumLines(sprint: Sprint | null, quiet: QuietProject[]): string[] {
|
|
75
|
+
if (sprint === null)
|
|
76
|
+
return [
|
|
77
|
+
"\n Momentum\n",
|
|
78
|
+
row("current sprint", "unknown"),
|
|
79
|
+
row("quiet after a burst", "unknown"),
|
|
80
|
+
];
|
|
81
|
+
return [
|
|
82
|
+
"\n Momentum\n",
|
|
83
|
+
row(
|
|
84
|
+
"current sprint",
|
|
85
|
+
`${sprint.project}, ${days(sprint.days)} of work since ${sprint.since}`
|
|
86
|
+
),
|
|
87
|
+
row("quiet after a burst", quietLabel(quiet)),
|
|
88
|
+
];
|
|
89
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { daysBetween } from "../wrapped/year";
|
|
2
|
+
import { type Activity, daysByProject, projectsByDay } from "./activity";
|
|
3
|
+
|
|
4
|
+
export interface Sprint {
|
|
5
|
+
project: string;
|
|
6
|
+
days: number;
|
|
7
|
+
since: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface QuietProject {
|
|
11
|
+
project: string;
|
|
12
|
+
lastActive: string;
|
|
13
|
+
quietDays: number;
|
|
14
|
+
burstDays: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const BURST_DAYS = 3;
|
|
18
|
+
const BURST_WINDOW_DAYS = 7;
|
|
19
|
+
const QUIET_DAYS = 14;
|
|
20
|
+
|
|
21
|
+
/** Counted in the days the user worked at all, so a weekend off does not end it. */
|
|
22
|
+
export function currentSprint(activity: Activity[]): Sprint | null {
|
|
23
|
+
const days = [...projectsByDay(activity)];
|
|
24
|
+
const latest = days.at(-1);
|
|
25
|
+
if (latest === undefined) return null;
|
|
26
|
+
const sprints = [...latest[1]].map((project) => {
|
|
27
|
+
let start = days.length - 1;
|
|
28
|
+
while (start > 0 && days[start - 1][1].has(project)) start--;
|
|
29
|
+
return { project, days: days.length - start, since: days[start][0] };
|
|
30
|
+
});
|
|
31
|
+
return sprints.sort((a, b) => b.days - a.days || a.project.localeCompare(b.project))[0];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function busiestWindow(days: string[]): number {
|
|
35
|
+
let most = 0;
|
|
36
|
+
let start = 0;
|
|
37
|
+
for (let end = 0; end < days.length; end++) {
|
|
38
|
+
while (daysBetween(days[start], days[end]) >= BURST_WINDOW_DAYS) start++;
|
|
39
|
+
most = Math.max(most, end - start + 1);
|
|
40
|
+
}
|
|
41
|
+
return most;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A burst is 3 active days within 7; quiet is 14 days without any. */
|
|
45
|
+
export function quietAfterBurst(activity: Activity[], today: string): QuietProject[] {
|
|
46
|
+
return [...daysByProject(activity)]
|
|
47
|
+
.map(([project, days]) => {
|
|
48
|
+
const lastActive = days.at(-1) ?? today;
|
|
49
|
+
return {
|
|
50
|
+
project,
|
|
51
|
+
lastActive,
|
|
52
|
+
quietDays: daysBetween(lastActive, today),
|
|
53
|
+
burstDays: busiestWindow(days),
|
|
54
|
+
};
|
|
55
|
+
})
|
|
56
|
+
.filter((p) => p.burstDays >= BURST_DAYS && p.quietDays >= QUIET_DAYS)
|
|
57
|
+
.sort((a, b) => b.lastActive.localeCompare(a.lastActive));
|
|
58
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Tally } from "./wrapped/year";
|
|
2
|
+
|
|
3
|
+
export const count = (n: number) => n.toLocaleString("en-US");
|
|
4
|
+
|
|
5
|
+
export function row(label: string, value: string): string {
|
|
6
|
+
return ` ${label.padEnd(20)} ${value}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function tallyLabel(tallies: Tally[]): string {
|
|
10
|
+
if (tallies.length === 0) return "unknown";
|
|
11
|
+
return tallies.map((t) => `${t.name} ${count(t.count)}`).join(" · ");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const days = (n: number) => `${count(n)} ${n === 1 ? "day" : "days"}`;
|
|
@@ -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,72 @@
|
|
|
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
|
+
mostActiveWeekday: Best | null;
|
|
9
|
+
mostActiveMonth: Best | null;
|
|
10
|
+
nightShare: number | null;
|
|
11
|
+
weekendShare: number | null;
|
|
12
|
+
channels: Tally[];
|
|
13
|
+
skimRate: number | null;
|
|
14
|
+
correctionRate: number | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface HourSlot {
|
|
18
|
+
day: string;
|
|
19
|
+
hour: number;
|
|
20
|
+
weekday: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const NIGHT_FROM = 22;
|
|
24
|
+
const NIGHT_UNTIL = 6;
|
|
25
|
+
const WEEKEND = new Set(["Sat", "Sun"]);
|
|
26
|
+
|
|
27
|
+
/** One slot per local hour with any of the user's own activity, however busy it was. */
|
|
28
|
+
function activeSlots(events: LoggedEvent[]): HourSlot[] {
|
|
29
|
+
const slots = new Map<string, HourSlot>();
|
|
30
|
+
for (const e of events.filter(isUserActivity)) {
|
|
31
|
+
const day = dayOf(e);
|
|
32
|
+
const clock = localClock(new Date(e.ts));
|
|
33
|
+
slots.set(`${day} ${clock.hour}`, { day, ...clock });
|
|
34
|
+
}
|
|
35
|
+
return [...slots.values()];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function shareOf<T>(items: T[], matches: (item: T) => boolean): number | null {
|
|
39
|
+
return items.length === 0 ? null : items.filter(matches).length / items.length;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Ties go to the first label, which for hours and months is the earliest. */
|
|
43
|
+
function busiest(slots: HourSlot[], labelOf: (slot: HourSlot) => string): Best | null {
|
|
44
|
+
const [top] = tally(slots, labelOf);
|
|
45
|
+
return top ? { value: top.count, label: top.name } : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const hourOf = ({ hour }: HourSlot) => `${String(hour).padStart(2, "0")}:00`;
|
|
49
|
+
|
|
50
|
+
const isNight = ({ hour }: HourSlot) => hour >= NIGHT_FROM || hour < NIGHT_UNTIL;
|
|
51
|
+
|
|
52
|
+
export const channelOf = (turn: LoggedEvent) =>
|
|
53
|
+
typeof turn.channel === "string" ? turn.channel : "terminal";
|
|
54
|
+
|
|
55
|
+
/** Skimming is only measured on a turn that answered a reply. */
|
|
56
|
+
export function wrappedHabits(events: LoggedEvent[]): WrappedHabits {
|
|
57
|
+
const slots = activeSlots(events);
|
|
58
|
+
const turns = ofType(events, "turn");
|
|
59
|
+
return {
|
|
60
|
+
mostActiveHour: busiest(slots, hourOf),
|
|
61
|
+
mostActiveWeekday: busiest(slots, (s) => s.weekday),
|
|
62
|
+
mostActiveMonth: busiest(slots, (s) => s.day.slice(0, 7)),
|
|
63
|
+
nightShare: shareOf(slots, isNight),
|
|
64
|
+
weekendShare: shareOf(slots, (s) => WEEKEND.has(s.weekday)),
|
|
65
|
+
channels: tally(turns, channelOf),
|
|
66
|
+
skimRate: shareOf(
|
|
67
|
+
turns.filter((t) => t.reply !== null && t.reply !== undefined),
|
|
68
|
+
(t) => t.skimmed === true
|
|
69
|
+
),
|
|
70
|
+
correctionRate: shareOf(turns, (t) => t.corrected === true),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -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,131 @@
|
|
|
1
|
+
import type { LoggedEvent } from "../../../hooks/lib/events";
|
|
2
|
+
import { apiPriceOf } from "../cost-report";
|
|
3
|
+
import { dayOf, daysBetween, 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
|
+
function best(candidates: Best[]): Best | null {
|
|
21
|
+
return candidates.reduce<Best | null>(
|
|
22
|
+
(top, c) => (top === null || c.value > top.value ? c : top),
|
|
23
|
+
null
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function countBy(events: LoggedEvent[], keyOf: (e: LoggedEvent) => string): Best[] {
|
|
28
|
+
const counts = new Map<string, number>();
|
|
29
|
+
for (const e of events) counts.set(keyOf(e), (counts.get(keyOf(e)) ?? 0) + 1);
|
|
30
|
+
return [...counts].map(([label, value]) => ({ label, value }));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function longestProject(events: LoggedEvent[]): Best | null {
|
|
34
|
+
const daysOf = new Map<string, string[]>();
|
|
35
|
+
for (const e of events) {
|
|
36
|
+
if (e.project === null) continue;
|
|
37
|
+
daysOf.set(e.project, [...(daysOf.get(e.project) ?? []), dayOf(e)]);
|
|
38
|
+
}
|
|
39
|
+
return best(
|
|
40
|
+
[...daysOf].map(([project, days]) => {
|
|
41
|
+
const [first, ...rest] = days.sort();
|
|
42
|
+
return { label: project, value: daysBetween(first, rest.at(-1) ?? first) + 1 };
|
|
43
|
+
})
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function longestStreak(events: LoggedEvent[]): Best | null {
|
|
48
|
+
const days = [...new Set(events.filter(isUserActivity).map(dayOf))].sort();
|
|
49
|
+
const runs: Best[] = [];
|
|
50
|
+
let start = 0;
|
|
51
|
+
for (let i = 1; i <= days.length; i++) {
|
|
52
|
+
if (i < days.length && daysBetween(days[i - 1], days[i]) === 1) continue;
|
|
53
|
+
runs.push({ value: i - start, label: `${days[start]} → ${days[i - 1]}` });
|
|
54
|
+
start = i;
|
|
55
|
+
}
|
|
56
|
+
return best(runs);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function longestAgentRun(events: LoggedEvent[]): Best | null {
|
|
60
|
+
return best(
|
|
61
|
+
ofType(events, "reply").flatMap((e) => {
|
|
62
|
+
const sec = numberOf(e.latencySec);
|
|
63
|
+
return sec === null ? [] : [{ value: sec, label: dayOf(e) }];
|
|
64
|
+
})
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface TurnSpan {
|
|
69
|
+
turn: LoggedEvent;
|
|
70
|
+
inside: LoggedEvent[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Each user turn and what happened in its session until the next one. */
|
|
74
|
+
function turnSpans(events: LoggedEvent[]): TurnSpan[] {
|
|
75
|
+
const open = new Map<string, TurnSpan>();
|
|
76
|
+
const spans: TurnSpan[] = [];
|
|
77
|
+
const inTimeOrder = [...events].sort((a, b) => a.ts.localeCompare(b.ts));
|
|
78
|
+
for (const e of inTimeOrder) {
|
|
79
|
+
if (e.session === null) continue;
|
|
80
|
+
if (e.type === "turn") {
|
|
81
|
+
const span = { turn: e, inside: [] };
|
|
82
|
+
open.set(e.session, span);
|
|
83
|
+
spans.push(span);
|
|
84
|
+
} else open.get(e.session)?.inside.push(e);
|
|
85
|
+
}
|
|
86
|
+
return spans;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const whereAndWhen = (turn: LoggedEvent) =>
|
|
90
|
+
turn.project === null ? dayOf(turn) : `${dayOf(turn)}, ${turn.project}`;
|
|
91
|
+
|
|
92
|
+
/** A turn with any call PAL cannot price is left out rather than undercounted. */
|
|
93
|
+
function turnCost({ inside }: TurnSpan): number | null {
|
|
94
|
+
const prices = ofType(inside, "model_call").map(apiPriceOf);
|
|
95
|
+
if (prices.length === 0 || prices.includes(null)) return null;
|
|
96
|
+
return prices.reduce<number>((sum, p) => sum + (p ?? 0), 0);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function mostExpensiveTurn(spans: TurnSpan[]): Best | null {
|
|
100
|
+
return best(
|
|
101
|
+
spans.flatMap((span) => {
|
|
102
|
+
const cost = turnCost(span);
|
|
103
|
+
return cost === null ? [] : [{ value: cost, label: whereAndWhen(span.turn) }];
|
|
104
|
+
})
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function biggestToolBurst(spans: TurnSpan[]): Best | null {
|
|
109
|
+
return best(
|
|
110
|
+
spans
|
|
111
|
+
.map((span) => ({
|
|
112
|
+
value: ofType(span.inside, "tool_call").length,
|
|
113
|
+
label: whereAndWhen(span.turn),
|
|
114
|
+
}))
|
|
115
|
+
.filter((b) => b.value > 0)
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function wrappedRecords(events: LoggedEvent[]): WrappedRecords {
|
|
120
|
+
const registered = ofType(events, "project").filter((e) => e.change === "registered");
|
|
121
|
+
const spans = turnSpans(events);
|
|
122
|
+
return {
|
|
123
|
+
projectsRegisteredInADay: best(countBy(registered, dayOf)),
|
|
124
|
+
commitsInADay: best(countBy(ofType(events, "commit"), dayOf)),
|
|
125
|
+
longestProject: longestProject(events),
|
|
126
|
+
longestStreak: longestStreak(events),
|
|
127
|
+
longestAgentRun: longestAgentRun(events),
|
|
128
|
+
mostExpensiveTurn: mostExpensiveTurn(spans),
|
|
129
|
+
biggestToolBurst: biggestToolBurst(spans),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { labelOfRuntime } from "../../../hooks/lib/agent-registry";
|
|
2
|
+
import type { CostTotals } from "../cost-report";
|
|
3
|
+
import { count, days, row, tallyLabel } from "../report-rows";
|
|
4
|
+
import { fmtDuration } from "../session-usage";
|
|
5
|
+
import { fmtCost } from "../token-report";
|
|
6
|
+
import type { RuntimeCost, WrappedAgents } from "./agents";
|
|
7
|
+
import type { WrappedHabits } from "./habits";
|
|
8
|
+
import type { SkillsNeverUsed, WrappedPal } from "./pal";
|
|
9
|
+
import type { Best, WrappedRecords } from "./records";
|
|
10
|
+
import type { WrappedTotals } from "./totals";
|
|
11
|
+
import type { Tally } from "./year";
|
|
12
|
+
|
|
13
|
+
function recordRow(
|
|
14
|
+
label: string,
|
|
15
|
+
record: Best | null,
|
|
16
|
+
format: (value: number) => string
|
|
17
|
+
): string {
|
|
18
|
+
const value =
|
|
19
|
+
record === null ? "unknown" : `${format(record.value)} (${record.label})`;
|
|
20
|
+
return ` ${label.padEnd(36)} ${value}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function recordsLines(records: WrappedRecords): string[] {
|
|
24
|
+
return [
|
|
25
|
+
"\n Records\n",
|
|
26
|
+
recordRow(
|
|
27
|
+
"most projects registered in a day",
|
|
28
|
+
records.projectsRegisteredInADay,
|
|
29
|
+
count
|
|
30
|
+
),
|
|
31
|
+
recordRow("most commits in a day", records.commitsInADay, count),
|
|
32
|
+
recordRow("longest project", records.longestProject, days),
|
|
33
|
+
recordRow("longest streak", records.longestStreak, days),
|
|
34
|
+
recordRow("longest agent-alone run", records.longestAgentRun, (sec) =>
|
|
35
|
+
fmtDuration(sec * 1000)
|
|
36
|
+
),
|
|
37
|
+
recordRow("most expensive turn", records.mostExpensiveTurn, fmtCost),
|
|
38
|
+
recordRow(
|
|
39
|
+
"biggest tool burst",
|
|
40
|
+
records.biggestToolBurst,
|
|
41
|
+
(n) => `${count(n)} tool calls`
|
|
42
|
+
),
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const percent = (share: number | null) =>
|
|
47
|
+
share === null ? "unknown" : `${Math.round(share * 100)}%`;
|
|
48
|
+
|
|
49
|
+
const hours = (n: number) => `${count(n)} active ${n === 1 ? "hour" : "hours"}`;
|
|
50
|
+
|
|
51
|
+
function busiestLabel(best: Best | null, unit: (n: number) => string): string {
|
|
52
|
+
return best === null ? "unknown" : `${best.label} (${unit(best.value)})`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function habitsLines(habits: WrappedHabits): string[] {
|
|
56
|
+
return [
|
|
57
|
+
"\n Habits\n",
|
|
58
|
+
row(
|
|
59
|
+
"most active hour",
|
|
60
|
+
busiestLabel(habits.mostActiveHour, (n) => `active on ${days(n)}`)
|
|
61
|
+
),
|
|
62
|
+
row("most active weekday", busiestLabel(habits.mostActiveWeekday, hours)),
|
|
63
|
+
row("most active month", busiestLabel(habits.mostActiveMonth, hours)),
|
|
64
|
+
row("at night (22-06)", percent(habits.nightShare)),
|
|
65
|
+
row("on weekends", percent(habits.weekendShare)),
|
|
66
|
+
row("messages by channel", tallyLabel(habits.channels)),
|
|
67
|
+
row("skimmed replies", percent(habits.skimRate)),
|
|
68
|
+
row("corrections", percent(habits.correctionRate)),
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function neverUsedLines(never: SkillsNeverUsed | null): string[] {
|
|
73
|
+
if (never === null) return [row("skills never used", "unknown")];
|
|
74
|
+
const summary = `${count(never.names.length)} of ${count(never.installed)} since ${never.since}`;
|
|
75
|
+
const names = never.names.length > 0 ? [row("", never.names.join(", "))] : [];
|
|
76
|
+
return [row("skills never used", summary), ...names];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Nothing logs a steering rule being approved yet. */
|
|
80
|
+
export function palLines(pal: WrappedPal): string[] {
|
|
81
|
+
const onTime = pal.promisesOnTime;
|
|
82
|
+
return [
|
|
83
|
+
"\n PAL\n",
|
|
84
|
+
row("skills used", tallyLabel(pal.skillsUsed)),
|
|
85
|
+
...neverUsedLines(pal.skillsNeverUsed),
|
|
86
|
+
row("rules approved", "unknown"),
|
|
87
|
+
row(
|
|
88
|
+
"promises on time",
|
|
89
|
+
onTime === null ? "unknown" : `${count(onTime)} of ${count(pal.promisesKept)}`
|
|
90
|
+
),
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function tokensLabel(totals: WrappedTotals): string {
|
|
95
|
+
const unknown = totals.callsWithoutTokens;
|
|
96
|
+
return unknown > 0
|
|
97
|
+
? `${count(totals.tokens)} + ${count(unknown)} calls without counts`
|
|
98
|
+
: count(totals.tokens);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function apiEquivalentLabel(cost: CostTotals): string {
|
|
102
|
+
const priced = fmtCost(cost.apiEquivalent);
|
|
103
|
+
return cost.unpriced > 0
|
|
104
|
+
? `${priced} + ${count(cost.unpriced)} unpriced calls`
|
|
105
|
+
: priced;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const paidLabel = (paid: number | null) => (paid === null ? "unknown" : fmtCost(paid));
|
|
109
|
+
|
|
110
|
+
function runtimeCostLabel({ runtime, calls, cost }: RuntimeCost): string {
|
|
111
|
+
const api = cost.unpriced === calls ? "unknown" : apiEquivalentLabel(cost);
|
|
112
|
+
return `${labelOfRuntime(runtime)} API-equivalent ${api}, paid ${paidLabel(cost.paid)}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const labelled = (tallies: Tally[]) =>
|
|
116
|
+
tallies.map((t) => ({ ...t, name: labelOfRuntime(t.name) }));
|
|
117
|
+
|
|
118
|
+
/** No agent reports which of Cursor's lines a human typed yet. */
|
|
119
|
+
export function agentsLines(agents: WrappedAgents): string[] {
|
|
120
|
+
const [first, ...rest] = agents.costs.map(runtimeCostLabel);
|
|
121
|
+
return [
|
|
122
|
+
"\n Agents\n",
|
|
123
|
+
row("messages by agent", tallyLabel(labelled(agents.messages))),
|
|
124
|
+
row("cost by agent", first ?? "unknown"),
|
|
125
|
+
...rest.map((label) => row("", label)),
|
|
126
|
+
row("Cursor human vs AI", "unknown"),
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function totalsLines(totals: WrappedTotals): string[] {
|
|
131
|
+
return [
|
|
132
|
+
"\n Totals\n",
|
|
133
|
+
row("projects", count(totals.projects)),
|
|
134
|
+
row("sessions", count(totals.sessions)),
|
|
135
|
+
row("commits", count(totals.commits)),
|
|
136
|
+
row("tokens", tokensLabel(totals)),
|
|
137
|
+
row("API-equivalent", apiEquivalentLabel(totals.cost)),
|
|
138
|
+
row("paid", paidLabel(totals.cost.paid)),
|
|
139
|
+
];
|
|
140
|
+
}
|
|
@@ -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,51 @@
|
|
|
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
|
+
const DAY_MS = 86_400_000;
|
|
13
|
+
|
|
14
|
+
export function daysBetween(from: string, to: string): number {
|
|
15
|
+
return Math.round((Date.parse(to) - Date.parse(from)) / DAY_MS);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function ofType(events: LoggedEvent[], type: EventType): LoggedEvent[] {
|
|
19
|
+
return events.filter((e) => e.type === type);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const isUserActivity = (e: LoggedEvent) =>
|
|
23
|
+
e.type === "commit" ||
|
|
24
|
+
e.type === "turn" ||
|
|
25
|
+
(e.type === "model_call" && e.entrypoint !== "sdk-cli");
|
|
26
|
+
|
|
27
|
+
export interface Tally {
|
|
28
|
+
name: string;
|
|
29
|
+
count: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Most frequent first, ties by name. */
|
|
33
|
+
export function tally<T>(items: T[], nameOf: (item: T) => string): Tally[] {
|
|
34
|
+
const counts = new Map<string, number>();
|
|
35
|
+
for (const item of items) {
|
|
36
|
+
const name = nameOf(item);
|
|
37
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
38
|
+
}
|
|
39
|
+
return [...counts]
|
|
40
|
+
.map(([name, count]) => ({ name, count }))
|
|
41
|
+
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function numberOf(value: unknown): number | null {
|
|
45
|
+
return typeof value === "number" ? value : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function eventsOfYear(year: number): LoggedEvent[] {
|
|
49
|
+
const dayBeforeInUtc = new Date(Date.UTC(year - 1, 11, 31));
|
|
50
|
+
return allEventsSince(dayBeforeInUtc).filter((e) => dayOf(e).startsWith(`${year}-`));
|
|
51
|
+
}
|