karajan-code 3.2.0 → 3.3.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 -1
- package/package.json +1 -1
- package/packages/hu-board/public/utils/board-view.js +11 -0
- package/packages/hu-board/public/utils/formatters.js +28 -1
- package/packages/hu-board/src/db.js +62 -3
- package/packages/hu-board/src/format.js +46 -1
- package/src/agents/aider-agent.js +42 -1
- package/src/agents/claude-agent.js +14 -3
- package/src/agents/codex-agent.js +44 -7
- package/src/agents/gemini-agent.js +37 -1
- package/src/agents/opencode-agent.js +34 -1
- package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +12 -1
- package/src/orchestrator/flow-runner.js +10 -2
- package/src/orchestrator/hu-sub-pipeline.js +30 -0
- package/src/session/journal/summary-writer.js +55 -1
- package/src/utils/budget.js +38 -9
- package/src/utils/telemetry.js +43 -0
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
-
> **v3.
|
|
26
|
+
> **v3.3.0 released** — Cross-provider cache observability (epic KJC-PCS-0056 / Phase 0 closed). Every `kj run` now measures provider-level prompt-cache hits end-to-end across Anthropic, OpenAI/Codex, Gemini, aider and opencode — same `cached_tokens` field flows from agent → BudgetTracker → `summary.md` → `board.db` → HU Board badge `🎯 N%` → telemetry `pipeline_complete`. Real data: Claude cold→hot 47.2% → 94.3% cache_pct (**76.4% cost savings on a single HU**), Gemini 87.9% → 96.8%. Null-safe: badge hides when unmeasured. Drop-in upgrade from v3.2.0. Full notes in [CHANGELOG.md](CHANGELOG.md).
|
|
27
27
|
|
|
28
28
|
You describe what you want to build. Karajan orchestrates multiple AI agents to plan it, implement it, test it, review it with SonarQube, and iterate. No babysitting required.
|
|
29
29
|
|
package/package.json
CHANGED
|
@@ -40,6 +40,9 @@ async function renderBoard() {
|
|
|
40
40
|
api(`/api/projects/${encodeURIComponent(selectedProject)}/cost`).catch(() => null),
|
|
41
41
|
]);
|
|
42
42
|
const costSummary = projectCost ? formatProjectCostSummary(projectCost) : null;
|
|
43
|
+
// Φ0-G (KJC-TSK-0525): aggregated cache-hit badge at the project
|
|
44
|
+
// header, rendered alongside the project cost chip.
|
|
45
|
+
const cacheSummary = projectCost ? formatCacheRatio(projectCost.cachedTokens, projectCost.tokensIn) : null;
|
|
43
46
|
|
|
44
47
|
// Pre-resolve project initials + name for every distinct project_id in
|
|
45
48
|
// the fetched stories so `renderStoryCard` is synchronous and the header
|
|
@@ -101,6 +104,7 @@ async function renderBoard() {
|
|
|
101
104
|
` : ''}
|
|
102
105
|
<span class="section-header__count">${stories.length} stories</span>
|
|
103
106
|
${costSummary ? `<span class="section-header__cost" title="${esc(costSummary.tooltip)}">💵 ${esc(costSummary.label)}</span>` : ''}
|
|
107
|
+
${cacheSummary ? `<span class="section-header__cache" title="${esc(cacheSummary.tooltip)}">${esc(cacheSummary.label)}</span>` : ''}
|
|
104
108
|
${isRunning ? `
|
|
105
109
|
<button id="running-badge-btn" class="section-header__badge"
|
|
106
110
|
style="margin-left:auto;padding:4px 10px;font-size:0.8rem;background:var(--color-yellow,#eab308);color:#000;border-radius:var(--radius-sm);font-weight:600;border:none;cursor:pointer;"
|
|
@@ -381,6 +385,13 @@ function renderStoryCard(story) {
|
|
|
381
385
|
const c = formatCost(story.cost_usd);
|
|
382
386
|
return c ? `<span class="story-card__cost" title="${esc(c.tooltip)}">💵 ${esc(c.label)}</span>` : '';
|
|
383
387
|
})()}
|
|
388
|
+
${(() => {
|
|
389
|
+
// Φ0-G (KJC-TSK-0525): per-HU cache hit badge. Rendered next to
|
|
390
|
+
// the cost so users can correlate "this run cost $X and 73% of
|
|
391
|
+
// input was served from cache" in one glance.
|
|
392
|
+
const r = formatCacheRatio(story.cached_tokens, story.tokens_in);
|
|
393
|
+
return r ? `<span class="story-card__cache" title="${esc(r.tooltip)}">${esc(r.label)}</span>` : '';
|
|
394
|
+
})()}
|
|
384
395
|
</div>
|
|
385
396
|
${missingTestContract ? `
|
|
386
397
|
<div class="story-card__meta" style="margin-top:4px;font-size:0.75rem;color:var(--color-yellow,#eab308);font-weight:600" title="This HU has no acceptance_tests declared — Run plan will reject it until you add at least one.">
|
|
@@ -55,6 +55,26 @@ function formatCost(costUsd) {
|
|
|
55
55
|
};
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// Φ0-G (KJC-TSK-0525): mirror of formatCacheRatio in src/format.js.
|
|
59
|
+
// Kept duplicated because this file is a classic script loaded into
|
|
60
|
+
// the browser, while src/format.js uses ES modules and is consumed by
|
|
61
|
+
// Node tests. See KJC-TSK-0501 header notes above.
|
|
62
|
+
function formatCacheRatio(cachedTokens, tokensIn) {
|
|
63
|
+
if (cachedTokens === null || cachedTokens === undefined) return null;
|
|
64
|
+
if (tokensIn === null || tokensIn === undefined) return null;
|
|
65
|
+
const c = Number(cachedTokens);
|
|
66
|
+
const t = Number(tokensIn);
|
|
67
|
+
if (!Number.isFinite(c) || !Number.isFinite(t)) return null;
|
|
68
|
+
if (t <= 0 || c < 0) return null;
|
|
69
|
+
const pct = Math.round((c / t) * 1000) / 10;
|
|
70
|
+
const num = c.toLocaleString("en-US");
|
|
71
|
+
const den = t.toLocaleString("en-US");
|
|
72
|
+
return {
|
|
73
|
+
label: `🎯 ${pct}%`,
|
|
74
|
+
tooltip: `Cache hits: ${num} / ${den} input tokens (${pct}%)`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
58
78
|
function formatProjectCostSummary(cost) {
|
|
59
79
|
if (!cost || typeof cost !== "object") return null;
|
|
60
80
|
const total = Number(cost.totalUsd);
|
|
@@ -63,6 +83,9 @@ function formatProjectCostSummary(cost) {
|
|
|
63
83
|
if (total === 0 && byPlan.length === 0) return null;
|
|
64
84
|
|
|
65
85
|
const lines = [`Total: $${total.toFixed(4)}`];
|
|
86
|
+
// Φ0-G (KJC-TSK-0525): aggregate cache hits line + per-plan suffix.
|
|
87
|
+
const projCache = formatCacheRatio(cost.cachedTokens, cost.tokensIn);
|
|
88
|
+
if (projCache) lines.push(`Cache: ${projCache.tooltip.split(": ")[1]}`);
|
|
66
89
|
if (byPlan.length > 0) {
|
|
67
90
|
lines.push("By plan:");
|
|
68
91
|
for (const p of byPlan) {
|
|
@@ -70,7 +93,11 @@ function formatProjectCostSummary(cost) {
|
|
|
70
93
|
if (!Number.isFinite(planTotal)) continue;
|
|
71
94
|
const huCount = Number.isFinite(p?.huCount) ? p.huCount : 0;
|
|
72
95
|
const planLabel = p?.planId || "unassigned";
|
|
73
|
-
|
|
96
|
+
const cachePct = p?.cachedRatioPct;
|
|
97
|
+
const cacheSuffix = (cachePct !== null && cachePct !== undefined && Number.isFinite(Number(cachePct)))
|
|
98
|
+
? ` 🎯 ${cachePct}%`
|
|
99
|
+
: "";
|
|
100
|
+
lines.push(` ${planLabel}: $${planTotal.toFixed(2)} (${huCount} HU${huCount === 1 ? "" : "s"})${cacheSuffix}`);
|
|
74
101
|
}
|
|
75
102
|
}
|
|
76
103
|
const unk = cost.unknownModelTokens;
|
|
@@ -328,6 +328,13 @@ export function initDb() {
|
|
|
328
328
|
// renders "—" while NULL. Stored as REAL so the SQL aggregations in
|
|
329
329
|
// Cost E (/api/projects/:id/cost) can SUM directly without parsing.
|
|
330
330
|
try { db.exec('ALTER TABLE stories ADD COLUMN cost_usd REAL'); } catch { /* already migrated */ }
|
|
331
|
+
// KJC-TSK-0525 (Φ0-G): persisted cached/input tokens per HU so the
|
|
332
|
+
// board can render the "🎯 N% cached" badge without re-parsing the
|
|
333
|
+
// session summary. NULL = unmeasured (older runs, cold runs without
|
|
334
|
+
// cache support). INTEGER because providers report token counts as
|
|
335
|
+
// ints. Ratio is computed at read time as cached_tokens / tokens_in.
|
|
336
|
+
try { db.exec('ALTER TABLE stories ADD COLUMN cached_tokens INTEGER'); } catch { /* already migrated */ }
|
|
337
|
+
try { db.exec('ALTER TABLE stories ADD COLUMN tokens_in INTEGER'); } catch { /* already migrated */ }
|
|
331
338
|
try { db.exec('CREATE INDEX IF NOT EXISTS idx_stories_plan ON stories(plan_id)'); } catch { /* ignore */ }
|
|
332
339
|
|
|
333
340
|
// is_test (KJC-TSK-0371 — board polish #3): per-project flag that
|
|
@@ -530,6 +537,35 @@ export function setStoryCost(storyId, costUsd) {
|
|
|
530
537
|
return res.changes > 0;
|
|
531
538
|
}
|
|
532
539
|
|
|
540
|
+
/**
|
|
541
|
+
* Persist cached/input tokens for a HU (KJC-TSK-0525, Φ0-G). Called by
|
|
542
|
+
* the orchestrator alongside `setStoryCost` once the HU finishes. Both
|
|
543
|
+
* values come from `BudgetTracker.since(huCursor)` (Φ0-E). Pass `null`
|
|
544
|
+
* to clear either cell. Non-finite numbers throw so callers don't
|
|
545
|
+
* silently null-out real telemetry.
|
|
546
|
+
*
|
|
547
|
+
* @param {string} storyId
|
|
548
|
+
* @param {number|null} cachedTokens
|
|
549
|
+
* @param {number|null} tokensIn
|
|
550
|
+
* @returns {boolean} true when the row existed and was updated
|
|
551
|
+
*/
|
|
552
|
+
export function setStoryCachedTokens(storyId, cachedTokens, tokensIn) {
|
|
553
|
+
const coerce = (v, name) => {
|
|
554
|
+
if (v === null || v === undefined) return null;
|
|
555
|
+
const n = Number(v);
|
|
556
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
557
|
+
throw new Error(`setStoryCachedTokens: ${name} must be a non-negative finite number or null (got ${v})`);
|
|
558
|
+
}
|
|
559
|
+
return Math.round(n);
|
|
560
|
+
};
|
|
561
|
+
const cached = coerce(cachedTokens, 'cachedTokens');
|
|
562
|
+
const tin = coerce(tokensIn, 'tokensIn');
|
|
563
|
+
const res = getDb()
|
|
564
|
+
.prepare('UPDATE stories SET cached_tokens = ?, tokens_in = ?, updated_at = ? WHERE id = ?')
|
|
565
|
+
.run(cached, tin, new Date().toISOString(), storyId);
|
|
566
|
+
return res.changes > 0;
|
|
567
|
+
}
|
|
568
|
+
|
|
533
569
|
/**
|
|
534
570
|
* List every distinct `plan_id` stamped on the stories of a given project.
|
|
535
571
|
* Used by "mark plan ready" so the board can act on each plan of a project
|
|
@@ -643,9 +679,13 @@ export function getProjectCost(projectId) {
|
|
|
643
679
|
.get(projectId);
|
|
644
680
|
if (!project) return null;
|
|
645
681
|
|
|
682
|
+
// Φ0-G: cached_tokens / tokens_in are nullable independently of
|
|
683
|
+
// cost_usd, so we read all three columns but keep cost as the
|
|
684
|
+
// primary filter (a row with only cache telemetry but no $ should
|
|
685
|
+
// not appear in the per-plan totals — cost rules the byPlan list).
|
|
646
686
|
const rows = getDb()
|
|
647
687
|
.prepare(
|
|
648
|
-
`SELECT plan_id, cost_usd FROM stories
|
|
688
|
+
`SELECT plan_id, cost_usd, cached_tokens, tokens_in FROM stories
|
|
649
689
|
WHERE project_id = ? AND cost_usd IS NOT NULL`
|
|
650
690
|
)
|
|
651
691
|
.all(projectId);
|
|
@@ -653,24 +693,43 @@ export function getProjectCost(projectId) {
|
|
|
653
693
|
const round4 = (n) => Math.round(n * 10000) / 10000;
|
|
654
694
|
const byPlanMap = new Map();
|
|
655
695
|
let totalUsd = 0;
|
|
696
|
+
let totalCached = 0;
|
|
697
|
+
let totalTokensIn = 0;
|
|
656
698
|
for (const r of rows) {
|
|
657
699
|
const planId = r.plan_id || 'unassigned';
|
|
658
700
|
const cost = Number(r.cost_usd);
|
|
659
701
|
if (!Number.isFinite(cost)) continue;
|
|
660
702
|
totalUsd += cost;
|
|
661
|
-
const
|
|
703
|
+
const cached = Number(r.cached_tokens);
|
|
704
|
+
const tin = Number(r.tokens_in);
|
|
705
|
+
if (Number.isFinite(cached) && cached >= 0) totalCached += cached;
|
|
706
|
+
if (Number.isFinite(tin) && tin >= 0) totalTokensIn += tin;
|
|
707
|
+
const cur = byPlanMap.get(planId) || { planId, totalUsd: 0, huCount: 0, cachedTokens: 0, tokensIn: 0 };
|
|
662
708
|
cur.totalUsd += cost;
|
|
663
709
|
cur.huCount += 1;
|
|
710
|
+
if (Number.isFinite(cached) && cached >= 0) cur.cachedTokens += cached;
|
|
711
|
+
if (Number.isFinite(tin) && tin >= 0) cur.tokensIn += tin;
|
|
664
712
|
byPlanMap.set(planId, cur);
|
|
665
713
|
}
|
|
666
714
|
|
|
715
|
+
const ratio = (cached, tin) => tin > 0 ? Math.round((cached / tin) * 1000) / 10 : null;
|
|
667
716
|
const byPlan = Array.from(byPlanMap.values())
|
|
668
|
-
.map((p) => ({
|
|
717
|
+
.map((p) => ({
|
|
718
|
+
planId: p.planId,
|
|
719
|
+
totalUsd: round4(p.totalUsd),
|
|
720
|
+
huCount: p.huCount,
|
|
721
|
+
cachedTokens: p.cachedTokens,
|
|
722
|
+
tokensIn: p.tokensIn,
|
|
723
|
+
cachedRatioPct: ratio(p.cachedTokens, p.tokensIn),
|
|
724
|
+
}))
|
|
669
725
|
.sort((a, b) => b.totalUsd - a.totalUsd);
|
|
670
726
|
|
|
671
727
|
return {
|
|
672
728
|
totalUsd: round4(totalUsd),
|
|
673
729
|
byPlan,
|
|
730
|
+
cachedTokens: totalCached,
|
|
731
|
+
tokensIn: totalTokensIn,
|
|
732
|
+
cachedRatioPct: ratio(totalCached, totalTokensIn),
|
|
674
733
|
unknownModelTokens: { tokensIn: 0, tokensOut: 0, models: [] },
|
|
675
734
|
currency: 'USD',
|
|
676
735
|
};
|
|
@@ -87,6 +87,42 @@ export function formatCost(costUsd) {
|
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Φ0-G (KJC-TSK-0525): per-HU and per-plan cached-tokens badge.
|
|
92
|
+
*
|
|
93
|
+
* Returns `{ label, tooltip }` for "🎯 23%" with a tooltip showing the
|
|
94
|
+
* exact numerator/denominator. Same "don't render 0% when there is no
|
|
95
|
+
* data" stance as formatCost — returns null when:
|
|
96
|
+
* - either arg is null/undefined/non-finite,
|
|
97
|
+
* - tokens_in is 0 (no input → ratio undefined, not zero),
|
|
98
|
+
* - cached is negative (would only happen from corrupted telemetry).
|
|
99
|
+
*
|
|
100
|
+
* The badge intentionally shows 1 decimal to keep narrow runs (e.g.
|
|
101
|
+
* a 73-token cache out of 1 200) readable without rounding to 0%.
|
|
102
|
+
*
|
|
103
|
+
* @param {number|null|undefined} cachedTokens
|
|
104
|
+
* @param {number|null|undefined} tokensIn
|
|
105
|
+
* @returns {{ label: string, tooltip: string } | null}
|
|
106
|
+
*/
|
|
107
|
+
export function formatCacheRatio(cachedTokens, tokensIn) {
|
|
108
|
+
// Reject null/undefined explicitly — Number(null) coerces to 0,
|
|
109
|
+
// which would otherwise render an honest-looking "🎯 0%" badge for
|
|
110
|
+
// runs where we simply have no telemetry. Same stance as formatCost.
|
|
111
|
+
if (cachedTokens === null || cachedTokens === undefined) return null;
|
|
112
|
+
if (tokensIn === null || tokensIn === undefined) return null;
|
|
113
|
+
const c = Number(cachedTokens);
|
|
114
|
+
const t = Number(tokensIn);
|
|
115
|
+
if (!Number.isFinite(c) || !Number.isFinite(t)) return null;
|
|
116
|
+
if (t <= 0 || c < 0) return null;
|
|
117
|
+
const pct = Math.round((c / t) * 1000) / 10;
|
|
118
|
+
const num = c.toLocaleString("en-US");
|
|
119
|
+
const den = t.toLocaleString("en-US");
|
|
120
|
+
return {
|
|
121
|
+
label: `🎯 ${pct}%`,
|
|
122
|
+
tooltip: `Cache hits: ${num} / ${den} input tokens (${pct}%)`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
90
126
|
/**
|
|
91
127
|
* Format the aggregated project-level cost chip rendered in the board
|
|
92
128
|
* header. Cost G (KJC-TSK-0518). Consumes the shape returned by
|
|
@@ -116,6 +152,11 @@ export function formatProjectCostSummary(cost) {
|
|
|
116
152
|
if (total === 0 && byPlan.length === 0) return null;
|
|
117
153
|
|
|
118
154
|
const lines = [`Total: $${total.toFixed(4)}`];
|
|
155
|
+
// Φ0-G (KJC-TSK-0525): when the API includes project-level cache
|
|
156
|
+
// counters, show the aggregate cached% line right under the total
|
|
157
|
+
// so users can see at a glance how much was served from cache.
|
|
158
|
+
const projCache = formatCacheRatio(cost.cachedTokens, cost.tokensIn);
|
|
159
|
+
if (projCache) lines.push(`Cache: ${projCache.tooltip.split(": ")[1]}`);
|
|
119
160
|
if (byPlan.length > 0) {
|
|
120
161
|
lines.push("By plan:");
|
|
121
162
|
for (const p of byPlan) {
|
|
@@ -123,7 +164,11 @@ export function formatProjectCostSummary(cost) {
|
|
|
123
164
|
if (!Number.isFinite(planTotal)) continue;
|
|
124
165
|
const huCount = Number.isFinite(p?.huCount) ? p.huCount : 0;
|
|
125
166
|
const planLabel = p?.planId || "unassigned";
|
|
126
|
-
|
|
167
|
+
const cachePct = p?.cachedRatioPct;
|
|
168
|
+
const cacheSuffix = (cachePct !== null && cachePct !== undefined && Number.isFinite(Number(cachePct)))
|
|
169
|
+
? ` 🎯 ${cachePct}%`
|
|
170
|
+
: "";
|
|
171
|
+
lines.push(` ${planLabel}: $${planTotal.toFixed(2)} (${huCount} HU${huCount === 1 ? "" : "s"})${cacheSuffix}`);
|
|
127
172
|
}
|
|
128
173
|
}
|
|
129
174
|
const unk = cost.unknownModelTokens;
|
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
import { BaseAgent } from "./base-agent.js";
|
|
2
2
|
import { resolveBin } from "./resolve-bin.js";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Extract token usage from Aider's stdout. Two shapes are supported:
|
|
6
|
+
*
|
|
7
|
+
* 1. LiteLLM passthrough — an OpenAI-style JSON `usage` block surfaces
|
|
8
|
+
* `prompt_tokens_details.cached_tokens` for prompt-cache hits.
|
|
9
|
+
* 2. Aider's native footer "Tokens: <sent> sent, <received> received
|
|
10
|
+
* [, <cached> cache hit]." (supports comma-separated and `k` suffix).
|
|
11
|
+
*
|
|
12
|
+
* Returns `{tokens_in, tokens_out, cached_tokens}` or null when no usage
|
|
13
|
+
* is found (= unmeasured; callers leave the fields undefined).
|
|
14
|
+
*/
|
|
15
|
+
export function extractAiderUsage(text) {
|
|
16
|
+
if (!text) return null;
|
|
17
|
+
for (const line of text.split("\n")) {
|
|
18
|
+
const t = line.trim();
|
|
19
|
+
if (!t.startsWith("{")) continue;
|
|
20
|
+
let obj;
|
|
21
|
+
try { obj = JSON.parse(t); } catch { continue; }
|
|
22
|
+
const u = obj?.usage ?? (typeof obj?.prompt_tokens === "number" ? obj : null);
|
|
23
|
+
if (!u || typeof u.prompt_tokens !== "number") continue;
|
|
24
|
+
return {
|
|
25
|
+
tokens_in: u.prompt_tokens,
|
|
26
|
+
tokens_out: u.completion_tokens ?? 0,
|
|
27
|
+
cached_tokens: u.prompt_tokens_details?.cached_tokens ?? 0
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const m = text.match(/Tokens:\s*([\d.,]+k?)\s*sent,\s*([\d.,]+k?)\s*received(?:,\s*([\d.,]+k?)\s*cache\s*hit)?/i);
|
|
31
|
+
if (!m) return null;
|
|
32
|
+
const parse = (s) => {
|
|
33
|
+
const c = s.replaceAll(",", "");
|
|
34
|
+
return c.toLowerCase().endsWith("k")
|
|
35
|
+
? Math.round(parseFloat(c.slice(0, -1)) * 1000)
|
|
36
|
+
: Number(c);
|
|
37
|
+
};
|
|
38
|
+
const sent = parse(m[1]);
|
|
39
|
+
const recv = parse(m[2]);
|
|
40
|
+
if (!Number.isFinite(sent) || !Number.isFinite(recv)) return null;
|
|
41
|
+
return { tokens_in: sent, tokens_out: recv, cached_tokens: m[3] ? parse(m[3]) : 0 };
|
|
42
|
+
}
|
|
43
|
+
|
|
4
44
|
export class AiderAgent extends BaseAgent {
|
|
5
45
|
async runTask(task) {
|
|
6
46
|
const role = task.role || "coder";
|
|
@@ -32,6 +72,7 @@ export class AiderAgent extends BaseAgent {
|
|
|
32
72
|
silenceTimeoutMs: task.silenceTimeoutMs,
|
|
33
73
|
timeout: task.timeoutMs
|
|
34
74
|
});
|
|
35
|
-
|
|
75
|
+
const usage = extractAiderUsage(res.stdout) ?? extractAiderUsage(res.stderr);
|
|
76
|
+
return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode, ...usage };
|
|
36
77
|
}
|
|
37
78
|
}
|
|
@@ -68,8 +68,16 @@ function collectAssistantText(obj) {
|
|
|
68
68
|
/**
|
|
69
69
|
* Extract usage metrics from stream-json/json NDJSON output.
|
|
70
70
|
* Looks for the "result" line which contains total_cost_usd,
|
|
71
|
-
* usage.input_tokens/output_tokens,
|
|
72
|
-
*
|
|
71
|
+
* usage.input_tokens/output_tokens, cache_read_input_tokens,
|
|
72
|
+
* cache_creation_input_tokens, and modelUsage.
|
|
73
|
+
*
|
|
74
|
+
* `cached_tokens` aggregates both cache_read_input_tokens (tokens served
|
|
75
|
+
* from Anthropic's automatic prompt cache) and cache_creation_input_tokens
|
|
76
|
+
* (tokens just written to the cache during this call). The sum represents
|
|
77
|
+
* the total volume of input that benefited from prompt-caching machinery.
|
|
78
|
+
*
|
|
79
|
+
* Returns an object with tokens_in, tokens_out, cached_tokens, cost_usd,
|
|
80
|
+
* model or null if not found.
|
|
73
81
|
*/
|
|
74
82
|
function extractUsageFromStreamJson(raw) {
|
|
75
83
|
const events = parseClaudeEvents(raw);
|
|
@@ -79,11 +87,14 @@ function extractUsageFromStreamJson(raw) {
|
|
|
79
87
|
|
|
80
88
|
const tokens_in = obj.usage?.input_tokens ?? 0;
|
|
81
89
|
const tokens_out = obj.usage?.output_tokens ?? 0;
|
|
90
|
+
const cache_read = obj.usage?.cache_read_input_tokens ?? 0;
|
|
91
|
+
const cache_creation = obj.usage?.cache_creation_input_tokens ?? 0;
|
|
92
|
+
const cached_tokens = cache_read + cache_creation;
|
|
82
93
|
const cost_usd = obj.total_cost_usd ?? undefined;
|
|
83
94
|
const modelUsage = obj.modelUsage;
|
|
84
95
|
const model = modelUsage ? Object.keys(modelUsage)[0] || null : null;
|
|
85
96
|
|
|
86
|
-
return { tokens_in, tokens_out, cost_usd, model };
|
|
97
|
+
return { tokens_in, tokens_out, cached_tokens, cost_usd, model };
|
|
87
98
|
}
|
|
88
99
|
return null;
|
|
89
100
|
}
|
|
@@ -2,18 +2,55 @@ import { BaseAgent } from "./base-agent.js";
|
|
|
2
2
|
import { resolveBin } from "./resolve-bin.js";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* Try to extract an OpenAI-style `usage` JSON block from codex stdout.
|
|
6
|
+
*
|
|
7
|
+
* OpenAI's Chat Completions API exposes prompt-cache hits via
|
|
8
|
+
* `usage.prompt_tokens_details.cached_tokens` (since Aug 2024). Codex
|
|
9
|
+
* CLI doesn't surface this in its default footer, but newer flags or a
|
|
10
|
+
* wrapper may emit a JSON `usage` line. We scan stdout line-by-line and
|
|
11
|
+
* accept the first parseable object that carries `prompt_tokens`.
|
|
12
|
+
*
|
|
13
|
+
* Returns `{tokens_in, tokens_out, cached_tokens}` or null when absent.
|
|
14
|
+
*/
|
|
15
|
+
function extractJsonUsage(stdout) {
|
|
16
|
+
for (const line of stdout.split("\n")) {
|
|
17
|
+
const trimmed = line.trim();
|
|
18
|
+
if (!trimmed.startsWith("{")) continue;
|
|
19
|
+
let obj;
|
|
20
|
+
try { obj = JSON.parse(trimmed); } catch { continue; }
|
|
21
|
+
const usage = obj?.usage ?? (typeof obj?.prompt_tokens === "number" ? obj : null);
|
|
22
|
+
if (!usage || typeof usage.prompt_tokens !== "number") continue;
|
|
23
|
+
return {
|
|
24
|
+
tokens_in: usage.prompt_tokens,
|
|
25
|
+
tokens_out: usage.completion_tokens ?? 0,
|
|
26
|
+
cached_tokens: usage.prompt_tokens_details?.cached_tokens ?? 0
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Extract token usage from Codex CLI stdout. Two shapes are supported:
|
|
34
|
+
*
|
|
35
|
+
* 1. OpenAI-style JSON `usage` block (preferred):
|
|
36
|
+
* `{"usage":{"prompt_tokens":N,"completion_tokens":M,
|
|
37
|
+
* "prompt_tokens_details":{"cached_tokens":C}}}`
|
|
38
|
+
* → `{tokens_in:N, tokens_out:M, cached_tokens:C}`.
|
|
39
|
+
*
|
|
40
|
+
* 2. Legacy "tokens used\n<number>" footer (codex's default total,
|
|
41
|
+
* no cache visibility) → `{tokens_in:0, tokens_out, cached_tokens:0}`.
|
|
42
|
+
*
|
|
43
|
+
* Returns null when neither shape is found.
|
|
10
44
|
*/
|
|
11
45
|
function extractCodexTokens(stdout) {
|
|
12
|
-
|
|
46
|
+
if (!stdout) return null;
|
|
47
|
+
const json = extractJsonUsage(stdout);
|
|
48
|
+
if (json) return json;
|
|
49
|
+
const match = stdout.match(/tokens?\s+used\s*\n\s*([\d,]+)/i);
|
|
13
50
|
if (!match) return null;
|
|
14
51
|
const total = Number(match[1].replaceAll(/,/g, ""));
|
|
15
52
|
if (!Number.isFinite(total) || total <= 0) return null;
|
|
16
|
-
return { tokens_in: 0, tokens_out: total };
|
|
53
|
+
return { tokens_in: 0, tokens_out: total, cached_tokens: 0 };
|
|
17
54
|
}
|
|
18
55
|
|
|
19
56
|
export class CodexAgent extends BaseAgent {
|
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
import { BaseAgent } from "./base-agent.js";
|
|
2
2
|
import { resolveBin } from "./resolve-bin.js";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Extract Gemini's `usageMetadata` from stdout, including
|
|
6
|
+
* `cachedContentTokenCount` (Gemini's context-caching API metric,
|
|
7
|
+
* https://ai.google.dev/api/caching).
|
|
8
|
+
*
|
|
9
|
+
* The gemini CLI emits `usageMetadata` as part of its JSON response
|
|
10
|
+
* shape (today: only `--output-format json`, used in reviewTask; in
|
|
11
|
+
* plain runTask mode no usage is surfaced). When absent, returns null
|
|
12
|
+
* so the caller leaves `cached_tokens` undefined (= unmeasured,
|
|
13
|
+
* distinct from a measured 0).
|
|
14
|
+
*/
|
|
15
|
+
export function extractGeminiUsage(text) {
|
|
16
|
+
if (!text) return null;
|
|
17
|
+
const candidates = [];
|
|
18
|
+
for (const line of text.split("\n")) {
|
|
19
|
+
const t = line.trim();
|
|
20
|
+
if (t.startsWith("{") || t.startsWith("[")) candidates.push(t);
|
|
21
|
+
}
|
|
22
|
+
candidates.push(text.trim());
|
|
23
|
+
for (const candidate of candidates) {
|
|
24
|
+
let obj;
|
|
25
|
+
try { obj = JSON.parse(candidate); } catch { continue; }
|
|
26
|
+
const meta = obj?.usageMetadata
|
|
27
|
+
?? obj?.usage_metadata
|
|
28
|
+
?? obj?.response?.usageMetadata;
|
|
29
|
+
if (!meta || typeof meta.promptTokenCount !== "number") continue;
|
|
30
|
+
return {
|
|
31
|
+
tokens_in: meta.promptTokenCount,
|
|
32
|
+
tokens_out: meta.candidatesTokenCount ?? 0,
|
|
33
|
+
cached_tokens: meta.cachedContentTokenCount ?? 0
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
4
39
|
export class GeminiAgent extends BaseAgent {
|
|
5
40
|
async runTask(task) {
|
|
6
41
|
const role = task.role || "coder";
|
|
@@ -33,6 +68,7 @@ export class GeminiAgent extends BaseAgent {
|
|
|
33
68
|
silenceTimeoutMs: task.silenceTimeoutMs,
|
|
34
69
|
timeout: task.timeoutMs
|
|
35
70
|
});
|
|
36
|
-
|
|
71
|
+
const usage = extractGeminiUsage(res.stdout) ?? extractGeminiUsage(res.stderr);
|
|
72
|
+
return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode, ...usage };
|
|
37
73
|
}
|
|
38
74
|
}
|
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
import { BaseAgent } from "./base-agent.js";
|
|
2
2
|
import { resolveBin } from "./resolve-bin.js";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Extract OpenAI-style `usage` from OpenCode stdout. With `--format json`
|
|
6
|
+
* (used in reviewTask) the CLI emits a JSON document; with LiteLLM-backed
|
|
7
|
+
* runs that document carries `usage.prompt_tokens_details.cached_tokens`.
|
|
8
|
+
*
|
|
9
|
+
* Accepts the whole payload, individual lines, or array elements with a
|
|
10
|
+
* nested `usage` object. Returns `{tokens_in, tokens_out, cached_tokens}`
|
|
11
|
+
* or null (= unmeasured).
|
|
12
|
+
*/
|
|
13
|
+
export function extractOpenCodeUsage(text) {
|
|
14
|
+
if (!text) return null;
|
|
15
|
+
const candidates = [text.trim()];
|
|
16
|
+
for (const line of text.split("\n")) {
|
|
17
|
+
const t = line.trim();
|
|
18
|
+
if (t.startsWith("{") || t.startsWith("[")) candidates.push(t);
|
|
19
|
+
}
|
|
20
|
+
for (const candidate of candidates) {
|
|
21
|
+
let obj;
|
|
22
|
+
try { obj = JSON.parse(candidate); } catch { continue; }
|
|
23
|
+
const usage = obj?.usage
|
|
24
|
+
?? obj?.result?.usage
|
|
25
|
+
?? (Array.isArray(obj) ? obj.find((e) => e?.usage)?.usage : null);
|
|
26
|
+
if (!usage || typeof usage.prompt_tokens !== "number") continue;
|
|
27
|
+
return {
|
|
28
|
+
tokens_in: usage.prompt_tokens,
|
|
29
|
+
tokens_out: usage.completion_tokens ?? 0,
|
|
30
|
+
cached_tokens: usage.prompt_tokens_details?.cached_tokens ?? 0
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
4
36
|
export class OpenCodeAgent extends BaseAgent {
|
|
5
37
|
async runTask(task) {
|
|
6
38
|
const role = task.role || "coder";
|
|
@@ -34,6 +66,7 @@ export class OpenCodeAgent extends BaseAgent {
|
|
|
34
66
|
silenceTimeoutMs: task.silenceTimeoutMs,
|
|
35
67
|
timeout: task.timeoutMs
|
|
36
68
|
});
|
|
37
|
-
|
|
69
|
+
const usage = extractOpenCodeUsage(res.stdout) ?? extractOpenCodeUsage(res.stderr);
|
|
70
|
+
return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode, ...usage };
|
|
38
71
|
}
|
|
39
72
|
}
|
|
@@ -316,8 +316,19 @@ function wirePlanCallbacks({ session, loadedPlan, projectDir, syncResultsToPlan,
|
|
|
316
316
|
await savePlanToDisk(projectDir, loadedPlan);
|
|
317
317
|
if (outcome && Number.isFinite(outcome.cost_usd)) {
|
|
318
318
|
try {
|
|
319
|
-
const { setStoryCost } = await import("../../../../packages/hu-board/src/db.js");
|
|
319
|
+
const { setStoryCost, setStoryCachedTokens } = await import("../../../../packages/hu-board/src/db.js");
|
|
320
320
|
setStoryCost(huId, outcome.cost_usd);
|
|
321
|
+
// Φ0-G (KJC-TSK-0525): cached/in tokens travel in the same
|
|
322
|
+
// outcome blob as cost. Write them together so the board
|
|
323
|
+
// can render the "🎯 N% cached" badge on the same render.
|
|
324
|
+
// Either may be null (older runs, no cache) — setter handles it.
|
|
325
|
+
if (Number.isFinite(outcome.cached_tokens) || Number.isFinite(outcome.tokens_in)) {
|
|
326
|
+
setStoryCachedTokens(
|
|
327
|
+
huId,
|
|
328
|
+
Number.isFinite(outcome.cached_tokens) ? outcome.cached_tokens : null,
|
|
329
|
+
Number.isFinite(outcome.tokens_in) ? outcome.tokens_in : null,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
321
332
|
} catch (err) {
|
|
322
333
|
logger?.warn?.(`Cost write failed for ${huId}: ${err.message}`);
|
|
323
334
|
}
|
|
@@ -263,15 +263,23 @@ async function _runFlowInner({ task, config, logger, flags = {}, emitter = null,
|
|
|
263
263
|
|
|
264
264
|
// --- Telemetry: anonymous pipeline_complete event (non-blocking) ---
|
|
265
265
|
try {
|
|
266
|
-
const { sendTelemetryEvent } = await import("../utils/telemetry.js");
|
|
266
|
+
const { sendTelemetryEvent, computeCachedPct } = await import("../utils/telemetry.js");
|
|
267
267
|
const durationS = Math.round((Date.now() - ctx.startedAt) / 1000);
|
|
268
268
|
const sessionStatus = ctx.session?.status || "unknown";
|
|
269
|
+
// Φ0-H (KJC-TSK-0526): aggregate cache-hit ratios per role so we
|
|
270
|
+
// can see on the fleet how much each provider's cache is buying.
|
|
271
|
+
let cachedPct = { cached_pct_coder: null, cached_pct_reviewer: null, cached_pct_total: null };
|
|
272
|
+
try {
|
|
273
|
+
const budgetSummary = ctx.budgetTracker?.summary?.();
|
|
274
|
+
if (budgetSummary) cachedPct = computeCachedPct(budgetSummary);
|
|
275
|
+
} catch { /* non-blocking — telemetry must never break runs */ }
|
|
269
276
|
sendTelemetryEvent("pipeline_complete", {
|
|
270
277
|
mode: config.review_mode,
|
|
271
278
|
agent: ctx.coderRole?.provider || config.coder,
|
|
272
279
|
duration_s: durationS,
|
|
273
280
|
success: sessionStatus === "approved",
|
|
274
|
-
taskType: ctx.session?.resolved_policies?.taskType || null
|
|
281
|
+
taskType: ctx.session?.resolved_policies?.taskType || null,
|
|
282
|
+
...cachedPct
|
|
275
283
|
}, config).catch(() => {});
|
|
276
284
|
} catch { /* non-blocking */ }
|
|
277
285
|
}
|
|
@@ -105,6 +105,31 @@ function sumCostUsd(entries) {
|
|
|
105
105
|
return Math.round(total * 1e6) / 1e6;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Sum an integer token-counter field across a slice of BudgetTracker
|
|
110
|
+
* entries (Φ0-G, KJC-TSK-0525). Same null-vs-0 semantics as sumCostUsd:
|
|
111
|
+
* returns null when nothing measurable was recorded so the board can
|
|
112
|
+
* distinguish "unmeasured" from "really zero".
|
|
113
|
+
*
|
|
114
|
+
* @param {Array<object>} entries
|
|
115
|
+
* @param {string} field — entry key to sum, e.g. "cached_tokens"
|
|
116
|
+
* @returns {number|null}
|
|
117
|
+
*/
|
|
118
|
+
function sumTokenField(entries, field) {
|
|
119
|
+
if (!Array.isArray(entries) || entries.length === 0) return null;
|
|
120
|
+
let total = 0;
|
|
121
|
+
let hasAny = false;
|
|
122
|
+
for (const e of entries) {
|
|
123
|
+
const n = Number(e?.[field]);
|
|
124
|
+
if (Number.isFinite(n) && n >= 0) {
|
|
125
|
+
total += n;
|
|
126
|
+
hasAny = true;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (!hasAny) return null;
|
|
130
|
+
return Math.round(total);
|
|
131
|
+
}
|
|
132
|
+
|
|
108
133
|
/**
|
|
109
134
|
* Build the per-HU outcome blob from whatever metadata the iteration
|
|
110
135
|
* loop returned. Tolerant by design — most fields are optional and we
|
|
@@ -157,6 +182,11 @@ function buildHuOutcome({ story: _story, iterResult, status, startedAt, huBudget
|
|
|
157
182
|
blockers,
|
|
158
183
|
summary,
|
|
159
184
|
cost_usd: sumCostUsd(huBudgetEntries),
|
|
185
|
+
// Φ0-G (KJC-TSK-0525): cached/input tokens drive the "🎯 N% cached"
|
|
186
|
+
// board badge. Null when nothing measurable was recorded so the UI
|
|
187
|
+
// can hide the chip on cold runs or providers without cache.
|
|
188
|
+
cached_tokens: sumTokenField(huBudgetEntries, 'cached_tokens'),
|
|
189
|
+
tokens_in: sumTokenField(huBudgetEntries, 'tokens_in'),
|
|
160
190
|
finishedAt: new Date().toISOString(),
|
|
161
191
|
};
|
|
162
192
|
}
|
|
@@ -18,7 +18,7 @@ import path from "node:path";
|
|
|
18
18
|
* @property {"APPROVED"|"REJECTED"|"PAUSED"|"FAILED"|string} result
|
|
19
19
|
* @property {number} iterations
|
|
20
20
|
* @property {number} durationMs
|
|
21
|
-
* @property {{ total_cost_usd?: number, total_tokens?: number, breakdown_by_role?: Object }} [budget]
|
|
21
|
+
* @property {{ total_cost_usd?: number, total_tokens?: number, total_cached_tokens?: number, breakdown_by_role?: Object }} [budget]
|
|
22
22
|
* @property {Record<string, { ok?: boolean, summary?: string }>} [stages]
|
|
23
23
|
* @property {Array<{ hash?: string, message?: string, date?: string, author?: string }>} [commits]
|
|
24
24
|
* @property {string[]} [files] - names of other journal files in the same dir
|
|
@@ -107,6 +107,55 @@ function renderBreakdownByRole(breakdown) {
|
|
|
107
107
|
return ["| Role | Tokens | Cost |", "|---|---|---|", ...rows].join("\n");
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Render the Cache hits section (Φ0-F, KJC-TSK-0524). Shows how many input
|
|
112
|
+
* tokens were served from provider-side prompt caches, per role, plus a
|
|
113
|
+
* conservative dollar savings estimate.
|
|
114
|
+
*
|
|
115
|
+
* Savings formula: cached tokens are billed at ~10% of the input rate (the
|
|
116
|
+
* Anthropic/OpenAI convention). Per role:
|
|
117
|
+
* savings_usd ≈ cost_usd * (cached_tokens / tokens_in) * 0.9
|
|
118
|
+
* The estimate is approximate (output tokens also contribute to cost_usd)
|
|
119
|
+
* and labelled with `~$` so users don't read it as exact.
|
|
120
|
+
*
|
|
121
|
+
* Returns:
|
|
122
|
+
* - null when no budget data is available
|
|
123
|
+
* - "cold run" placeholder when cached == 0 across all roles
|
|
124
|
+
* - Markdown table sorted by cached tokens desc when there is cache activity
|
|
125
|
+
*/
|
|
126
|
+
function renderCacheHits(budget) {
|
|
127
|
+
if (!budget) return null;
|
|
128
|
+
const totalCached = Number(budget.total_cached_tokens ?? 0);
|
|
129
|
+
const breakdown = budget.breakdown_by_role || {};
|
|
130
|
+
const entries = Object.entries(breakdown)
|
|
131
|
+
.map(([role, m]) => ({
|
|
132
|
+
role,
|
|
133
|
+
cached: Number(m?.cached_tokens || 0),
|
|
134
|
+
tokens_in: Number(m?.tokens_in || 0),
|
|
135
|
+
cost: Number(m?.total_cost_usd || 0),
|
|
136
|
+
}))
|
|
137
|
+
.filter((e) => e.cached > 0);
|
|
138
|
+
|
|
139
|
+
if (totalCached === 0 && entries.length === 0) {
|
|
140
|
+
return "_Cache hits: 0 tokens (cold run)._";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const rows = entries
|
|
144
|
+
.sort((a, b) => b.cached - a.cached)
|
|
145
|
+
.map((e) => {
|
|
146
|
+
const ratio = e.tokens_in > 0 ? (e.cached / e.tokens_in) * 100 : 0;
|
|
147
|
+
const savings = e.tokens_in > 0 ? e.cost * (e.cached / e.tokens_in) * 0.9 : 0;
|
|
148
|
+
return `| \`${esc(e.role)}\` | ${e.cached.toLocaleString("en-US")} | ${ratio.toFixed(1)}% | ~$${savings.toFixed(4)} |`;
|
|
149
|
+
});
|
|
150
|
+
const header = "| Role | Cached tokens | Ratio (cached/in) | Est. savings |\n|---|---|---|---|";
|
|
151
|
+
return [
|
|
152
|
+
`**Total cached**: ${totalCached.toLocaleString("en-US")} tokens`,
|
|
153
|
+
"",
|
|
154
|
+
header,
|
|
155
|
+
...rows,
|
|
156
|
+
].join("\n");
|
|
157
|
+
}
|
|
158
|
+
|
|
110
159
|
/**
|
|
111
160
|
* Render the Skills section: which process skills (addyosmani) were resolved
|
|
112
161
|
* for the task, which OpenSkills were installed, and which skills would have
|
|
@@ -192,6 +241,11 @@ export function buildSummaryMarkdown(input) {
|
|
|
192
241
|
sections.push("", "## Budget breakdown (by role)", "", breakdown);
|
|
193
242
|
}
|
|
194
243
|
|
|
244
|
+
const cacheHitsSection = renderCacheHits(input.budget);
|
|
245
|
+
if (cacheHitsSection) {
|
|
246
|
+
sections.push("", "## Cache hits", "", cacheHitsSection);
|
|
247
|
+
}
|
|
248
|
+
|
|
195
249
|
const skillsSection = renderSkills(input.skills);
|
|
196
250
|
if (skillsSection) {
|
|
197
251
|
sections.push("", "## Skills Used", "", skillsSection);
|
package/src/utils/budget.js
CHANGED
|
@@ -26,6 +26,13 @@ export function extractUsageMetrics(result, defaultModel = null) {
|
|
|
26
26
|
usage?.output_tokens ??
|
|
27
27
|
usage?.completion_tokens ??
|
|
28
28
|
0;
|
|
29
|
+
const cached_tokens =
|
|
30
|
+
result?.cached_tokens ??
|
|
31
|
+
usage?.cached_tokens ??
|
|
32
|
+
usage?.cache_read_input_tokens ??
|
|
33
|
+
usage?.prompt_tokens_details?.cached_tokens ??
|
|
34
|
+
usage?.cachedContentTokenCount ??
|
|
35
|
+
0;
|
|
29
36
|
const cost_usd =
|
|
30
37
|
result?.cost_usd ??
|
|
31
38
|
usage?.cost_usd ??
|
|
@@ -58,7 +65,7 @@ export function extractUsageMetrics(result, defaultModel = null) {
|
|
|
58
65
|
}
|
|
59
66
|
}
|
|
60
67
|
|
|
61
|
-
return { tokens_in: finalTokensIn, tokens_out: finalTokensOut, cost_usd, model, estimated };
|
|
68
|
+
return { tokens_in: finalTokensIn, tokens_out: finalTokensOut, cached_tokens, cost_usd, model, estimated };
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
function toSafeNumber(value) {
|
|
@@ -79,9 +86,10 @@ function normalizeLimit(limit) {
|
|
|
79
86
|
}
|
|
80
87
|
|
|
81
88
|
function addToBreakdown(map, key, entry) {
|
|
82
|
-
const current = map[key] || { tokens_in: 0, tokens_out: 0, total_tokens: 0, total_cost_usd: 0, count: 0 };
|
|
89
|
+
const current = map[key] || { tokens_in: 0, tokens_out: 0, cached_tokens: 0, total_tokens: 0, total_cost_usd: 0, count: 0 };
|
|
83
90
|
current.tokens_in += entry.tokens_in;
|
|
84
91
|
current.tokens_out += entry.tokens_out;
|
|
92
|
+
current.cached_tokens += entry.cached_tokens || 0;
|
|
85
93
|
current.total_tokens += entry.tokens_in + entry.tokens_out;
|
|
86
94
|
current.total_cost_usd = roundUsd(current.total_cost_usd + entry.cost_usd);
|
|
87
95
|
current.count += 1;
|
|
@@ -94,9 +102,10 @@ export class BudgetTracker {
|
|
|
94
102
|
this.pricing = mergePricing(DEFAULT_MODEL_PRICING, options.pricing || {});
|
|
95
103
|
}
|
|
96
104
|
|
|
97
|
-
record({ role, provider, model, tokens_in, tokens_out, cost_usd, duration_ms, stage_index, estimated } = {}) {
|
|
105
|
+
record({ role, provider, model, tokens_in, tokens_out, cached_tokens, cost_usd, duration_ms, stage_index, estimated } = {}) {
|
|
98
106
|
const safeTokensIn = toSafeNumber(tokens_in);
|
|
99
107
|
const safeTokensOut = toSafeNumber(tokens_out);
|
|
108
|
+
const safeCached = toSafeNumber(cached_tokens);
|
|
100
109
|
const hasExplicitCost = cost_usd !== undefined && cost_usd !== null && cost_usd !== "";
|
|
101
110
|
const modelName = model || provider || null;
|
|
102
111
|
const computedCost = calculateUsageCostUsd({
|
|
@@ -113,6 +122,7 @@ export class BudgetTracker {
|
|
|
113
122
|
timestamp: new Date().toISOString(),
|
|
114
123
|
tokens_in: safeTokensIn,
|
|
115
124
|
tokens_out: safeTokensOut,
|
|
125
|
+
cached_tokens: safeCached,
|
|
116
126
|
cost_usd: roundUsd(hasExplicitCost ? cost_usd : computedCost)
|
|
117
127
|
};
|
|
118
128
|
if (duration_ms !== undefined && duration_ms !== null) {
|
|
@@ -129,19 +139,36 @@ export class BudgetTracker {
|
|
|
129
139
|
}
|
|
130
140
|
|
|
131
141
|
total() {
|
|
142
|
+
return this._aggregate(this.entries);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
_aggregate(entries) {
|
|
132
146
|
let tokensIn = 0;
|
|
133
147
|
let tokensOut = 0;
|
|
148
|
+
let cached = 0;
|
|
134
149
|
let totalCost = 0;
|
|
135
|
-
for (const entry of
|
|
150
|
+
for (const entry of entries) {
|
|
136
151
|
tokensIn += entry.tokens_in;
|
|
137
152
|
tokensOut += entry.tokens_out;
|
|
153
|
+
cached += entry.cached_tokens || 0;
|
|
138
154
|
totalCost = roundUsd(totalCost + entry.cost_usd);
|
|
139
155
|
}
|
|
140
|
-
return {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
156
|
+
return { tokens_in: tokensIn, tokens_out: tokensOut, cached_tokens: cached, cost_usd: totalCost };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Snapshot cursor (Φ0-E). Returns an opaque marker pointing at the current
|
|
161
|
+
* end of the entries log. Pair with `since(cursor)` to compute per-HU deltas
|
|
162
|
+
* without resetting global accumulators (per-HU isolation).
|
|
163
|
+
*/
|
|
164
|
+
cursor() {
|
|
165
|
+
return this.entries.length;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
since(cursor = 0) {
|
|
169
|
+
const start = Number.isFinite(cursor) && cursor >= 0 ? Math.min(cursor, this.entries.length) : 0;
|
|
170
|
+
const slice = this.entries.slice(start);
|
|
171
|
+
return { ...this._aggregate(slice), count: slice.length };
|
|
145
172
|
}
|
|
146
173
|
|
|
147
174
|
remaining(limit) {
|
|
@@ -171,6 +198,7 @@ export class BudgetTracker {
|
|
|
171
198
|
const hasEstimates = this.entries.some(e => e.estimated);
|
|
172
199
|
const result = {
|
|
173
200
|
total_tokens: totals.tokens_in + totals.tokens_out,
|
|
201
|
+
total_cached_tokens: totals.cached_tokens,
|
|
174
202
|
total_cost_usd: totals.cost_usd,
|
|
175
203
|
breakdown_by_role: byRole,
|
|
176
204
|
entries: [...this.entries],
|
|
@@ -191,6 +219,7 @@ export class BudgetTracker {
|
|
|
191
219
|
duration_ms: entry.duration_ms ?? null,
|
|
192
220
|
tokens_in: entry.tokens_in,
|
|
193
221
|
tokens_out: entry.tokens_out,
|
|
222
|
+
cached_tokens: entry.cached_tokens || 0,
|
|
194
223
|
cost_usd: entry.cost_usd
|
|
195
224
|
};
|
|
196
225
|
if (entry.estimated) item.estimated = true;
|
package/src/utils/telemetry.js
CHANGED
|
@@ -26,6 +26,49 @@ export function buildTelemetryPayload(eventName, data = {}) {
|
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Φ0-H (KJC-TSK-0526): aggregate cache-hit ratio per role from the budget
|
|
31
|
+
* summary the orchestrator already builds. Returns `{ cached_pct_coder,
|
|
32
|
+
* cached_pct_reviewer, cached_pct_total }` with values rounded to one
|
|
33
|
+
* decimal, or `null` per slot when there is no measurable input (rather
|
|
34
|
+
* than `0`, so the backend can distinguish "no cache" from "no signal").
|
|
35
|
+
*
|
|
36
|
+
* Provider-agnostic by design: the BudgetTracker entries already
|
|
37
|
+
* normalise `cached_tokens` for Anthropic (`cache_read_input_tokens`),
|
|
38
|
+
* OpenAI / Codex (`prompt_tokens_details.cached_tokens`), Gemini
|
|
39
|
+
* (`cachedContentTokenCount`) and the LiteLLM-routed providers
|
|
40
|
+
* (aider, opencode), so this helper does not need to know which
|
|
41
|
+
* provider produced the run.
|
|
42
|
+
*
|
|
43
|
+
* @param {{ breakdown_by_role?: object, total_cached_tokens?: number } | null | undefined} summary
|
|
44
|
+
* @returns {{ cached_pct_coder: number|null, cached_pct_reviewer: number|null, cached_pct_total: number|null }}
|
|
45
|
+
*/
|
|
46
|
+
export function computeCachedPct(summary) {
|
|
47
|
+
const empty = { cached_pct_coder: null, cached_pct_reviewer: null, cached_pct_total: null };
|
|
48
|
+
if (!summary || typeof summary !== "object") return empty;
|
|
49
|
+
const ratio = (cached, tokensIn) => {
|
|
50
|
+
const c = Number(cached);
|
|
51
|
+
const t = Number(tokensIn);
|
|
52
|
+
if (!Number.isFinite(c) || !Number.isFinite(t) || t <= 0 || c < 0) return null;
|
|
53
|
+
return Math.round((c / t) * 1000) / 10;
|
|
54
|
+
};
|
|
55
|
+
const byRole = summary.breakdown_by_role || {};
|
|
56
|
+
const coder = byRole.coder || null;
|
|
57
|
+
const reviewer = byRole.reviewer || null;
|
|
58
|
+
let totalCached = 0;
|
|
59
|
+
let totalIn = 0;
|
|
60
|
+
for (const role of Object.keys(byRole)) {
|
|
61
|
+
const r = byRole[role];
|
|
62
|
+
totalCached += Number(r?.cached_tokens || 0);
|
|
63
|
+
totalIn += Number(r?.tokens_in || 0);
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
cached_pct_coder: coder ? ratio(coder.cached_tokens, coder.tokens_in) : null,
|
|
67
|
+
cached_pct_reviewer: reviewer ? ratio(reviewer.cached_tokens, reviewer.tokens_in) : null,
|
|
68
|
+
cached_pct_total: ratio(totalCached, totalIn)
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
29
72
|
/**
|
|
30
73
|
* Send an anonymous telemetry event. Non-blocking, fire-and-forget.
|
|
31
74
|
* Never throws, never blocks the pipeline.
|