@ngockhoale/ukit 3.4.23 → 3.4.25

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/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 3.4.25 - 2026-10-08
6
+
7
+ **P0 hotfix: Stop hook no longer force-continues a run the user paused or cancelled.**
8
+ - `stop-coordinator`: a genuine user pause/cancel turn ("pause", "cancel", "tạm dừng", "dừng lại", ...) now outranks every Stop lane (completion gate, task watchdog, handoff cursor). The stop is released with a visible note and run state (RUN.md, tasks, branches, worktrees) is left untouched. The signal is read only from real user turns in the host transcript, never from agent text, tool output, sub-agent reports or hook feedback; a later resume turn wins; an unreadable or over-budget scan means "no interruption", so enforcement is never relaxed by a lost read.
9
+ - Handoff cursor reentrancy valve: when `stop_hook_active` is set and the same cursor signature repeats, the stop is released once with an advisory instead of waiting for the stalled-block cap. A cursor that moved keeps blocking.
10
+ - Regression tests: `tests/hooks/stopHookInterruption.test.js` (15 cases).
11
+ - Documentation registry: `docs/analysis/SUBAGENT-COST-AUDIT.md` (C99) is now classified in `manifests/documentation.yaml`; 3.4.24's consistency suite flagged it as an unregistered/orphan doc.
12
+ - Not included: no change to evidence/ledger accounting or model routing.
13
+
14
+ ## 3.4.24 - 2026-10-08
15
+
16
+ **Honest per-category token usage record + report** (C98; capability only — no producer exists yet, so real stores report `no_data`).
17
+ - Schema v1.3 (additive): `model.completed`/`model.failed` payloads may now carry `usage_categories` — the five typed `{value, source}` resources `fresh_input_tokens`, `cache_read_tokens`, `cache_write_tokens`, `output_tokens`, `reasoning_tokens`. Absent categories stay `UNKNOWN`/null — never 0, never estimated. The registry stays append-only; old records remain valid.
18
+ - `routeAdapter` maps provider-reported categories when a producer supplies `usage.categories`; numeric values are accepted only from `PROVIDER` sources, and the legacy aggregate-token mapping is byte-unchanged.
19
+ - New read-only `ukit telemetry usage [--json]`: scans the segment store non-destructively (corrupt segments are skipped and reported, never quarantined by a read), prints a readiness verdict `no_data | aggregate_only | categorized`, per-source totals only (never combined), record counts (not unique runs), and labels `cache.hit/miss` as UKit-internal lookups, not a provider prompt cache. Exit codes: 0 normal, 1 usage error, 2 typed store failure. Creates no files.
20
+ - `readSegments(..., {readOnly: true})` new option: default quarantine behavior is byte-for-byte unchanged.
21
+ - Privacy: the five category keys + `usage_categories` joined the support-projection allowlist and `REDACTION_VERSION` bumped to `df-redact-7` (existing support bundles invalidate once). Counts and source tags only — never content.
22
+ - Not included: a producer that actually records provider token categories (queued Q-C98-1/Q-C98-2 — needs evidence the host exposes usage), any cost/price model or provider identity fields (Q-C98-3). No token-savings or cost claims are made or implied.
23
+
5
24
  ## 3.4.23 - 2026-10-08
6
25
 
7
26
  **Core hook/gate reliability repairs** (C97; each backed by baseline RED, GREEN and independent different-model review).
@@ -405,6 +405,17 @@ entries:
405
405
  load_policy: never
406
406
  validation: [none]
407
407
  archive_policy: immutable
408
+ - id: docs-analysis-subagent-cost-audit
409
+ path: docs/analysis/SUBAGENT-COST-AUDIT.md
410
+ class: archive
411
+ audience: [maintainer]
412
+ owner: product
413
+ source_of_truth: docs/analysis/SUBAGENT-COST-AUDIT.md
414
+ merge_strategy: none
415
+ load_policy: never
416
+ validation: [none]
417
+ archive_policy: immutable
418
+ notes: C99 measurement-only sub-agent cost audit snapshot; registered in 3.4.25 (was unclassified on 3.4.24)
408
419
  - id: docs-skill-audits
409
420
  path: docs/skill-audits/
