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