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 CHANGED
@@ -23,7 +23,7 @@
23
23
 
24
24
  ---
25
25
 
26
- > **v3.2.0 released** — Cost tracking end-to-end (epic KJC-PCS-0055 closed). Every `kj run` now records per-HU USD spend via the `BudgetTracker` cursor-snapshot pattern, persists it to `board.db.cost_usd`, and the HU Board surfaces both a per-card badge (`$0.02`) and a project-wide chip with per-plan breakdown. Null-safe: unmeasured HUs hide the badge instead of misleading "$0.00". Drop-in upgrade from v3.1.0. Full notes in [CHANGELOG.md](CHANGELOG.md).
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -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
- lines.push(` ${planLabel}: $${planTotal.toFixed(2)} (${huCount} HU${huCount === 1 ? "" : "s"})`);
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 cur = byPlanMap.get(planId) || { planId, totalUsd: 0, huCount: 0 };
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) => ({ planId: p.planId, totalUsd: round4(p.totalUsd), huCount: p.huCount }))
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
- lines.push(` ${planLabel}: $${planTotal.toFixed(2)} (${huCount} HU${huCount === 1 ? "" : "s"})`);
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
- return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode };
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, and modelUsage.
72
- * Returns an object with tokens_in, tokens_out, cost_usd, model or null if not found.
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
- * Extract token usage from Codex CLI stdout.
6
- * Codex prints "tokens used\n<number>" at the end, where number may have comma separators.
7
- * Returns { tokens_out } with the total token count, or null if not found.
8
- * Since Codex doesn't split input/output, we assign the total to tokens_out
9
- * as a conservative estimate for cost calculation.
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
- const match = (stdout || "").match(/tokens?\s+used\s*\n\s*([\d,]+)/i);
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
- return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode };
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
- return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode };
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);
@@ -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 this.entries) {
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
- tokens_in: tokensIn,
142
- tokens_out: tokensOut,
143
- cost_usd: totalCost
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;
@@ -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.