@ngockhoale/ukit 3.4.22 → 3.4.24
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 +25 -0
- package/manifests/platform.full.yaml +29 -0
- package/package.json +1 -1
- package/src/cli/commands/telemetry.js +67 -4
- package/src/cli/index.js +1 -1
- package/src/core/docContracts.js +60 -6
- package/src/core/observability/adapters/routeAdapter.js +48 -9
- package/src/core/observability/analytics/usageReadiness.js +342 -0
- package/src/core/observability/privacy/allowlist.js +10 -1
- package/src/core/observability/schema/constants.js +13 -0
- package/src/core/observability/schema/registry.js +20 -0
- package/src/core/observability/schema/validate.js +45 -2
- package/src/core/observability/segments/readSegments.js +23 -1
- package/template_project/.claude/agents/feature-implementer.md +10 -3
- package/template_project/.claude/commands/ukit/handoff-clear.md +27 -12
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +65 -32
- package/template_project/.claude/commands/ukit/handoff-implement.md +44 -23
- package/template_project/.claude/hooks/handoff-resume.sh +36 -11
- package/template_project/.claude/hooks/protect-files.sh +10 -2
- package/template_project/.claude/hooks/vision-router.sh +11 -1
- package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +418 -0
- package/template_project/.claude/ukit/index/extract-image.mjs +77 -22
- package/template_project/.claude/ukit/index/land-worktree.mjs +543 -0
- package/template_project/.claude/ukit/index/stale-spec-check.mjs +48 -1
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +86 -28
- package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +60 -0
- package/template_project/.claude/ukit/runtime/observability-emit.mjs +53 -2
- package/template_project/.claude/ukit/runtime/output-compression.mjs +46 -20
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +16 -2
- package/template_project/.omp/agents/feature-implementer.md +10 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 3.4.24 - 2026-10-08
|
|
6
|
+
|
|
7
|
+
**Honest per-category token usage record + report** (C98; capability only — no producer exists yet, so real stores report `no_data`).
|
|
8
|
+
- 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.
|
|
9
|
+
- `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.
|
|
10
|
+
- 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.
|
|
11
|
+
- `readSegments(..., {readOnly: true})` new option: default quarantine behavior is byte-for-byte unchanged.
|
|
12
|
+
- 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.
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
15
|
+
## 3.4.23 - 2026-10-08
|
|
16
|
+
|
|
17
|
+
**Core hook/gate reliability repairs** (C97; each backed by baseline RED, GREEN and independent different-model review).
|
|
18
|
+
- `stale-spec-check`: containment errors for external/traversal targets are caught at the checker boundary instead of crashing the hook chain.
|
|
19
|
+
- `hook-chain-runner`: bounded failure provenance and diagnostics while staying fail-closed.
|
|
20
|
+
- `docContracts`: `checkRenderVars` and `collectScannableDocs` prune isolated worktree checkouts; the walk skips `node_modules` and breaks symlink cycles by realpath (live-repo contract check 12.4s -> 0.3s).
|
|
21
|
+
- Handoff intent: exact command-suffix boundary, `handoff-implement` recognized, torn newest JSONL entry yields unknown instead of an older opposite intent; `handoff-create`-owned runs stay planning-only through Stop/resume.
|
|
22
|
+
- `protect-files`: backslash path spellings get the same protection as slash paths.
|
|
23
|
+
- `land-worktree`: complete base-relative copy-back (committed, staged, deleted, renamed, newline paths).
|
|
24
|
+
- `cleanup-task-worktree`: receipt-gated, recoverable cleanup with source/destination authority containment; no forced removal.
|
|
25
|
+
- Vision: already-pending unread image is idempotent; a corrupt pending marker is retained with a diagnostic.
|
|
26
|
+
- `output-compression`: a subagent's raw tool output no longer charges the parent's compact-pressure session (hard-cap gates unchanged).
|
|
27
|
+
- Not included: shell create-scope detection (TASK-C97-007) and decision-log lock reclaim (TASK-C97-013) failed final review and were reverted to the 3.4.22 behavior.
|
|
28
|
+
- Packed-artifact size cap raised 3.16 MB -> 3.17 MB.
|
|
29
|
+
|
|
5
30
|
## 3.4.22 - 2026-10-07
|
|
6
31
|
|
|
7
32
|
**Windows: `.sh` hooks no longer deny every Edit/Write/Bash.**
|
|
@@ -1653,6 +1653,35 @@ items:
|
|
|
1653
1653
|
packs:
|
|
1654
1654
|
- core
|
|
1655
1655
|
|
|
1656
|
+
# C97 TASK-C97-009 (MODULE04 FR-M4-004): the task-scoped landing helper that
|
|
1657
|
+
# replaces the unstaged-only cp loop in the fullstack/implement 3c samples.
|
|
1658
|
+
- id: ukit-index-land-worktree-script
|
|
1659
|
+
type: config
|
|
1660
|
+
sourceTemplate: .claude/ukit/index/land-worktree.mjs
|
|
1661
|
+
targetPath: .claude/ukit/index/land-worktree.mjs
|
|
1662
|
+
requires:
|
|
1663
|
+
- ukit-runtime-scripts
|
|
1664
|
+
mergeStrategy: overwrite_with_backup
|
|
1665
|
+
variables: []
|
|
1666
|
+
enabledByDefault: true
|
|
1667
|
+
packs:
|
|
1668
|
+
- core
|
|
1669
|
+
|
|
1670
|
+
# C97 TASK-C97-010 (MODULE04 FR-M4-003/003a): the receipt-gated recoverable
|
|
1671
|
+
# cleanup helper that replaces the force-remove/-D samples in the fullstack,
|
|
1672
|
+
# implement and clear command docs.
|
|
1673
|
+
- id: ukit-index-cleanup-task-worktree-script
|
|
1674
|
+
type: config
|
|
1675
|
+
sourceTemplate: .claude/ukit/index/cleanup-task-worktree.mjs
|
|
1676
|
+
targetPath: .claude/ukit/index/cleanup-task-worktree.mjs
|
|
1677
|
+
requires:
|
|
1678
|
+
- ukit-runtime-scripts
|
|
1679
|
+
mergeStrategy: overwrite_with_backup
|
|
1680
|
+
variables: []
|
|
1681
|
+
enabledByDefault: true
|
|
1682
|
+
packs:
|
|
1683
|
+
- core
|
|
1684
|
+
|
|
1656
1685
|
- id: ukit-index-stale-spec-check-script
|
|
1657
1686
|
type: config
|
|
1658
1687
|
sourceTemplate: .claude/ukit/index/stale-spec-check.mjs
|
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
// collect ingestStoredTelemetry → flush → maybeRefreshSupport (canary+)
|
|
5
5
|
// status [--json] stage, segments, counters, support lag, crashes
|
|
6
|
+
// usage [--json] read-only usage-readiness report over the segment store
|
|
6
7
|
// digest [--json] rebuildIndex → anomalies + digest markdown
|
|
7
8
|
// export-support projectSupport now (explicit user act — works below canary)
|
|
8
9
|
// import <path> validateSupportBundle on a received bundle
|
|
@@ -29,6 +30,10 @@ import { ingestStoredTelemetry } from '../../core/observability/adapters/ingest.
|
|
|
29
30
|
import { listSegments, totalSegmentBytes } from '../../core/observability/segments/internal.js';
|
|
30
31
|
import { readSegments } from '../../core/observability/segments/readSegments.js';
|
|
31
32
|
import { rebuildIndex } from '../../core/observability/analytics/rebuild.js';
|
|
33
|
+
import {
|
|
34
|
+
buildUsageReadiness,
|
|
35
|
+
renderUsageReadiness,
|
|
36
|
+
} from '../../core/observability/analytics/usageReadiness.js';
|
|
32
37
|
import { renderStoreDigest } from '../../core/observability/analytics/storeDigest.js';
|
|
33
38
|
import { renderTraceDigest } from '../../core/observability/analytics/digest.js';
|
|
34
39
|
import {
|
|
@@ -43,8 +48,8 @@ import { isValidOperationId } from '../../core/agentRuntime/eventStore.js';
|
|
|
43
48
|
import { runEvaluation } from '../../core/observability/evaluation/runner.js';
|
|
44
49
|
|
|
45
50
|
const HELP_FLAGS = new Set(['--help', '-h', 'help']);
|
|
46
|
-
const SUBCOMMANDS = new Set(['collect', 'status', 'digest', 'export-support', 'export-run', 'import', 'evaluate']);
|
|
47
|
-
const JSON_SUBCOMMANDS = new Set(['status', 'digest', 'evaluate']);
|
|
51
|
+
const SUBCOMMANDS = new Set(['collect', 'status', 'usage', 'digest', 'export-support', 'export-run', 'import', 'evaluate']);
|
|
52
|
+
const JSON_SUBCOMMANDS = new Set(['status', 'usage', 'digest', 'evaluate']);
|
|
48
53
|
|
|
49
54
|
// The automatic support refresh's canary gate lives in schedule.js via
|
|
50
55
|
// resolveSeamStages (projector seam minimum) — the CLI does not pre-gate.
|
|
@@ -61,6 +66,7 @@ function printUsage() {
|
|
|
61
66
|
console.log(' collect Ingest stored telemetry into the flight recorder,');
|
|
62
67
|
console.log(' flush it, and refresh the support view (stage canary+)');
|
|
63
68
|
console.log(' status Show stage, segments, counters, support lag, crashes');
|
|
69
|
+
console.log(' usage Read-only usage readiness report over the segment store');
|
|
64
70
|
console.log(' digest Rebuild the trace index; print anomalies + digest markdown');
|
|
65
71
|
console.log(' export-support Write the sanitized UKit Support bundle now');
|
|
66
72
|
console.log(' export-run <op> Write a sanitized per-run support bundle (V-06)');
|
|
@@ -68,7 +74,7 @@ function printUsage() {
|
|
|
68
74
|
console.log(' evaluate Run the AI evaluator lane (needs observability.evaluator config)');
|
|
69
75
|
console.log('');
|
|
70
76
|
console.log('Options:');
|
|
71
|
-
console.log(' --json JSON output (status, digest, evaluate)');
|
|
77
|
+
console.log(' --json JSON output (status, usage, digest, evaluate)');
|
|
72
78
|
console.log(' -h, --help Show this help');
|
|
73
79
|
}
|
|
74
80
|
|
|
@@ -337,6 +343,59 @@ async function digestCommand({ projectRoot, json }) {
|
|
|
337
343
|
console.log(out.join('\n'));
|
|
338
344
|
}
|
|
339
345
|
|
|
346
|
+
// --- usage --------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
// Scan cap (SPEC FR-07): at most 200,000 records feed the report; the reader
|
|
349
|
+
// limit is one higher so a 200001st record can mark the scan as truncated.
|
|
350
|
+
const USAGE_SCAN_LIMIT = 200_000;
|
|
351
|
+
|
|
352
|
+
// Read-only readiness scan (TASK-C98-006, SPEC §5 FR-07): never getRecorder,
|
|
353
|
+
// never a flush, never mkdir, never a quarantine — the store looks after
|
|
354
|
+
// `usage` exactly as it looked before. Dispatched before loadConfig so the
|
|
355
|
+
// report cannot create a runtime config either.
|
|
356
|
+
async function usageCommand({ projectRoot, json }) {
|
|
357
|
+
const root = segmentsRoot(projectRoot);
|
|
358
|
+
const stream = readSegments(root, { readOnly: true, limit: USAGE_SCAN_LIMIT + 1 });
|
|
359
|
+
const records = [];
|
|
360
|
+
let truncated = false;
|
|
361
|
+
for await (const record of stream) {
|
|
362
|
+
if (records.length === USAGE_SCAN_LIMIT) {
|
|
363
|
+
truncated = true;
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
records.push(record);
|
|
367
|
+
}
|
|
368
|
+
const report = buildUsageReadiness(records, {
|
|
369
|
+
coverage: stream.coverage,
|
|
370
|
+
truncated,
|
|
371
|
+
scanLimit: USAGE_SCAN_LIMIT,
|
|
372
|
+
});
|
|
373
|
+
if (json) console.log(JSON.stringify(report));
|
|
374
|
+
else console.log(renderUsageReadiness(report));
|
|
375
|
+
|
|
376
|
+
// Segment-level degradation stays exit 0 — the report itself declares the
|
|
377
|
+
// gap (unquarantined segments, degraded entries). Only a root-level degrade
|
|
378
|
+
// (the segments root itself unreadable, e.g. io_enotdir on a file at that
|
|
379
|
+
// path) is this command's typed failure; the resolveRoot io_enoent on a
|
|
380
|
+
// not-yet-existing tree is the missing-store success, not a failure
|
|
381
|
+
// (SPEC FR-07). resolveRoot reports the realpath'd root (macOS /var →
|
|
382
|
+
// /private/var), so compare against both spellings.
|
|
383
|
+
const degraded = Array.isArray(stream.coverage.degraded) ? stream.coverage.degraded : [];
|
|
384
|
+
let realRoot = root;
|
|
385
|
+
try {
|
|
386
|
+
realRoot = await fs.realpath(root);
|
|
387
|
+
} catch {
|
|
388
|
+
// Root absent (empty store) — the literal spelling is the only one.
|
|
389
|
+
}
|
|
390
|
+
const rootDegrade = degraded.find(
|
|
391
|
+
(e) => e && (e.file === root || e.file === realRoot) && e.reason !== 'io_enoent',
|
|
392
|
+
);
|
|
393
|
+
if (rootDegrade) {
|
|
394
|
+
console.error(`[UKit] telemetry usage failed: ${rootDegrade.reason}`);
|
|
395
|
+
process.exitCode = 2;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
340
399
|
// --- export-support / import --------------------------------------------------
|
|
341
400
|
|
|
342
401
|
async function exportSupportCommand({ projectRoot, config }) {
|
|
@@ -506,9 +565,13 @@ export async function runTelemetry({ projectRoot, packageRoot, argv = [] }) {
|
|
|
506
565
|
return;
|
|
507
566
|
}
|
|
508
567
|
|
|
509
|
-
const config = await loadConfig(projectRoot);
|
|
510
568
|
const json = flags.includes('--json');
|
|
511
569
|
|
|
570
|
+
// Read-only scan — config is not needed and must not be created (FR-07).
|
|
571
|
+
if (sub === 'usage') return usageCommand({ projectRoot, json });
|
|
572
|
+
|
|
573
|
+
const config = await loadConfig(projectRoot);
|
|
574
|
+
|
|
512
575
|
if (sub === 'collect') return collectCommand({ projectRoot, config });
|
|
513
576
|
if (sub === 'status') return statusCommand({ projectRoot, config, json });
|
|
514
577
|
if (sub === 'digest') return digestCommand({ projectRoot, json });
|
package/src/cli/index.js
CHANGED
|
@@ -141,7 +141,7 @@ export async function runCli({ argv, packageRoot, projectRoot, packageVersion })
|
|
|
141
141
|
console.log(' memory Inspect shared UKit memory');
|
|
142
142
|
console.log(' playbook List/show playbooks (project > user > builtin)');
|
|
143
143
|
console.log(' metrics Telemetry roll-up (route outcomes, failure patterns, memory)');
|
|
144
|
-
console.log(' telemetry Flight recorder (collect/status/digest/export-support/import/evaluate)');
|
|
144
|
+
console.log(' telemetry Flight recorder (collect/status/usage/digest/export-support/import/evaluate)');
|
|
145
145
|
console.log(' feedback Record or list wrong-route feedback labels');
|
|
146
146
|
console.log(' self-improve Run the automatic learn/tune/collect pass now (hooks run it)');
|
|
147
147
|
console.log(' vm Agent VM driver (list/show/status/shadow-run/evidence)');
|
package/src/core/docContracts.js
CHANGED
|
@@ -92,10 +92,27 @@ async function isDir(absPath) {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
// Recursive repo-relative file walk under rootDir/dirRel. `filter` selects
|
|
95
|
-
// files by extension etc.
|
|
96
|
-
|
|
95
|
+
// files by extension etc. `pruneDir(rel)` optionally excludes a directory
|
|
96
|
+
// subtree BEFORE descent (default: never prune — unrelated consumers keep the
|
|
97
|
+
// full walk). `dirIs` carries a per-top-level-walk set of descended directory
|
|
98
|
+
// `fs.realpath`s; because `fs.stat` follows symlinks, a symlink cycle (e.g.
|
|
99
|
+
// the live repo's `node_modules/node_modules -> node_modules`, or an
|
|
100
|
+
// exploration worktree symlinked at the repo root) would otherwise grow
|
|
101
|
+
// unbounded — the realpath guard makes every real directory traverse at most
|
|
102
|
+
// once. A directory named `node_modules` is never doc scope, so it is skipped
|
|
103
|
+
// by basename before its installed-tree contents are walked. Never throws
|
|
104
|
+
// (unreadable dirs are skipped).
|
|
105
|
+
async function walkFiles(rootDir, dirRel, filter = () => true, pruneDir = null, dirIs = null) {
|
|
106
|
+
const dirs = dirIs || new Set();
|
|
97
107
|
const out = [];
|
|
98
108
|
const dirAbs = dirRel ? path.join(rootDir, dirRel) : rootDir;
|
|
109
|
+
// Seed the starting directory so a child symlink pointing back at it (or at
|
|
110
|
+
// any ancestor of this walk) is recognized as a cycle immediately.
|
|
111
|
+
try {
|
|
112
|
+
dirs.add(await fs.realpath(dirAbs));
|
|
113
|
+
} catch {
|
|
114
|
+
/* realpath unavailable — descend without the cycle seed */
|
|
115
|
+
}
|
|
99
116
|
let names;
|
|
100
117
|
try {
|
|
101
118
|
names = await fs.readdir(dirAbs);
|
|
@@ -110,12 +127,36 @@ async function walkFiles(rootDir, dirRel, filter = () => true) {
|
|
|
110
127
|
} catch {
|
|
111
128
|
continue; // dangling symlink / unreadable
|
|
112
129
|
}
|
|
113
|
-
if (st.isDirectory())
|
|
114
|
-
|
|
130
|
+
if (st.isDirectory()) {
|
|
131
|
+
if (pruneDir && pruneDir(rel)) continue;
|
|
132
|
+
if (name === 'node_modules') continue; // never doc scope — skip the installed tree
|
|
133
|
+
let real;
|
|
134
|
+
try {
|
|
135
|
+
real = await fs.realpath(path.join(rootDir, rel));
|
|
136
|
+
} catch {
|
|
137
|
+
continue; // unreadable — nothing to descend
|
|
138
|
+
}
|
|
139
|
+
if (dirs.has(real)) continue; // symlink cycle / real directory already walked
|
|
140
|
+
dirs.add(real);
|
|
141
|
+
out.push(...(await walkFiles(rootDir, rel, filter, pruneDir, dirs)));
|
|
142
|
+
} else if (filter(name, rel)) out.push(rel);
|
|
115
143
|
}
|
|
116
144
|
return out;
|
|
117
145
|
}
|
|
118
146
|
|
|
147
|
+
// SPEC FR-004 (TASK-C97-003): true when `rel` is inside an isolated checkout
|
|
148
|
+
// worktree subtree — `.claude/worktrees/<agent>/` or `.worktrees/<task>/`.
|
|
149
|
+
// Exact path-COMPONENT comparison (not a string prefix), so ordinary lookalike
|
|
150
|
+
// directories such as `.claude/worktrees-not-isolated/` stay in scope.
|
|
151
|
+
function isIsolatedCheckoutSubtree(rel) {
|
|
152
|
+
const parts = rel.split('/');
|
|
153
|
+
for (let i = 0; i < parts.length; i++) {
|
|
154
|
+
if (parts[i] === '.worktrees') return true;
|
|
155
|
+
if (parts[i] === '.claude' && parts[i + 1] === 'worktrees') return true;
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
119
160
|
function makeCheck(id, label, severity, extra) {
|
|
120
161
|
return { id, label, severity, passed: false, ...extra };
|
|
121
162
|
}
|
|
@@ -314,8 +355,17 @@ async function checkRenderVars(rootDir, registry) {
|
|
|
314
355
|
if (typeof e.path !== 'string' || e.class !== 'derived' || e.path.startsWith('template_project/')) continue;
|
|
315
356
|
// Only .md doc surfaces are render targets — scripts/components/templates
|
|
316
357
|
// inside derived dirs legitimately use {{ }} as their own placeholders.
|
|
358
|
+
// FR-004: descent prunes exact isolated-checkout subtrees BEFORE walking
|
|
359
|
+
// (render-only prune; raw nested CHANGELOG/docs placeholders belong to
|
|
360
|
+
// exploration worktrees, not the current checkout). All other .claude
|
|
361
|
+
// content, lookalike dirs and root instructions stay enforced.
|
|
317
362
|
if (e.path.endsWith('/')) {
|
|
318
|
-
for (const f of await walkFiles(
|
|
363
|
+
for (const f of await walkFiles(
|
|
364
|
+
rootDir,
|
|
365
|
+
e.path.slice(0, -1),
|
|
366
|
+
(n) => n.endsWith('.md'),
|
|
367
|
+
isIsolatedCheckoutSubtree,
|
|
368
|
+
)) {
|
|
319
369
|
targets.add(f);
|
|
320
370
|
}
|
|
321
371
|
} else if (e.path.endsWith('.md')) {
|
|
@@ -444,7 +494,11 @@ export async function collectScannableDocs(rootDir, registry) {
|
|
|
444
494
|
if (typeof e.path === 'string' && e.path.endsWith('/')) dirs.add(e.path.slice(0, -1));
|
|
445
495
|
}
|
|
446
496
|
for (const dirRel of dirs) {
|
|
447
|
-
|
|
497
|
+
// FR-004 (TASK-C97-018): prune isolated-checkout subtrees before descent —
|
|
498
|
+
// exploration worktrees are not part of the current checkout, and their
|
|
499
|
+
// own node_modules symlink would otherwise drag the whole installed tree
|
|
500
|
+
// into the walk. `walkFiles` additionally breaks symlink cycles by realpath.
|
|
501
|
+
for (const f of await walkFiles(rootDir, dirRel, (n) => n.endsWith('.md'), isIsolatedCheckoutSubtree)) {
|
|
448
502
|
files.add(f);
|
|
449
503
|
}
|
|
450
504
|
}
|
|
@@ -29,9 +29,22 @@ import {
|
|
|
29
29
|
createAdapterContext,
|
|
30
30
|
emitRecord,
|
|
31
31
|
} from './common.js';
|
|
32
|
+
import { USAGE_CATEGORY_KEYS, USAGE_CATEGORIES_FIELD } from '../schema/constants.js';
|
|
32
33
|
|
|
33
34
|
const RESOURCE_SOURCES = new Set(['PROVIDER', 'LOCAL_TOKENIZER', 'ESTIMATED', 'UNKNOWN']);
|
|
34
35
|
|
|
36
|
+
// C98 FR-05 producer contract (SPEC §5): `ledger.model.usage.categories`
|
|
37
|
+
// carries camelCase counts normalized by the producer (freshInput/cacheRead/
|
|
38
|
+
// cacheWrite/output/reasoning); the adapter maps them to the schema category
|
|
39
|
+
// keys without interpreting provider-native names.
|
|
40
|
+
const USAGE_CATEGORY_INPUT_KEYS = Object.freeze({
|
|
41
|
+
freshInput: 'fresh_input_tokens',
|
|
42
|
+
cacheRead: 'cache_read_tokens',
|
|
43
|
+
cacheWrite: 'cache_write_tokens',
|
|
44
|
+
output: 'output_tokens',
|
|
45
|
+
reasoning: 'reasoning_tokens',
|
|
46
|
+
});
|
|
47
|
+
|
|
35
48
|
function normalizeRow(row) {
|
|
36
49
|
if (!isPlainObject(row)) return null;
|
|
37
50
|
const audit = isPlainObject(row.audit) ? row.audit : row;
|
|
@@ -79,6 +92,29 @@ function modelResource(model) {
|
|
|
79
92
|
return { value, source };
|
|
80
93
|
}
|
|
81
94
|
|
|
95
|
+
// C98 FR-05: categories are provider-reported or unknown. A numeric value is
|
|
96
|
+
// accepted only when usage.source === 'PROVIDER'; any other source (incl.
|
|
97
|
+
// missing/UNKNOWN/ESTIMATED/LOCAL_TOKENIZER), absent key, or non-integer
|
|
98
|
+
// value stays `{ value: null, source: 'UNKNOWN' }` — never 0, never derived
|
|
99
|
+
// from the aggregate, never estimated. Returns null when no categories
|
|
100
|
+
// object is supplied, so the payload field is omitted entirely.
|
|
101
|
+
function usageCategories(model) {
|
|
102
|
+
const usage = isPlainObject(model.usage) ? model.usage : {};
|
|
103
|
+
if (!isPlainObject(usage.categories)) return null;
|
|
104
|
+
const categories = {};
|
|
105
|
+
for (const key of USAGE_CATEGORY_KEYS) {
|
|
106
|
+
categories[key] = { value: null, source: 'UNKNOWN' };
|
|
107
|
+
}
|
|
108
|
+
if (usage.source !== 'PROVIDER') return categories;
|
|
109
|
+
for (const [inputKey, categoryKey] of Object.entries(USAGE_CATEGORY_INPUT_KEYS)) {
|
|
110
|
+
const value = usage.categories[inputKey];
|
|
111
|
+
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) {
|
|
112
|
+
categories[categoryKey] = { value, source: 'PROVIDER' };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return categories;
|
|
116
|
+
}
|
|
117
|
+
|
|
82
118
|
export function adaptRouteOutcomes(rows, options = {}) {
|
|
83
119
|
if (!Array.isArray(rows)) return [];
|
|
84
120
|
const ctx = createAdapterContext({ writerId: 'adapter-route', ...options });
|
|
@@ -113,19 +149,22 @@ export function adaptRouteOutcomes(rows, options = {}) {
|
|
|
113
149
|
const model = isPlainObject(ledger.model) ? ledger.model : null;
|
|
114
150
|
if (model) {
|
|
115
151
|
const resource = modelResource(model);
|
|
152
|
+
const categories = usageCategories(model);
|
|
116
153
|
const failed = model.failed === true || asString(model.status) === 'failed';
|
|
154
|
+
const modelPayload = {
|
|
155
|
+
request_key: requestKey,
|
|
156
|
+
duration_ms:
|
|
157
|
+
typeof model.durationMs === 'number' && Number.isFinite(model.durationMs)
|
|
158
|
+
? model.durationMs
|
|
159
|
+
: null,
|
|
160
|
+
resource,
|
|
161
|
+
telemetry_complete: resource.source !== 'UNKNOWN',
|
|
162
|
+
};
|
|
163
|
+
if (categories) modelPayload[USAGE_CATEGORIES_FIELD] = categories;
|
|
117
164
|
const modelRecord = emitRecord(ctx, {
|
|
118
165
|
semantic_name: failed ? 'model.failed' : 'model.completed',
|
|
119
166
|
wall_time_utc: asIsoTime(audit.ts) ?? asIsoTime(ledger.updatedAt),
|
|
120
|
-
payload:
|
|
121
|
-
request_key: requestKey,
|
|
122
|
-
duration_ms:
|
|
123
|
-
typeof model.durationMs === 'number' && Number.isFinite(model.durationMs)
|
|
124
|
-
? model.durationMs
|
|
125
|
-
: null,
|
|
126
|
-
resource,
|
|
127
|
-
telemetry_complete: resource.source !== 'UNKNOWN',
|
|
128
|
-
},
|
|
167
|
+
payload: modelPayload,
|
|
129
168
|
});
|
|
130
169
|
if (modelRecord) records.push(modelRecord);
|
|
131
170
|
}
|