410
421
  class: archive
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.23",
3
+ "version": "3.4.25",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,107 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { loadCorpus } from './reader.mjs';
5
+ import { computeMetrics } from './metrics.mjs';
6
+ import { computeDuplicates } from './duplicates.mjs';
7
+ import { renderMarkdown } from './render.mjs';
8
+
9
+ const MISSING = [
10
+ 'Provider/gateway id is not recorded in transcript usage.',
11
+ 'Native request latency is unavailable; timestamp gaps are only approximate.',
12
+ 'Retry counts are not recorded as a distinct telemetry field.',
13
+ 'Per-sub-agent request-shape cost is unavailable.',
14
+ 'Price table is absent; currency cost cannot be calculated.',
15
+ 'Codex and omp transcripts are not included by this Claude Code reader.',
16
+ 'Observability token categories are unavailable.',
17
+ ];
18
+
19
+ export async function buildReport(root, opts = {}) {
20
+ const corpus = await loadCorpus(root, { since: opts.since ?? null, until: opts.until ?? null });
21
+ // Wave-1 reader predates the normalized call.depth field; derive it without
22
+ // changing that shared module, while honoring depth when it is already present.
23
+ const depthByAgent = new Map(corpus.agents.map((agent) => [`${agent.sessionId}\u0000${agent.agentId}`, agent.depth]));
24
+ for (const call of corpus.calls) {
25
+ if (call.depth == null) call.depth = call.agentId == null ? 0 : (depthByAgent.get(`${call.sessionId}\u0000${call.agentId}`) ?? 1);
26
+ }
27
+ const metrics = computeMetrics(corpus);
28
+ const duplicates = computeDuplicates(corpus);
29
+ const headline = {
30
+ subShare: metrics.share.subOfInputSide,
31
+ subUncachedShare: metrics.share.subOfUncached,
32
+ contextAmplification: duplicates.amplification,
33
+ duplicateContextRatio: duplicates.duplicateRatio,
34
+ avgCallsPerTask: metrics.perTask.avgCalls,
35
+ };
36
+ return {
37
+ schema: 'subagent-cost/v1',
38
+ generatedAt: new Date().toISOString(),
39
+ root: path.basename(path.resolve(root)),
40
+ window: { since: opts.since ?? null, until: opts.until ?? null },
41
+ stats: corpus.stats,
42
+ headline,
43
+ metrics,
44
+ duplicates,
45
+ missing: [...MISSING],
46
+ };
47
+ }
48
+
49
+ function parseArgs(args) {
50
+ const opts = {};
51
+ const allowed = new Set(['--root', '--since', '--until', '--json', '--md']);
52
+ for (let i = 0; i < args.length; i += 1) {
53
+ const flag = args[i];
54
+ if (!allowed.has(flag)) throw new Error(`bad flag: ${flag}`);
55
+ const value = args[i + 1];
56
+ if (!value || value.startsWith('--')) throw new Error(`missing value for ${flag}`);
57
+ if (opts[flag]) throw new Error(`duplicate flag: ${flag}`);
58
+ opts[flag] = value;
59
+ i += 1;
60
+ }
61
+ if (!opts['--root']) throw new Error('missing required --root');
62
+ for (const flag of ['--since', '--until']) {
63
+ if (opts[flag] && !Number.isFinite(Date.parse(opts[flag]))) throw new Error(`invalid ISO date for ${flag}: ${opts[flag]}`);
64
+ }
65
+ if (opts['--since'] && opts['--until'] && Date.parse(opts['--since']) > Date.parse(opts['--until'])) throw new Error('--since must be <= --until');
66
+ return opts;
67
+ }
68
+
69
+ async function main(args) {
70
+ let opts;
71
+ try {
72
+ opts = parseArgs(args);
73
+ } catch (error) {
74
+ process.stderr.write(`${error.message}\n`);
75
+ return 2;
76
+ }
77
+ let report;
78
+ try {
79
+ report = await buildReport(opts['--root'], { since: opts['--since'] ?? null, until: opts['--until'] ?? null });
80
+ } catch (error) {
81
+ process.stderr.write(`${error.message}\n`);
82
+ return 2;
83
+ }
84
+ const markdown = renderMarkdown(report);
85
+ try {
86
+ if (opts['--json']) {
87
+ fs.mkdirSync(path.dirname(path.resolve(opts['--json'])), { recursive: true });
88
+ fs.writeFileSync(opts['--json'], `${JSON.stringify(report, null, 2)}\n`);
89
+ }
90
+ if (opts['--md']) {
91
+ fs.mkdirSync(path.dirname(path.resolve(opts['--md'])), { recursive: true });
92
+ fs.writeFileSync(opts['--md'], markdown);
93
+ }
94
+ } catch (error) {
95
+ process.stderr.write(`${error.message}\n`);
96
+ return 2;
97
+ }
98
+ process.stdout.write(`subagent-cost: ${report.metrics.totals.main.calls + report.metrics.totals.sub.calls} calls, ${report.stats.files} files, headline subShare=${report.headline.subShare}\n`);
99
+ if (opts['--json']) process.stdout.write(`json: ${path.resolve(opts['--json'])}\n`);
100
+ if (opts['--md']) process.stdout.write(`md: ${path.resolve(opts['--md'])}\n`);
101
+ return 0;
102
+ }
103
+
104
+ const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : null;
105
+ if (invokedPath === fileURLToPath(import.meta.url)) {
106
+ process.exitCode = await main(process.argv.slice(2));
107
+ }
@@ -0,0 +1,91 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { realpathSync } from 'node:fs';
3
+ import { pathToFileURL } from 'node:url';
4
+
5
+ const HEADLINE_KEYS = ['subShare', 'subUncachedShare', 'contextAmplification', 'duplicateContextRatio', 'avgCallsPerTask'];
6
+ const MARKER = /<!--\s*HEADLINE\s+(\{[^\n]*\})\s*-->/;
7
+
8
+ function parseArgs(args) {
9
+ const options = {};
10
+ for (let i = 0; i < args.length; i += 1) {
11
+ const flag = args[i];
12
+ if (!['--doc', '--report'].includes(flag) || !args[i + 1] || args[i + 1].startsWith('--')) {
13
+ throw new Error('usage: check-doc.mjs --doc <md> --report <json>');
14
+ }
15
+ if (options[flag]) throw new Error(`duplicate flag: ${flag}`);
16
+ options[flag] = args[++i];
17
+ }
18
+ if (!options['--doc'] || !options['--report']) throw new Error('usage: check-doc.mjs --doc <md> --report <json>');
19
+ return options;
20
+ }
21
+
22
+ async function checkDoc(docPath, reportPath) {
23
+ let doc;
24
+ let reportText;
25
+ try {
26
+ [doc, reportText] = await Promise.all([readFile(docPath, 'utf8'), readFile(reportPath, 'utf8')]);
27
+ } catch (error) {
28
+ console.error(`check-doc: ${error.message}`);
29
+ return 2;
30
+ }
31
+
32
+ let report;
33
+ try {
34
+ report = JSON.parse(reportText);
35
+ } catch {
36
+ console.error('check-doc: report is not valid JSON');
37
+ return 2;
38
+ }
39
+ const marker = doc.match(MARKER);
40
+ if (!marker) {
41
+ console.error('check-doc: missing HEADLINE marker');
42
+ return 2;
43
+ }
44
+ let embedded;
45
+ try {
46
+ embedded = JSON.parse(marker[1]);
47
+ } catch {
48
+ console.error('check-doc: invalid HEADLINE JSON');
49
+ return 2;
50
+ }
51
+
52
+ const mismatches = [];
53
+ const headline = report?.headline;
54
+ for (const key of HEADLINE_KEYS) {
55
+ const actual = embedded[key];
56
+ const expected = headline?.[key];
57
+ if (actual === null || expected === null) {
58
+ if (actual !== expected) mismatches.push(key);
59
+ } else if (typeof actual !== 'number' || !Number.isFinite(actual) || typeof expected !== 'number' || !Number.isFinite(expected) || Math.abs(actual - expected) > 0.001 * Math.max(1, Math.abs(actual), Math.abs(expected))) {
60
+ mismatches.push(key);
61
+ }
62
+ }
63
+
64
+ for (let section = 1; section <= 17; section += 1) {
65
+ if (!new RegExp(`^## ${section}\\.`, 'm').test(doc)) mismatches.push(`## ${section}.`);
66
+ }
67
+ for (let question = 1; question <= 16; question += 1) {
68
+ if (!new RegExp(`\\bQ${question}\\b`).test(doc)) mismatches.push(`Q${question}`);
69
+ }
70
+
71
+ if (mismatches.length) {
72
+ console.error(`check-doc: mismatch: ${[...new Set(mismatches)].join(', ')}`);
73
+ return 1;
74
+ }
75
+ console.log('check-doc: headline and required markers match');
76
+ return 0;
77
+ }
78
+
79
+ async function main() {
80
+ let options;
81
+ try {
82
+ options = parseArgs(process.argv.slice(2));
83
+ } catch (error) {
84
+ console.error(`check-doc: ${error.message}`);
85
+ process.exitCode = 2;
86
+ return;
87
+ }
88
+ process.exitCode = await checkDoc(options['--doc'], options['--report']);
89
+ }
90
+
91
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) await main();
@@ -0,0 +1,195 @@
1
+ const HEAVY_INPUT_SIDE_MIN = 250_000;
2
+ const HEAVY_OUTPUT_MAX = 2_000;
3
+ const MIN_HEAVY_CALLS_FOR_VERDICT = 10;
4
+ const TOP_LIMIT = 20;
5
+
6
+ const isFiniteNumber = Number.isFinite;
7
+ const inputSide = (call) => (isFiniteNumber(call.input) ? call.input : 0)
8
+ + (isFiniteNumber(call.cacheRead) ? call.cacheRead : 0)
9
+ + (isFiniteNumber(call.cacheWrite) ? call.cacheWrite : 0);
10
+ const ratio = (numerator, denominator) => denominator > 0 ? numerator / denominator : null;
11
+ const agentType = (agent) => agent.agentType ?? 'unknown';
12
+ const sortText = (a, b) => String(a) < String(b) ? -1 : (String(a) > String(b) ? 1 : 0);
13
+
14
+ function getAgents(corpus) {
15
+ return Array.isArray(corpus?.agents) ? corpus.agents.filter((agent) => agent && typeof agent === 'object') : [];
16
+ }
17
+
18
+ function getCalls(corpus) {
19
+ return Array.isArray(corpus?.calls) ? corpus.calls.filter((call) => call && typeof call === 'object') : [];
20
+ }
21
+
22
+ function collectSegments(agents) {
23
+ const byHash = new Map();
24
+ for (const agent of agents) {
25
+ if (!Array.isArray(agent.segments)) continue;
26
+ for (const segment of agent.segments) {
27
+ if (!segment || typeof segment !== 'object' || segment.h == null
28
+ || !isFiniteNumber(segment.t) || segment.t < 0) continue;
29
+ if (!byHash.has(segment.h)) byHash.set(segment.h, []);
30
+ byHash.get(segment.h).push({
31
+ h: segment.h,
32
+ t: segment.t,
33
+ turnId: agent.turnId,
34
+ agentId: agent.agentId,
35
+ agentType: agentType(agent),
36
+ });
37
+ }
38
+ }
39
+ return byHash;
40
+ }
41
+
42
+ function repeatedOccurrences(occurrences) {
43
+ // A segment can occur more than once inside an agent prompt. Repetition is
44
+ // measured between agents, so retain that agent's first occurrence only.
45
+ const firstByAgent = new Map();
46
+ for (const occurrence of occurrences) {
47
+ if (occurrence.agentId == null || firstByAgent.has(occurrence.agentId)) continue;
48
+ firstByAgent.set(occurrence.agentId, occurrence);
49
+ }
50
+ const distinctAgents = [...firstByAgent.values()];
51
+ if (distinctAgents.length < 2) return null;
52
+ return distinctAgents;
53
+ }
54
+
55
+ function duplicateBreakdown(occurrences) {
56
+ const agents = repeatedOccurrences(occurrences);
57
+ if (!agents) return null;
58
+
59
+ let withinTokens = 0;
60
+ let crossTokens = 0;
61
+ const first = agents[0];
62
+ for (const occurrence of agents.slice(1)) {
63
+ if (occurrence.turnId === first.turnId) withinTokens += occurrence.t;
64
+ else crossTokens += occurrence.t;
65
+ }
66
+ return {
67
+ withinTokens,
68
+ crossTokens,
69
+ count: agents.length,
70
+ displayTokens: first.t,
71
+ duplicateTokens: withinTokens + crossTokens,
72
+ };
73
+ }
74
+
75
+ function computeAmplification(corpus, agents, calls) {
76
+ const agentsById = new Map(agents.filter((agent) => agent.agentId != null)
77
+ .map((agent) => [agent.agentId, agent]));
78
+ const inputByType = new Map();
79
+ for (const call of calls) {
80
+ const agent = agentsById.get(call.agentId);
81
+ if (!agent || call.zero || (call.depth ?? agent.depth ?? 1) <= 0) continue;
82
+ const type = agentType(agent);
83
+ inputByType.set(type, (inputByType.get(type) ?? 0) + inputSide(call));
84
+ }
85
+
86
+ const promptsByType = new Map();
87
+ for (const agent of agents) {
88
+ if (agent.agentId == null || (agent.depth ?? 1) <= 0
89
+ || !isFiniteNumber(agent.promptTokensEst) || agent.promptTokensEst < 0) continue;
90
+ const type = agentType(agent);
91
+ promptsByType.set(type, (promptsByType.get(type) ?? 0) + agent.promptTokensEst);
92
+ }
93
+
94
+ const inputTotal = [...inputByType.values()].reduce((sum, value) => sum + value, 0);
95
+ const promptTotal = [...promptsByType.values()].reduce((sum, value) => sum + value, 0);
96
+ const types = [...new Set([...inputByType.keys(), ...promptsByType.keys()])].sort(sortText);
97
+ return {
98
+ overall: promptTotal === 0 ? null : inputTotal / promptTotal,
99
+ byType: Object.fromEntries(types.map((type) => [
100
+ type,
101
+ (promptsByType.get(type) ?? 0) === 0 ? null : (inputByType.get(type) ?? 0) / promptsByType.get(type),
102
+ ])),
103
+ };
104
+ }
105
+
106
+ function summarizeSegments(byHash) {
107
+ let totalTokens = 0;
108
+ let duplicateTokens = 0;
109
+ let withinTokens = 0;
110
+ let crossTokens = 0;
111
+ const repeated = [];
112
+
113
+ for (const [hash, occurrences] of byHash) {
114
+ totalTokens += occurrences.reduce((sum, item) => sum + item.t, 0);
115
+ const breakdown = duplicateBreakdown(occurrences);
116
+ if (!breakdown) continue;
117
+ duplicateTokens += breakdown.duplicateTokens;
118
+ withinTokens += breakdown.withinTokens;
119
+ crossTokens += breakdown.crossTokens;
120
+ repeated.push({
121
+ h: hash,
122
+ t: breakdown.displayTokens,
123
+ count: breakdown.count,
124
+ agentTypes: [...new Set(occurrences.filter((item) => item.agentId != null)
125
+ .map((item) => item.agentType))].sort(sortText),
126
+ duplicateTokens: breakdown.duplicateTokens,
127
+ });
128
+ }
129
+
130
+ repeated.sort((a, b) => b.duplicateTokens - a.duplicateTokens || sortText(a.h, b.h));
131
+ return {
132
+ totalTokens,
133
+ duplicateTokens,
134
+ withinTokens,
135
+ crossTokens,
136
+ topRepeatedSegments: repeated.slice(0, TOP_LIMIT).map(({ duplicateTokens: _tokens, ...segment }) => segment),
137
+ };
138
+ }
139
+
140
+ function computeReplayHypothesis(calls, agents, byHash) {
141
+ const agentsById = new Map(agents.filter((agent) => agent.agentId != null)
142
+ .map((agent) => [agent.agentId, agent]));
143
+ const heavyCalls = calls.filter((call) => {
144
+ const agent = agentsById.get(call.agentId);
145
+ if (!agent || call.zero || (call.depth ?? agent.depth ?? 1) <= 0) return false;
146
+ return inputSide(call) >= HEAVY_INPUT_SIDE_MIN
147
+ && isFiniteNumber(call.output) && call.output <= HEAVY_OUTPUT_MAX;
148
+ });
149
+ const heavyTurns = new Set(heavyCalls.map((call) => call.turnId).filter((turnId) => turnId != null));
150
+ const sharedHeavyTurns = new Set();
151
+
152
+ for (const occurrences of byHash.values()) {
153
+ const agentIdsByTurn = new Map();
154
+ for (const occurrence of repeatedOccurrences(occurrences) ?? []) {
155
+ if (occurrence.turnId == null || !heavyTurns.has(occurrence.turnId)) continue;
156
+ if (!agentIdsByTurn.has(occurrence.turnId)) agentIdsByTurn.set(occurrence.turnId, new Set());
157
+ agentIdsByTurn.get(occurrence.turnId).add(occurrence.agentId);
158
+ }
159
+ for (const [turnId, ids] of agentIdsByTurn) if (ids.size >= 2) sharedHeavyTurns.add(turnId);
160
+ }
161
+
162
+ return {
163
+ verdict: heavyCalls.length < MIN_HEAVY_CALLS_FOR_VERDICT
164
+ ? 'inconclusive'
165
+ : (sharedHeavyTurns.size > 0 ? 'supported' : 'not_supported'),
166
+ heavyCalls: heavyCalls.length,
167
+ heavyTurns: heavyTurns.size,
168
+ sharedHeavyTurns: sharedHeavyTurns.size,
169
+ share: ratio(sharedHeavyTurns.size, heavyTurns.size),
170
+ };
171
+ }
172
+
173
+ export function computeDuplicates(corpus) {
174
+ const agents = getAgents(corpus);
175
+ const calls = getCalls(corpus);
176
+ const byHash = collectSegments(agents);
177
+ const segments = summarizeSegments(byHash);
178
+ const amplification = computeAmplification(corpus, agents, calls);
179
+
180
+ return {
181
+ amplification: amplification.overall,
182
+ amplificationByType: amplification.byType,
183
+ duplicateRatio: ratio(segments.duplicateTokens, segments.totalTokens),
184
+ withinTurn: {
185
+ tokens: segments.withinTokens,
186
+ ratio: ratio(segments.withinTokens, segments.totalTokens),
187
+ },
188
+ crossTurn: {
189
+ tokens: segments.crossTokens,
190
+ ratio: ratio(segments.crossTokens, segments.totalTokens),
191
+ },
192
+ topRepeatedSegments: segments.topRepeatedSegments,
193
+ replayHypothesis: computeReplayHypothesis(calls, agents, byHash),
194
+ };
195
+ }
@@ -0,0 +1,181 @@
1
+ const N_PLUS_ONE_MIN_AGENTS = 3;
2
+ const N_PLUS_ONE_MAX_PROMPT_TOKENS = 2_000;
3
+ const RETRY_MIN_OCCURRENCES = 2;
4
+ const LOOP_MIN_OCCURRENCES = 5;
5
+ const HEAVY_INPUT_SIDE_MIN = 250_000;
6
+ const HEAVY_OUTPUT_MAX = 2_000;
7
+ const TOP_LIMIT = 20;
8
+
9
+ export function percentile(values, p) {
10
+ if (!Array.isArray(values) || values.length === 0) return null;
11
+ const sorted = values.filter(Number.isFinite).sort((a, b) => a - b);
12
+ if (sorted.length === 0) return null;
13
+ const rank = Math.max(1, Math.ceil(p * sorted.length));
14
+ return sorted[rank - 1];
15
+ }
16
+
17
+ const sum = (items, field) => items.reduce((total, item) => total + (Number.isFinite(item[field]) ? item[field] : 0), 0);
18
+ const ratio = (n, d) => d > 0 ? n / d : null;
19
+ const inputSide = (call) => (call.input ?? 0) + (call.cacheRead ?? 0) + (call.cacheWrite ?? 0);
20
+ const uncached = (call) => (call.input ?? 0) + (call.cacheWrite ?? 0);
21
+ const callTokens = (call) => inputSide(call) + (call.output ?? 0);
22
+ const validCalls = (corpus) => (corpus.calls ?? []).filter((call) => !call.zero);
23
+ const grouped = (items, keyFn) => {
24
+ const result = new Map();
25
+ for (const item of items) {
26
+ const key = keyFn(item);
27
+ if (key == null) continue;
28
+ if (!result.has(key)) result.set(key, []);
29
+ result.get(key).push(item);
30
+ }
31
+ return result;
32
+ };
33
+ const totalFor = (calls) => ({
34
+ calls: calls.length,
35
+ input: sum(calls, 'input'),
36
+ cacheRead: sum(calls, 'cacheRead'),
37
+ cacheWrite: sum(calls, 'cacheWrite'),
38
+ output: sum(calls, 'output'),
39
+ uncached: calls.reduce((n, c) => n + uncached(c), 0),
40
+ inputSide: calls.reduce((n, c) => n + inputSide(c), 0),
41
+ });
42
+ const cacheFor = (calls) => {
43
+ const totals = totalFor(calls);
44
+ return { hitRate: ratio(totals.cacheRead, totals.inputSide), cacheRead: totals.cacheRead, inputSide: totals.inputSide };
45
+ };
46
+ const sortedTop = (items, getValue, getKey) => [...items]
47
+ .map((item) => ({ ...item, uncached: getValue(item), inputSide: getKey(item) }))
48
+ .sort((a, b) => b.uncached - a.uncached || b.inputSide - a.inputSide || String(a.agentId ?? a.turnId).localeCompare(String(b.agentId ?? b.turnId)))
49
+ .slice(0, TOP_LIMIT);
50
+
51
+ function callPercentiles(calls, selector) {
52
+ return {
53
+ p50: percentile(calls.map(selector), 0.5),
54
+ p90: percentile(calls.map(selector), 0.9),
55
+ p99: percentile(calls.map(selector), 0.99),
56
+ };
57
+ }
58
+
59
+ function computeTasks(corpus, calls) {
60
+ const tasks = new Map();
61
+ for (const turn of corpus.turns ?? []) tasks.set(turn.turnId, { turnId: turn.turnId, calls: [] });
62
+ for (const call of calls) {
63
+ const key = call.turnId ?? `${call.sessionId}#unattributed`;
64
+ if (!tasks.has(key)) tasks.set(key, { turnId: key, calls: [] });
65
+ tasks.get(key).calls.push(call);
66
+ }
67
+ const rows = [...tasks.values()];
68
+ const subAgentCounts = rows.map((task) => new Set((corpus.agents ?? []).filter((a) => a.turnId === task.turnId).map((a) => a.agentId)).size);
69
+ return {
70
+ tasks: rows.length,
71
+ avgSubAgents: ratio(subAgentCounts.reduce((a, b) => a + b, 0), rows.length),
72
+ avgCalls: ratio(rows.reduce((n, task) => n + task.calls.length, 0), rows.length),
73
+ ...Object.fromEntries(['Calls', 'Tokens'].flatMap((label) => {
74
+ const values = rows.map((task) => label === 'Calls' ? task.calls.length : task.calls.reduce((n, c) => n + callTokens(c), 0));
75
+ return [[`p50${label}`, percentile(values, 0.5)], [`p90${label}`, percentile(values, 0.9)], [`p99${label}`, percentile(values, 0.99)]];
76
+ })),
77
+ };
78
+ }
79
+
80
+ export function computeMetrics(corpus) {
81
+ const calls = validCalls(corpus);
82
+ const mainCalls = calls.filter((call) => (call.depth ?? 0) === 0);
83
+ const subCalls = calls.filter((call) => (call.depth ?? 0) > 0);
84
+ const main = totalFor(mainCalls);
85
+ const sub = totalFor(subCalls);
86
+ const all = totalFor(calls);
87
+ const totalOutput = all.output;
88
+ const cacheByType = {};
89
+ const byType = {};
90
+ const typeCalls = grouped(subCalls, (call) => call.agentType ?? 'unknown');
91
+ for (const [type, typeGroup] of typeCalls) {
92
+ const agents = (corpus.agents ?? []).filter((agent) => (agent.agentType ?? 'unknown') === type);
93
+ const totals = totalFor(typeGroup);
94
+ const latencies = typeGroup.map((call) => call.latencyMs).filter(Number.isFinite);
95
+ const finalTokens = agents.map((agent) => agent.finalTokensEst).filter(Number.isFinite);
96
+ const refs = agents.map((agent) => agent.finalRefRatio).filter(Number.isFinite);
97
+ byType[type] = {
98
+ agents: new Set(agents.map((agent) => agent.agentId)).size,
99
+ calls: totals.calls,
100
+ inputSide: totals.inputSide,
101
+ uncached: totals.uncached,
102
+ output: totals.output,
103
+ avgCallsPerAgent: ratio(totals.calls, new Set(agents.map((agent) => agent.agentId)).size),
104
+ outputPerInput: ratio(totals.output, totals.inputSide),
105
+ avgFinalTokensEst: ratio(finalTokens.reduce((a, b) => a + b, 0), finalTokens.length),
106
+ avgFinalRefRatio: ratio(refs.reduce((a, b) => a + b, 0), refs.length),
107
+ latencyP50: percentile(latencies, 0.5),
108
+ latencyP90: percentile(latencies, 0.9),
109
+ valueProxy: { label: 'proxy; not a measure of true agent value', avgFinalTokensEst: ratio(finalTokens.reduce((a, b) => a + b, 0), finalTokens.length), avgFinalRefRatio: ratio(refs.reduce((a, b) => a + b, 0), refs.length), outputPerInput: ratio(totals.output, totals.inputSide) },
110
+ };
111
+ cacheByType[type] = cacheFor(typeGroup);
112
+ }
113
+ const depthCalls = grouped(calls, (call) => call.depth ?? 0);
114
+ const byDepth = Object.fromEntries([...depthCalls].map(([depth, group]) => [depth, totalFor(group)]));
115
+ const modelCalls = grouped(calls, (call) => call.model ?? 'unknown');
116
+ const byModel = Object.fromEntries([...modelCalls].map(([model, group]) => [model, totalFor(group)]));
117
+ const turnAgents = grouped(corpus.agents ?? [], (agent) => agent.turnId);
118
+ const fanoutCounts = [...turnAgents.values()].map((agents) => agents.length);
119
+ const fanoutHistogram = {};
120
+ for (const count of fanoutCounts) fanoutHistogram[count] = (fanoutHistogram[count] ?? 0) + 1;
121
+ const heavy = subCalls.filter((call) => inputSide(call) >= HEAVY_INPUT_SIDE_MIN && call.output <= HEAVY_OUTPUT_MAX);
122
+ const heavyByType = {};
123
+ for (const [type, group] of grouped(heavy, (call) => call.agentType ?? 'unknown')) heavyByType[type] = group.length;
124
+ const heavyBySession = {};
125
+ for (const [sessionId, group] of grouped(heavy, (call) => call.sessionId ?? 'unknown')) heavyBySession[sessionId] = group.length;
126
+ const turns = [...new Set([...calls.map((call) => call.turnId), ...(corpus.turns ?? []).map((turn) => turn.turnId)].filter(Boolean))];
127
+ const turnAgentsAll = grouped(corpus.agents ?? [], (agent) => agent.turnId);
128
+ const flagged = (predicate) => {
129
+ const ids = [];
130
+ for (const [turnId, agents] of turnAgentsAll) if (predicate(agents)) ids.push(turnId);
131
+ return { count: ids.length, turnIds: ids.slice(0, TOP_LIMIT) };
132
+ };
133
+ const nPlusOne = flagged((agents) => {
134
+ const sorted = [...agents].sort((a, b) => a.firstTs - b.firstTs);
135
+ let run = 0;
136
+ let previous = null;
137
+ for (const agent of sorted) {
138
+ const small = Number.isFinite(agent.promptTokensEst) && agent.promptTokensEst <= N_PLUS_ONE_MAX_PROMPT_TOKENS;
139
+ if (small && previous && previous.agentType === agent.agentType && agent.firstTs >= previous.lastTs) run += 1;
140
+ else run = small ? 1 : 0;
141
+ previous = agent;
142
+ if (run >= N_PLUS_ONE_MIN_AGENTS) return true;
143
+ }
144
+ return false;
145
+ });
146
+ const retryLoops = flagged((agents) => [...grouped(agents.filter((a) => a.promptHash != null), (a) => `${a.agentType}\0${a.promptHash}`).values()].some((group) => group.length >= RETRY_MIN_OCCURRENCES));
147
+ const loops = flagged((agents) => [...grouped(agents, (a) => a.agentType).values()].some((group) => group.length >= LOOP_MIN_OCCURRENCES));
148
+ const agentStats = (corpus.agents ?? []).map((agent) => {
149
+ const group = calls.filter((call) => call.agentId === agent.agentId);
150
+ return { agentId: agent.agentId, agentType: agent.agentType, turnId: agent.turnId, calls: group.length, uncached: group.reduce((n, c) => n + uncached(c), 0), inputSide: group.reduce((n, c) => n + inputSide(c), 0), output: sum(group, 'output') };
151
+ });
152
+ const turnStats = turns.map((turnId) => {
153
+ const group = calls.filter((call) => call.turnId === turnId);
154
+ return { turnId, calls: group.length, uncached: group.reduce((n, c) => n + uncached(c), 0), inputSide: group.reduce((n, c) => n + inputSide(c), 0), output: sum(group, 'output') };
155
+ });
156
+ const costliest = [...turnStats].sort((a, b) => b.uncached - a.uncached).slice(0, 5);
157
+ const tokenFlow = costliest.map(({ turnId }) => ({ turnId, calls: calls.filter((call) => call.turnId === turnId).sort((a, b) => a.ts - b.ts).map((call) => ({ ts: call.ts, agentType: call.agentType, depth: call.depth, input: call.input, cacheRead: call.cacheRead, cacheWrite: call.cacheWrite, output: call.output })) }));
158
+
159
+ return {
160
+ totals: { main, sub },
161
+ share: {
162
+ subOfInputSide: ratio(sub.inputSide, all.inputSide),
163
+ subOfUncached: ratio(sub.uncached, all.uncached),
164
+ subOfOutput: ratio(sub.output, totalOutput),
165
+ subOfCalls: ratio(sub.calls, all.calls),
166
+ },
167
+ cache: { main: cacheFor(mainCalls), sub: cacheFor(subCalls), byType: cacheByType },
168
+ perTask: computeTasks(corpus, calls),
169
+ byType,
170
+ byDepth,
171
+ byModel,
172
+ fanout: { histogram: fanoutHistogram, max: fanoutCounts.length ? Math.max(...fanoutCounts) : 0, p90: percentile(fanoutCounts, 0.9) },
173
+ heavyCalls: { count: heavy.length, share: ratio(heavy.length, subCalls.length), byType: heavyByType, bySession: heavyBySession },
174
+ nPlusOne,
175
+ retryLoops,
176
+ loops,
177
+ topAgents: sortedTop(agentStats, (agent) => agent.uncached, (agent) => agent.inputSide),
178
+ topTurns: sortedTop(turnStats, (turn) => turn.uncached, (turn) => turn.inputSide),
179
+ tokenFlow,
180
+ };
181
+ }