portable-agent-layer 0.101.0 → 0.103.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 +1 -0
- package/package.json +1 -1
- package/src/cli/cost.ts +23 -3
- package/src/cli/patterns.ts +31 -0
- package/src/cli/tree.ts +2 -0
- package/src/hooks/LoadContext.ts +6 -12
- package/src/hooks/lib/context-overhead.ts +30 -0
- package/src/hooks/lib/events.ts +1 -0
- package/src/hooks/lib/semi-static.ts +1 -1
- package/src/hooks/lib/session-context.ts +10 -0
- package/src/hooks/lib/session-events.ts +1 -1
- package/src/tools/lib/cost/efficiency.ts +58 -0
- package/src/tools/lib/cost/leaderboards.ts +81 -0
- package/src/tools/lib/cost/mix.ts +50 -0
- package/src/tools/lib/cost/overhead.ts +29 -0
- package/src/tools/lib/cost/render.ts +70 -0
- 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 +17 -0
- package/src/tools/lib/wrapped/habits.ts +15 -8
- package/src/tools/lib/wrapped/records.ts +5 -11
- package/src/tools/lib/wrapped/render.ts +8 -13
- package/src/tools/lib/wrapped/year.ts +6 -0
package/README.md
CHANGED
|
@@ -87,6 +87,7 @@ pal cli status # check your setup
|
|
|
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
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 |
|
|
90
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 |
|
|
91
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 |
|
|
92
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,11 +1,31 @@
|
|
|
1
1
|
import { labelOfRuntime } from "../hooks/lib/agent-registry";
|
|
2
|
-
import {
|
|
2
|
+
import { allEventsSince } from "../hooks/lib/events";
|
|
3
3
|
import { leaf } from "../tools/lib/command";
|
|
4
|
+
import { efficiency } from "../tools/lib/cost/efficiency";
|
|
5
|
+
import { leaderboards } from "../tools/lib/cost/leaderboards";
|
|
6
|
+
import { modelMix, palSpend } from "../tools/lib/cost/mix";
|
|
7
|
+
import { contextOverhead } from "../tools/lib/cost/overhead";
|
|
8
|
+
import {
|
|
9
|
+
efficiencyLines,
|
|
10
|
+
leaderboardLines,
|
|
11
|
+
overheadLines,
|
|
12
|
+
shareLines,
|
|
13
|
+
} from "../tools/lib/cost/render";
|
|
4
14
|
import { costLines, costMonths } from "../tools/lib/cost-report";
|
|
15
|
+
import { ofType } from "../tools/lib/wrapped/year";
|
|
5
16
|
|
|
6
17
|
function printCost(): undefined {
|
|
7
|
-
const
|
|
8
|
-
|
|
18
|
+
const events = allEventsSince(new Date(0));
|
|
19
|
+
const months = costMonths(ofType(events, "model_call"));
|
|
20
|
+
const lines = [
|
|
21
|
+
...costLines(months, labelOfRuntime),
|
|
22
|
+
...leaderboardLines(leaderboards(events)),
|
|
23
|
+
...efficiencyLines(efficiency(events)),
|
|
24
|
+
...shareLines("Model mix", modelMix(events)),
|
|
25
|
+
...shareLines("PAL's own spend per caller, already in the totals", palSpend(events)),
|
|
26
|
+
...overheadLines(contextOverhead(events)),
|
|
27
|
+
];
|
|
28
|
+
for (const line of lines) console.log(line);
|
|
9
29
|
}
|
|
10
30
|
|
|
11
31
|
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,6 +22,7 @@ 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";
|
|
@@ -157,6 +158,7 @@ export function cliTree(admin: AdminHandlers): Group {
|
|
|
157
158
|
usage: usageCommand,
|
|
158
159
|
cost: costCommand,
|
|
159
160
|
wrapped: wrappedCommand,
|
|
161
|
+
patterns: patternsCommand,
|
|
160
162
|
content: contentCommand,
|
|
161
163
|
events: eventsCommand,
|
|
162
164
|
infer: inferCommand,
|
package/src/hooks/LoadContext.ts
CHANGED
|
@@ -8,23 +8,21 @@
|
|
|
8
8
|
* Which agent gets what, in which envelope, is in lib/session-context.ts.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
12
|
-
import { resolve } from "node:path";
|
|
13
11
|
import { getActiveAgent } from "./lib/agent";
|
|
14
12
|
import { autoUpdateOnStart } from "./lib/auto-update";
|
|
15
13
|
import { buildClaudeMd, regenerateIfNeeded } from "./lib/claude-md";
|
|
16
14
|
import { type AgentTarget, buildSystemReminder } from "./lib/context";
|
|
15
|
+
import { logContextInjected } from "./lib/context-overhead";
|
|
17
16
|
import { duplicatesCursorHooks } from "./lib/cursor-shadow";
|
|
18
17
|
import { logContextSnapshot, logDebug, logError } from "./lib/log";
|
|
19
|
-
import { platform } from "./lib/paths";
|
|
20
18
|
import {
|
|
21
19
|
contextEnvelope,
|
|
22
20
|
contextForSource,
|
|
23
|
-
copilotInstructions,
|
|
24
21
|
isSubagentSession,
|
|
25
22
|
needsAgentsMd,
|
|
23
|
+
writeCopilotInstructions,
|
|
26
24
|
} from "./lib/session-context";
|
|
27
|
-
import { logSessionStart } from "./lib/session-events";
|
|
25
|
+
import { logSessionStart, type SessionStartPayload } from "./lib/session-events";
|
|
28
26
|
import { isPalSpawnedInference } from "./lib/spawn-guard";
|
|
29
27
|
import { readStdinJSON } from "./lib/stdin";
|
|
30
28
|
|
|
@@ -49,7 +47,7 @@ try {
|
|
|
49
47
|
|
|
50
48
|
try {
|
|
51
49
|
const active = getActiveAgent();
|
|
52
|
-
const input = await readStdinJSON<
|
|
50
|
+
const input = await readStdinJSON<SessionStartPayload>();
|
|
53
51
|
logSessionStart(input);
|
|
54
52
|
const wanted = contextForSource(active, input?.source);
|
|
55
53
|
if (wanted === "none") {
|
|
@@ -75,15 +73,11 @@ try {
|
|
|
75
73
|
);
|
|
76
74
|
if (!envelope) process.exit(0);
|
|
77
75
|
|
|
78
|
-
if (envelope.file)
|
|
79
|
-
const dir = resolve(platform.copilotDir(), "instructions");
|
|
80
|
-
mkdirSync(dir, { recursive: true });
|
|
81
|
-
const path = resolve(dir, "pal-session.instructions.md");
|
|
82
|
-
writeFileSync(path, copilotInstructions(envelope.file), "utf-8");
|
|
83
|
-
}
|
|
76
|
+
if (envelope.file) writeCopilotInstructions(envelope.file);
|
|
84
77
|
|
|
85
78
|
if (envelope.kind === "text") console.log(envelope.payload);
|
|
86
79
|
else process.stdout.write(envelope.payload);
|
|
80
|
+
logContextInjected(input, reminder);
|
|
87
81
|
logDebug("LoadContext", `Reminder injected: ${reminder.length} chars`);
|
|
88
82
|
} catch (err) {
|
|
89
83
|
logError("LoadContext:reminder", err);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { buildClaudeMd } from "./claude-md";
|
|
2
|
+
import { appendEvent, logQuietly } from "./events";
|
|
3
|
+
import { type HookTurnPayload, hookSessionId } from "./hook-turn";
|
|
4
|
+
import { getSemiStaticSources, readFileSafe } from "./semi-static";
|
|
5
|
+
|
|
6
|
+
function semiStaticChars(): number {
|
|
7
|
+
return getSemiStaticSources().reduce(
|
|
8
|
+
(sum, source) => sum + readFileSafe(source.path).length,
|
|
9
|
+
0
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Chars, not tokens: no tokenizer runs at session start. */
|
|
14
|
+
export function logContextInjected(
|
|
15
|
+
payload: HookTurnPayload | null,
|
|
16
|
+
reminder: string,
|
|
17
|
+
now: Date = new Date()
|
|
18
|
+
): void {
|
|
19
|
+
logQuietly("context_injected", () =>
|
|
20
|
+
appendEvent(
|
|
21
|
+
"context_injected",
|
|
22
|
+
{
|
|
23
|
+
instructionsChars: buildClaudeMd().length,
|
|
24
|
+
semiStaticChars: semiStaticChars(),
|
|
25
|
+
dynamicChars: reminder.length,
|
|
26
|
+
},
|
|
27
|
+
{ ts: now.toISOString(), session: hookSessionId(payload) ?? null }
|
|
28
|
+
)
|
|
29
|
+
);
|
|
30
|
+
}
|
package/src/hooks/lib/events.ts
CHANGED
|
@@ -54,6 +54,7 @@ export const EVENT_FIELDS = {
|
|
|
54
54
|
],
|
|
55
55
|
session_end: ["reason", "durationSec", "turns", "messages", "status"],
|
|
56
56
|
compaction: ["trigger", "withInstructions", "contextTokens"],
|
|
57
|
+
context_injected: ["instructionsChars", "semiStaticChars", "dynamicChars"],
|
|
57
58
|
hook_timing: ["hook", "totalMs", "handlers"],
|
|
58
59
|
inference_call: [
|
|
59
60
|
"caller",
|
|
@@ -48,7 +48,7 @@ export function antigravityRule(description: string, content: string): string {
|
|
|
48
48
|
return `---\ntrigger: always_on\ndescription: ${description}\n---\n\n${content}`;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
function readFileSafe(path: string): string {
|
|
51
|
+
export function readFileSafe(path: string): string {
|
|
52
52
|
try {
|
|
53
53
|
if (!existsSync(path)) return "";
|
|
54
54
|
return readFileSync(path, "utf-8").trim();
|
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
* context for weeks — and none of it was reachable from a test.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { resolve } from "node:path";
|
|
10
12
|
import type { AgentName } from "./agent-registry";
|
|
13
|
+
import { platform } from "./paths";
|
|
11
14
|
|
|
12
15
|
export interface ContextEnvelope {
|
|
13
16
|
/** Whether the agent parses stdout as JSON or reads it as raw text. */
|
|
@@ -96,3 +99,10 @@ export function contextEnvelope(
|
|
|
96
99
|
export function copilotInstructions(context: string): string {
|
|
97
100
|
return `---\napplyTo: "**"\n---\n\n${context}`;
|
|
98
101
|
}
|
|
102
|
+
|
|
103
|
+
export function writeCopilotInstructions(context: string): void {
|
|
104
|
+
const dir = resolve(platform.copilotDir(), "instructions");
|
|
105
|
+
mkdirSync(dir, { recursive: true });
|
|
106
|
+
const path = resolve(dir, "pal-session.instructions.md");
|
|
107
|
+
writeFileSync(path, copilotInstructions(context), "utf-8");
|
|
108
|
+
}
|
|
@@ -20,7 +20,7 @@ const SESSION_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
20
20
|
const TRANSCRIPT_TAIL_BYTES = 256 * 1024;
|
|
21
21
|
const MAX_FIELD = 64;
|
|
22
22
|
|
|
23
|
-
interface SessionStartPayload extends HookTurnPayload {
|
|
23
|
+
export interface SessionStartPayload extends HookTurnPayload {
|
|
24
24
|
source?: string;
|
|
25
25
|
model?: unknown;
|
|
26
26
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { LoggedEvent } from "../../../hooks/lib/events";
|
|
2
|
+
import { apiPriceOf } from "../cost-report";
|
|
3
|
+
import { dayOf, numberOf, ofType } from "../wrapped/year";
|
|
4
|
+
|
|
5
|
+
export interface PerUnit {
|
|
6
|
+
cost: number;
|
|
7
|
+
units: number;
|
|
8
|
+
lowerBound: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Efficiency {
|
|
12
|
+
since: string;
|
|
13
|
+
perCommit: PerUnit | null;
|
|
14
|
+
perClosedIsc: PerUnit | null;
|
|
15
|
+
cacheHitRate: number | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const PROMPT_FIELDS = [
|
|
19
|
+
"inputTokens",
|
|
20
|
+
"cacheRead",
|
|
21
|
+
"cacheWrite5m",
|
|
22
|
+
"cacheWrite1h",
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
function cacheHitRate(calls: LoggedEvent[]): number | null {
|
|
26
|
+
let read = 0;
|
|
27
|
+
let prompt = 0;
|
|
28
|
+
for (const call of calls) {
|
|
29
|
+
for (const field of PROMPT_FIELDS) prompt += numberOf(call[field]) ?? 0;
|
|
30
|
+
read += numberOf(call.cacheRead) ?? 0;
|
|
31
|
+
}
|
|
32
|
+
return prompt === 0 ? null : read / prompt;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function perUnit(spent: number, unpriced: number, units: number): PerUnit | null {
|
|
36
|
+
return units === 0 ? null : { cost: spent / units, units, lowerBound: unpriced > 0 };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Only since the first logged model call: older commits and ISCs cost nothing PAL saw. */
|
|
40
|
+
export function efficiency(events: LoggedEvent[]): Efficiency | null {
|
|
41
|
+
const calls = ofType(events, "model_call");
|
|
42
|
+
const [first] = [...calls].sort((a, b) => a.ts.localeCompare(b.ts));
|
|
43
|
+
if (first === undefined) return null;
|
|
44
|
+
const since = (e: LoggedEvent) => e.ts >= first.ts;
|
|
45
|
+
const prices = calls.map(apiPriceOf);
|
|
46
|
+
const spent = prices.reduce<number>((sum, p) => sum + (p ?? 0), 0);
|
|
47
|
+
const unpriced = prices.filter((p) => p === null).length;
|
|
48
|
+
const commits = ofType(events, "commit").filter(since).length;
|
|
49
|
+
const closed = ofType(events, "commitment").filter(
|
|
50
|
+
(e) => since(e) && e.outcome === "kept"
|
|
51
|
+
).length;
|
|
52
|
+
return {
|
|
53
|
+
since: dayOf(first),
|
|
54
|
+
perCommit: perUnit(spent, unpriced, commits),
|
|
55
|
+
perClosedIsc: perUnit(spent, unpriced, closed),
|
|
56
|
+
cacheHitRate: cacheHitRate(calls),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { LoggedEvent } from "../../../hooks/lib/events";
|
|
2
|
+
import { apiPriceOf } from "../cost-report";
|
|
3
|
+
import { turnCost, turnSpans, whereAndWhen } from "../wrapped/records";
|
|
4
|
+
import { dayOf, ofType, tally } from "../wrapped/year";
|
|
5
|
+
|
|
6
|
+
export interface Entry {
|
|
7
|
+
label: string;
|
|
8
|
+
cost: number;
|
|
9
|
+
unpriced: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Leaderboards {
|
|
13
|
+
projects: Entry[];
|
|
14
|
+
sessions: Entry[];
|
|
15
|
+
days: Entry[];
|
|
16
|
+
turns: Entry[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const TOP = 5;
|
|
20
|
+
|
|
21
|
+
const mostExpensive = (entries: Entry[]) =>
|
|
22
|
+
entries.sort((a, b) => b.cost - a.cost || a.label.localeCompare(b.label)).slice(0, TOP);
|
|
23
|
+
|
|
24
|
+
function board(calls: LoggedEvent[], labelOf: (call: LoggedEvent) => string): Entry[] {
|
|
25
|
+
const entries = new Map<string, Entry>();
|
|
26
|
+
for (const call of calls) {
|
|
27
|
+
const label = labelOf(call);
|
|
28
|
+
const entry = entries.get(label) ?? { label, cost: 0, unpriced: 0 };
|
|
29
|
+
entries.set(label, entry);
|
|
30
|
+
const price = apiPriceOf(call);
|
|
31
|
+
if (price === null) entry.unpriced++;
|
|
32
|
+
else entry.cost += price;
|
|
33
|
+
}
|
|
34
|
+
return mostExpensive([...entries.values()]);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const projectOf = (call: LoggedEvent) => call.project ?? "no project";
|
|
38
|
+
|
|
39
|
+
function sessionLabel(session: string | null, own: LoggedEvent[]): string {
|
|
40
|
+
const [project] = tally(
|
|
41
|
+
own.filter((call) => call.project !== null),
|
|
42
|
+
projectOf
|
|
43
|
+
);
|
|
44
|
+
const [first] = own.sort((a, b) => a.ts.localeCompare(b.ts));
|
|
45
|
+
const id = session?.slice(0, 8) ?? "no session";
|
|
46
|
+
return `${id} (${project?.name ?? "no project"}, ${dayOf(first)})`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A session is named by the project most of its calls ran in, and the day it started. */
|
|
50
|
+
function sessionLabels(calls: LoggedEvent[]): Map<string | null, string> {
|
|
51
|
+
const bySession = new Map<string | null, LoggedEvent[]>();
|
|
52
|
+
for (const call of calls) {
|
|
53
|
+
const own = bySession.get(call.session) ?? [];
|
|
54
|
+
bySession.set(call.session, own);
|
|
55
|
+
own.push(call);
|
|
56
|
+
}
|
|
57
|
+
return new Map(
|
|
58
|
+
[...bySession].map(([session, own]) => [session, sessionLabel(session, own)])
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function turnBoard(events: LoggedEvent[]): Entry[] {
|
|
63
|
+
return mostExpensive(
|
|
64
|
+
turnSpans(events).flatMap((span) => {
|
|
65
|
+
const cost = turnCost(span);
|
|
66
|
+
return cost === null ? [] : [{ label: whereAndWhen(span.turn), cost, unpriced: 0 }];
|
|
67
|
+
})
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Every model call counts, PAL's own spawns included; its inference_call twins never do. */
|
|
72
|
+
export function leaderboards(events: LoggedEvent[]): Leaderboards {
|
|
73
|
+
const calls = ofType(events, "model_call");
|
|
74
|
+
const sessions = sessionLabels(calls);
|
|
75
|
+
return {
|
|
76
|
+
projects: board(calls, projectOf),
|
|
77
|
+
sessions: board(calls, (call) => String(sessions.get(call.session))),
|
|
78
|
+
days: board(calls, dayOf),
|
|
79
|
+
turns: turnBoard(events),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { LoggedEvent } from "../../../hooks/lib/events";
|
|
2
|
+
import { apiPriceOf } from "../cost-report";
|
|
3
|
+
import { numberOf, ofType } from "../wrapped/year";
|
|
4
|
+
|
|
5
|
+
export interface Share {
|
|
6
|
+
label: string;
|
|
7
|
+
calls: number;
|
|
8
|
+
cost: number;
|
|
9
|
+
unpriced: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function shares(
|
|
13
|
+
calls: LoggedEvent[],
|
|
14
|
+
labelOf: (call: LoggedEvent) => string,
|
|
15
|
+
priceOf: (call: LoggedEvent) => number | null
|
|
16
|
+
): Share[] {
|
|
17
|
+
const byLabel = new Map<string, Share>();
|
|
18
|
+
for (const call of calls) {
|
|
19
|
+
const label = labelOf(call);
|
|
20
|
+
const share = byLabel.get(label) ?? { label, calls: 0, cost: 0, unpriced: 0 };
|
|
21
|
+
byLabel.set(label, share);
|
|
22
|
+
share.calls++;
|
|
23
|
+
const price = priceOf(call);
|
|
24
|
+
if (price === null) share.unpriced++;
|
|
25
|
+
else share.cost += price;
|
|
26
|
+
}
|
|
27
|
+
return [...byLabel.values()].sort(
|
|
28
|
+
(a, b) => b.cost - a.cost || b.calls - a.calls || a.label.localeCompare(b.label)
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const textOr = (value: unknown, fallback: string) =>
|
|
33
|
+
typeof value === "string" ? value : fallback;
|
|
34
|
+
|
|
35
|
+
export function modelMix(events: LoggedEvent[]): Share[] {
|
|
36
|
+
return shares(
|
|
37
|
+
ofType(events, "model_call"),
|
|
38
|
+
(call) => textOr(call.model, "unknown model"),
|
|
39
|
+
apiPriceOf
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Already inside the model calls PAL spawned; never add it to a total. */
|
|
44
|
+
export function palSpend(events: LoggedEvent[]): Share[] {
|
|
45
|
+
return shares(
|
|
46
|
+
ofType(events, "inference_call"),
|
|
47
|
+
(call) => textOr(call.caller, "unknown caller"),
|
|
48
|
+
(call) => numberOf(call.costUsd)
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { LoggedEvent } from "../../../hooks/lib/events";
|
|
2
|
+
import { dayOf, numberOf, ofType } from "../wrapped/year";
|
|
3
|
+
|
|
4
|
+
type Tier = "instructionsChars" | "semiStaticChars" | "dynamicChars";
|
|
5
|
+
|
|
6
|
+
export interface Overhead {
|
|
7
|
+
since: string;
|
|
8
|
+
sessions: number;
|
|
9
|
+
perSession: Record<Tier, number>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A compacted session is injected again; its injections add up. */
|
|
13
|
+
export function contextOverhead(events: LoggedEvent[]): Overhead | null {
|
|
14
|
+
const injections = ofType(events, "context_injected");
|
|
15
|
+
const [first] = [...injections].sort((a, b) => a.ts.localeCompare(b.ts));
|
|
16
|
+
if (first === undefined) return null;
|
|
17
|
+
const sessions = new Set(injections.map((e) => e.session ?? e.id)).size;
|
|
18
|
+
const average = (tier: Tier) =>
|
|
19
|
+
injections.reduce((sum, e) => sum + (numberOf(e[tier]) ?? 0), 0) / sessions;
|
|
20
|
+
return {
|
|
21
|
+
since: dayOf(first),
|
|
22
|
+
sessions,
|
|
23
|
+
perSession: {
|
|
24
|
+
instructionsChars: average("instructionsChars"),
|
|
25
|
+
semiStaticChars: average("semiStaticChars"),
|
|
26
|
+
dynamicChars: average("dynamicChars"),
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { count, counted } from "../report-rows";
|
|
2
|
+
import { fmtCost } from "../token-report";
|
|
3
|
+
import type { Efficiency, PerUnit } from "./efficiency";
|
|
4
|
+
import type { Entry, Leaderboards } from "./leaderboards";
|
|
5
|
+
import type { Share } from "./mix";
|
|
6
|
+
import type { Overhead } from "./overhead";
|
|
7
|
+
|
|
8
|
+
function entryLine({ label, cost, unpriced }: Entry): string {
|
|
9
|
+
const extra = unpriced > 0 ? ` + ${count(unpriced)} unpriced calls` : "";
|
|
10
|
+
return ` ${fmtCost(cost).padStart(10)} ${label}${extra}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function boardLines(title: string, entries: Entry[]): string[] {
|
|
14
|
+
const lines = entries.length === 0 ? [" unknown"] : entries.map(entryLine);
|
|
15
|
+
return [`\n Most expensive ${title}\n`, ...lines];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function leaderboardLines(boards: Leaderboards): string[] {
|
|
19
|
+
return [
|
|
20
|
+
...boardLines("projects", boards.projects),
|
|
21
|
+
...boardLines("sessions", boards.sessions),
|
|
22
|
+
...boardLines("days", boards.days),
|
|
23
|
+
...boardLines("turns", boards.turns),
|
|
24
|
+
];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function perUnitText(per: PerUnit | null, unit: string): string {
|
|
28
|
+
if (per === null) return "unknown";
|
|
29
|
+
const atLeast = per.lowerBound ? "at least " : "";
|
|
30
|
+
return `${atLeast}${fmtCost(per.cost)} over ${counted(per.units, unit)}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const percent = (rate: number | null) =>
|
|
34
|
+
rate === null ? "unknown" : `${(rate * 100).toFixed(1)}%`;
|
|
35
|
+
|
|
36
|
+
export function efficiencyLines(efficiency: Efficiency | null): string[] {
|
|
37
|
+
if (efficiency === null) return [];
|
|
38
|
+
return [
|
|
39
|
+
`\n Since ${efficiency.since}\n`,
|
|
40
|
+
` Per commit ${perUnitText(efficiency.perCommit, "commit")}`,
|
|
41
|
+
` Per closed ISC ${perUnitText(efficiency.perClosedIsc, "ISC")}`,
|
|
42
|
+
` Cache hit rate ${percent(efficiency.cacheHitRate)}`,
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function shareLine({ label, calls, cost, unpriced }: Share): string {
|
|
47
|
+
const price = unpriced === calls ? "unknown" : fmtCost(cost);
|
|
48
|
+
const extra = unpriced > 0 && unpriced < calls ? `, ${count(unpriced)} unpriced` : "";
|
|
49
|
+
return ` ${price.padStart(10)} ${label} ${counted(calls, "call")}${extra}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const chars = (n: number) => `${count(Math.round(n))} chars`.padStart(14);
|
|
53
|
+
|
|
54
|
+
export function overheadLines(overhead: Overhead | null): string[] {
|
|
55
|
+
const title = "\n PAL context per session\n";
|
|
56
|
+
if (overhead === null) return [title, " unknown"];
|
|
57
|
+
const { instructionsChars, semiStaticChars, dynamicChars } = overhead.perSession;
|
|
58
|
+
return [
|
|
59
|
+
`\n PAL context per session, over ${counted(overhead.sessions, "session")} since ${overhead.since}\n`,
|
|
60
|
+
` Instructions ${chars(instructionsChars)}`,
|
|
61
|
+
` Semi-static ${chars(semiStaticChars)}`,
|
|
62
|
+
` Dynamic ${chars(dynamicChars)}`,
|
|
63
|
+
` Total ${chars(instructionsChars + semiStaticChars + dynamicChars)}`,
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function shareLines(title: string, shares: Share[]): string[] {
|
|
68
|
+
const lines = shares.length === 0 ? [" unknown"] : shares.map(shareLine);
|
|
69
|
+
return [`\n ${title}\n`, ...lines];
|
|
70
|
+
}
|
|
@@ -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,17 @@
|
|
|
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 counted = (n: number, noun: string) =>
|
|
15
|
+
`${count(n)} ${noun}${n === 1 ? "" : "s"}`;
|
|
16
|
+
|
|
17
|
+
export const days = (n: number) => counted(n, "day");
|
|
@@ -5,6 +5,8 @@ import { dayOf, isUserActivity, ofType, type Tally, tally } from "./year";
|
|
|
5
5
|
|
|
6
6
|
export interface WrappedHabits {
|
|
7
7
|
mostActiveHour: Best | null;
|
|
8
|
+
mostActiveWeekday: Best | null;
|
|
9
|
+
mostActiveMonth: Best | null;
|
|
8
10
|
nightShare: number | null;
|
|
9
11
|
weekendShare: number | null;
|
|
10
12
|
channels: Tally[];
|
|
@@ -13,6 +15,7 @@ export interface WrappedHabits {
|
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
interface HourSlot {
|
|
18
|
+
day: string;
|
|
16
19
|
hour: number;
|
|
17
20
|
weekday: string;
|
|
18
21
|
}
|
|
@@ -25,8 +28,9 @@ const WEEKEND = new Set(["Sat", "Sun"]);
|
|
|
25
28
|
function activeSlots(events: LoggedEvent[]): HourSlot[] {
|
|
26
29
|
const slots = new Map<string, HourSlot>();
|
|
27
30
|
for (const e of events.filter(isUserActivity)) {
|
|
31
|
+
const day = dayOf(e);
|
|
28
32
|
const clock = localClock(new Date(e.ts));
|
|
29
|
-
slots.set(`${
|
|
33
|
+
slots.set(`${day} ${clock.hour}`, { day, ...clock });
|
|
30
34
|
}
|
|
31
35
|
return [...slots.values()];
|
|
32
36
|
}
|
|
@@ -35,16 +39,17 @@ function shareOf<T>(items: T[], matches: (item: T) => boolean): number | null {
|
|
|
35
39
|
return items.length === 0 ? null : items.filter(matches).length / items.length;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return top ? { value: top[1], label: `${String(top[0]).padStart(2, "0")}:00` } : null;
|
|
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;
|
|
43
46
|
}
|
|
44
47
|
|
|
48
|
+
const hourOf = ({ hour }: HourSlot) => `${String(hour).padStart(2, "0")}:00`;
|
|
49
|
+
|
|
45
50
|
const isNight = ({ hour }: HourSlot) => hour >= NIGHT_FROM || hour < NIGHT_UNTIL;
|
|
46
51
|
|
|
47
|
-
const channelOf = (turn: LoggedEvent) =>
|
|
52
|
+
export const channelOf = (turn: LoggedEvent) =>
|
|
48
53
|
typeof turn.channel === "string" ? turn.channel : "terminal";
|
|
49
54
|
|
|
50
55
|
/** Skimming is only measured on a turn that answered a reply. */
|
|
@@ -52,7 +57,9 @@ export function wrappedHabits(events: LoggedEvent[]): WrappedHabits {
|
|
|
52
57
|
const slots = activeSlots(events);
|
|
53
58
|
const turns = ofType(events, "turn");
|
|
54
59
|
return {
|
|
55
|
-
mostActiveHour:
|
|
60
|
+
mostActiveHour: busiest(slots, hourOf),
|
|
61
|
+
mostActiveWeekday: busiest(slots, (s) => s.weekday),
|
|
62
|
+
mostActiveMonth: busiest(slots, (s) => s.day.slice(0, 7)),
|
|
56
63
|
nightShare: shareOf(slots, isNight),
|
|
57
64
|
weekendShare: shareOf(slots, (s) => WEEKEND.has(s.weekday)),
|
|
58
65
|
channels: tally(turns, channelOf),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { LoggedEvent } from "../../../hooks/lib/events";
|
|
2
2
|
import { apiPriceOf } from "../cost-report";
|
|
3
|
-
import { dayOf, isUserActivity, numberOf, ofType } from "./year";
|
|
3
|
+
import { dayOf, daysBetween, isUserActivity, numberOf, ofType } from "./year";
|
|
4
4
|
|
|
5
5
|
export interface Best {
|
|
6
6
|
value: number;
|
|
@@ -17,8 +17,6 @@ export interface WrappedRecords {
|
|
|
17
17
|
biggestToolBurst: Best | null;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
const DAY_MS = 86_400_000;
|
|
21
|
-
|
|
22
20
|
function best(candidates: Best[]): Best | null {
|
|
23
21
|
return candidates.reduce<Best | null>(
|
|
24
22
|
(top, c) => (top === null || c.value > top.value ? c : top),
|
|
@@ -32,10 +30,6 @@ function countBy(events: LoggedEvent[], keyOf: (e: LoggedEvent) => string): Best
|
|
|
32
30
|
return [...counts].map(([label, value]) => ({ label, value }));
|
|
33
31
|
}
|
|
34
32
|
|
|
35
|
-
function daysBetween(from: string, to: string): number {
|
|
36
|
-
return Math.round((Date.parse(to) - Date.parse(from)) / DAY_MS);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
33
|
function longestProject(events: LoggedEvent[]): Best | null {
|
|
40
34
|
const daysOf = new Map<string, string[]>();
|
|
41
35
|
for (const e of events) {
|
|
@@ -71,13 +65,13 @@ function longestAgentRun(events: LoggedEvent[]): Best | null {
|
|
|
71
65
|
);
|
|
72
66
|
}
|
|
73
67
|
|
|
74
|
-
interface TurnSpan {
|
|
68
|
+
export interface TurnSpan {
|
|
75
69
|
turn: LoggedEvent;
|
|
76
70
|
inside: LoggedEvent[];
|
|
77
71
|
}
|
|
78
72
|
|
|
79
73
|
/** Each user turn and what happened in its session until the next one. */
|
|
80
|
-
function turnSpans(events: LoggedEvent[]): TurnSpan[] {
|
|
74
|
+
export function turnSpans(events: LoggedEvent[]): TurnSpan[] {
|
|
81
75
|
const open = new Map<string, TurnSpan>();
|
|
82
76
|
const spans: TurnSpan[] = [];
|
|
83
77
|
const inTimeOrder = [...events].sort((a, b) => a.ts.localeCompare(b.ts));
|
|
@@ -92,11 +86,11 @@ function turnSpans(events: LoggedEvent[]): TurnSpan[] {
|
|
|
92
86
|
return spans;
|
|
93
87
|
}
|
|
94
88
|
|
|
95
|
-
const whereAndWhen = (turn: LoggedEvent) =>
|
|
89
|
+
export const whereAndWhen = (turn: LoggedEvent) =>
|
|
96
90
|
turn.project === null ? dayOf(turn) : `${dayOf(turn)}, ${turn.project}`;
|
|
97
91
|
|
|
98
92
|
/** A turn with any call PAL cannot price is left out rather than undercounted. */
|
|
99
|
-
function turnCost({ inside }: TurnSpan): number | null {
|
|
93
|
+
export function turnCost({ inside }: TurnSpan): number | null {
|
|
100
94
|
const prices = ofType(inside, "model_call").map(apiPriceOf);
|
|
101
95
|
if (prices.length === 0 || prices.includes(null)) return null;
|
|
102
96
|
return prices.reduce<number>((sum, p) => sum + (p ?? 0), 0);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { labelOfRuntime } from "../../../hooks/lib/agent-registry";
|
|
2
2
|
import type { CostTotals } from "../cost-report";
|
|
3
|
+
import { count, days, row, tallyLabel } from "../report-rows";
|
|
3
4
|
import { fmtDuration } from "../session-usage";
|
|
4
5
|
import { fmtCost } from "../token-report";
|
|
5
6
|
import type { RuntimeCost, WrappedAgents } from "./agents";
|
|
@@ -9,12 +10,6 @@ import type { Best, WrappedRecords } from "./records";
|
|
|
9
10
|
import type { WrappedTotals } from "./totals";
|
|
10
11
|
import type { Tally } from "./year";
|
|
11
12
|
|
|
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
13
|
function recordRow(
|
|
19
14
|
label: string,
|
|
20
15
|
record: Best | null,
|
|
@@ -25,8 +20,6 @@ function recordRow(
|
|
|
25
20
|
return ` ${label.padEnd(36)} ${value}`;
|
|
26
21
|
}
|
|
27
22
|
|
|
28
|
-
const days = (n: number) => `${count(n)} ${n === 1 ? "day" : "days"}`;
|
|
29
|
-
|
|
30
23
|
export function recordsLines(records: WrappedRecords): string[] {
|
|
31
24
|
return [
|
|
32
25
|
"\n Records\n",
|
|
@@ -53,19 +46,21 @@ export function recordsLines(records: WrappedRecords): string[] {
|
|
|
53
46
|
const percent = (share: number | null) =>
|
|
54
47
|
share === null ? "unknown" : `${Math.round(share * 100)}%`;
|
|
55
48
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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)})`;
|
|
59
53
|
}
|
|
60
54
|
|
|
61
55
|
export function habitsLines(habits: WrappedHabits): string[] {
|
|
62
|
-
const hour = habits.mostActiveHour;
|
|
63
56
|
return [
|
|
64
57
|
"\n Habits\n",
|
|
65
58
|
row(
|
|
66
59
|
"most active hour",
|
|
67
|
-
|
|
60
|
+
busiestLabel(habits.mostActiveHour, (n) => `active on ${days(n)}`)
|
|
68
61
|
),
|
|
62
|
+
row("most active weekday", busiestLabel(habits.mostActiveWeekday, hours)),
|
|
63
|
+
row("most active month", busiestLabel(habits.mostActiveMonth, hours)),
|
|
69
64
|
row("at night (22-06)", percent(habits.nightShare)),
|
|
70
65
|
row("on weekends", percent(habits.weekendShare)),
|
|
71
66
|
row("messages by channel", tallyLabel(habits.channels)),
|
|
@@ -9,6 +9,12 @@ export function dayOf(event: LoggedEvent): string {
|
|
|
9
9
|
return localDay(new Date(event.ts));
|
|
10
10
|
}
|
|
11
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
|
+
|
|
12
18
|
export function ofType(events: LoggedEvent[], type: EventType): LoggedEvent[] {
|
|
13
19
|
return events.filter((e) => e.type === type);
|
|
14
20
|
}
|