@maxiaochao/pi-toolkit 0.13.7 → 0.13.9

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +5 -1
  3. package/extensions/btw/index.ts +7 -16
  4. package/extensions/btw/tests/context.mjs +78 -0
  5. package/package.json +4 -3
  6. package/packages/cache-export/CHANGELOG.md +11 -0
  7. package/packages/cache-export/README.md +14 -0
  8. package/packages/cache-export/TESTING.md +8 -2
  9. package/packages/cache-export/docs/demo.gif +0 -0
  10. package/packages/cache-export/docs/screenshot.png +0 -0
  11. package/packages/cache-export/extensions/cache-export.ts +44 -3
  12. package/packages/cache-export/package.json +24 -6
  13. package/packages/cache-export/src/render.ts +120 -59
  14. package/packages/cache-export/tests/e2e-browser.mjs +45 -5
  15. package/packages/cache-export/tests/evidence.mjs +113 -0
  16. package/packages/cache-export/tests/extension.mjs +52 -0
  17. package/packages/cache-export/tests/run-tests.mjs +12 -11
  18. package/packages/codex-edit/CHANGELOG.md +7 -0
  19. package/packages/codex-edit/README.md +4 -0
  20. package/packages/codex-edit/TESTING.md +4 -0
  21. package/packages/codex-edit/docs/test-report.html +19 -11
  22. package/packages/codex-edit/docs/test-report.md +22 -13
  23. package/packages/codex-edit/docs/test-summary.md +1 -1
  24. package/packages/codex-edit/extensions/codex-edit.ts +12 -4
  25. package/packages/codex-edit/tests/comparison-cases.mjs +35 -0
  26. package/packages/codex-edit/tests/comparison-harness.mjs +5 -6
  27. package/packages/codex-edit/tests/generate-test-report.mjs +12 -7
  28. package/packages/codex-edit/tests/native-vs-codex.ts +14 -14
  29. package/packages/codex-edit/tests/released-vs-local.test.mjs +4 -0
  30. package/packages/herdr-subagents/ARCHITECTURE.md +4 -1
  31. package/packages/herdr-subagents/CHANGELOG.md +12 -0
  32. package/packages/herdr-subagents/README.md +11 -2
  33. package/packages/herdr-subagents/TESTING.md +12 -0
  34. package/packages/herdr-subagents/docs/demo.webm +0 -0
  35. package/packages/herdr-subagents/docs/screenshot.png +0 -0
  36. package/packages/herdr-subagents/extensions/subagent-report.ts +84 -30
  37. package/packages/herdr-subagents/package.json +2 -2
  38. package/packages/herdr-subagents/tests/report-boundary.mjs +83 -0
  39. package/packages/herdr-subagents/tests/report-lifecycle.mjs +69 -0
  40. package/packages/herdr-subagents/tests/runtime.mjs +26 -0
  41. package/packages/herdr-subagents/tests/standalone-package.mjs +23 -0
  42. package/packages/worktree/CHANGELOG.md +4 -0
  43. package/packages/worktree/README.md +4 -0
  44. package/packages/worktree/TESTING.md +4 -0
  45. package/packages/worktree/bin/pi-worktree +1 -1
  46. package/packages/worktree/tests/run-tests.sh +10 -0
  47. package/skills/web-browser/SKILL.md +3 -1
  48. package/skills/web-browser/scripts/start.js +42 -8
@@ -9,8 +9,8 @@ import { USAGE_STYLES, USAGE_SCRIPT } from "./tau-assets.ts";
9
9
  // ---------------------------------------------------------------------------
10
10
 
11
11
  function hhmmss(ts) {
12
- const iso = String(ts || "");
13
- if (!iso) return "";
12
+ const iso = ts ?? "";
13
+ if (iso === "") return "";
14
14
  const d = new Date(iso);
15
15
  if (isNaN(d)) return "";
16
16
  // render in the machine's local timezone (Asia/Shanghai here -> Beijing time)
@@ -26,13 +26,20 @@ export function tzLabel() {
26
26
  return `${tz} (UTC${off})`;
27
27
  }
28
28
 
29
+ function timeMs(value) {
30
+ const result = typeof value === "number" ? value : typeof value === "string" && value ? Date.parse(value) : NaN;
31
+ return Number.isFinite(result) ? result : null;
32
+ }
33
+
29
34
  export function parseEntries(entries) {
30
35
  const requests = [];
31
36
  const events = [];
32
37
  const toolCounts = {};
33
38
  let compactions = 0;
34
39
  let prevKey = null;
35
- const longCacheUntilByKey = new Map();
40
+ const auxiliary = [];
41
+ const warmByKey = new Map();
42
+ let observedPolicy;
36
43
  let gapTools = []; // tool results accumulated since the previous request
37
44
 
38
45
  const addEvent = (num, kind, label, ts) =>
@@ -41,7 +48,26 @@ export function parseEntries(entries) {
41
48
  for (const e of entries || []) {
42
49
  const t = e?.type;
43
50
  const ts = e?.timestamp;
51
+ if (t === "custom" && e.customType === "cache-export-request") {
52
+ observedPolicy = e.data;
53
+ continue;
54
+ }
55
+ if (t === "usage" || ((t === "compaction" || t === "branch_summary") && e.usage)) {
56
+ const kind = t === "usage" ? e.kind : t;
57
+ auxiliary.push({ kind, timestamp: hhmmss(ts), beforeRequest: requests.length + 1,
58
+ provider: e.provider ?? "Unknown", model: e.model ?? "Unknown", usage: e.usage ?? {} });
59
+ if (kind === "cache_warm" && ((e.usage?.cacheRead ?? 0) + (e.usage?.cacheWrite ?? 0)) > 0) {
60
+ warmByKey.set(`${e.provider}/${e.model}`, timeMs(ts));
61
+ }
62
+ if (t === "usage") continue;
63
+ }
64
+ if (t === "context_edit" || t === "branch_summary") {
65
+ addEvent(requests.length + 1, "context", t === "context_edit" ? "Context edit" : "Branch summary", ts);
66
+ warmByKey.clear();
67
+ continue;
68
+ }
44
69
  if (t === "compaction") {
70
+ warmByKey.clear();
45
71
  compactions += 1;
46
72
  addEvent(requests.length + 1, "compaction", "Compaction", ts);
47
73
  continue;
@@ -98,25 +124,33 @@ export function parseEntries(entries) {
98
124
  }
99
125
  prevKey = key;
100
126
 
101
- const tsMs = Date.parse(String(ts || ""));
102
- let longCacheUntil = longCacheUntilByKey.get(key) || 0;
103
- if (u.cacheRead === 0 && u.cacheWrite > 0 && !u.cacheWrite1h) {
104
- longCacheUntil = 0;
105
- longCacheUntilByKey.delete(key);
106
- }
107
- if (u.cacheWrite1h > 0 && Number.isFinite(tsMs)) {
108
- longCacheUntil = Math.max(longCacheUntil, tsMs + LONG_TTL_MINUTES * 60000);
109
- longCacheUntilByKey.set(key, longCacheUntil);
110
- }
111
- const cacheTtlMinutes = Number.isFinite(tsMs) && tsMs <= longCacheUntil
112
- ? LONG_TTL_MINUTES
113
- : DEFAULT_TTL_MINUTES;
127
+ const startMs = timeMs(m.timestamp);
128
+ const durationMs = Number.isFinite(m.durationMs) && m.durationMs >= 0 ? m.durationMs : null;
129
+ const previous = requests.at(-1);
130
+ const startGapMs = startMs !== null && previous?.startMs !== null && previous?.startMs !== undefined
131
+ ? startMs - previous.startMs : null;
132
+ const idleMs = startMs !== null && previous?.endMs !== null && previous?.endMs !== undefined
133
+ ? Math.max(0, startMs - previous.endMs) : null;
134
+ const policy = observedPolicy?.messageTimestamp === m.timestamp && observedPolicy?.provider === provider && observedPolicy?.model === model && observedPolicy?.api === api
135
+ ? observedPolicy.policy : null;
136
+ observedPolicy = undefined;
137
+ const references = new Set((Array.isArray(policy?.explicitTtlsSeconds) ? policy.explicitTtlsSeconds : [])
138
+ .filter((seconds) => Number.isFinite(seconds) && seconds > 0));
139
+ // A reported one-hour write is a policy reference, not a server expiry time.
140
+ // Never propagate a guessed expiry or downgrade historical hits to five minutes.
141
+ if (u.cacheWrite1h > 0) references.add(3600);
142
+ const mixedWrites = u.cacheWrite1h > 0 && u.cacheWrite > u.cacheWrite1h;
143
+ const policyReferenceSeconds = !mixedWrites && references.size === 1 ? [...references][0] : null;
114
144
 
115
145
  const cost = u.cost && typeof u.cost.total === "number" ? u.cost.total : null;
116
146
  requests.push({
117
147
  number: num,
118
148
  isoTs: String(ts || ""),
119
- timestamp: hhmmss(ts),
149
+ timestamp: hhmmss(startMs) || "Unknown",
150
+ startMs, durationMs, startGapMs, idleMs,
151
+ endMs: startMs !== null && durationMs !== null ? startMs + durationMs : null,
152
+ lastWarmAt: warmByKey.get(key) ?? null,
153
+ policy, policyReferenceSeconds,
120
154
  provider,
121
155
  model,
122
156
  api,
@@ -125,9 +159,8 @@ export function parseEntries(entries) {
125
159
  cached,
126
160
  cacheWrite,
127
161
  cacheWrite1h: u.cacheWrite1h || 0,
128
- cacheTtlMinutes,
129
162
  output,
130
- reasoning: u.reasoning || 0,
163
+ reasoning: Number.isFinite(u.reasoning) ? u.reasoning : null,
131
164
  stopReason: m.stopReason || "",
132
165
  estimatedCost: cost,
133
166
  gapTools,
@@ -138,7 +171,7 @@ export function parseEntries(entries) {
138
171
  }
139
172
 
140
173
  const toolCalls = Object.entries(toolCounts).sort((a, b) => b[1] - a[1]);
141
- return { requests, events, toolCalls, compactions };
174
+ return { requests, events, toolCalls, compactions, auxiliary };
142
175
  }
143
176
 
144
177
  // ---------------------------------------------------------------------------
@@ -160,7 +193,7 @@ const SERIES_COLORS = {
160
193
  "cache writes": ["#9cffb1", "#166534"],
161
194
  fresh: ["#ff4f4f", "#b91c1c"],
162
195
  request: ["#ff4f4f", "#b91c1c"],
163
- cumulative: ["#93c5fd", "#2563eb"],
196
+ "segment cumulative": ["#93c5fd", "#2563eb"],
164
197
  output: ["#c084fc", "#9333ea"],
165
198
  reasoning: ["#9cffb1", "#166534"],
166
199
  event: ["#a7f3f0", "#0f766e"],
@@ -181,6 +214,27 @@ function fmtCost(v) {
181
214
 
182
215
  const fmtInt = (n) => n.toLocaleString("en-US");
183
216
  const fmtPct1 = (x) => (x * 100).toFixed(1) + "%";
217
+ const fmtDuration = (ms) => Number.isFinite(ms) ? `${(ms / 1000).toFixed(1)}s` : "Unknown";
218
+
219
+ function policyLabel(request) {
220
+ const reference = request.policyReferenceSeconds;
221
+ const model = request.policy?.modelReference;
222
+ const modelText = model && Object.keys(model).length
223
+ ? `; model reference ${Object.entries(model).map(([tier, seconds]) => `${tier}=${seconds}s`).join(", ")}` : "";
224
+ const retention = request.policy?.retention ? `; observed retention=${request.policy.retention}` : "";
225
+ return `${Number.isFinite(reference) ? `${reference}s recorded reference` : "Unknown"}${retention}${modelText}; server expiry Unknown`;
226
+ }
227
+
228
+ function renderAuxiliaryUsage(auxiliary = []) {
229
+ const count = (value) => Number.isFinite(value) ? fmtInt(value) : "Unknown";
230
+ const rows = auxiliary.map((entry) => `<tr><td>${esc(entry.kind)}</td><td>${esc(entry.timestamp)}</td>` +
231
+ `<td>${esc(entry.provider)}</td><td>${esc(entry.model)}</td>` +
232
+ ["input", "cacheRead", "cacheWrite", "output"].map((field) => `<td>${count(entry.usage[field])}</td>`).join("") + "</tr>").join("");
233
+ return `<div class="usage-panel"><h2>Auxiliary usage (whole branch)</h2>` +
234
+ `<p class="usage-note">Completed usage records only: cache warming, compaction and other auxiliary calls. Excluded from main-request charts and totals. A completed warm call is not proof that the next main prefix will hit. Planned warming is not counted.</p>` +
235
+ `<div class="usage-table-wrap"><table><thead><tr><th>Kind</th><th>Recorded completion</th><th>Provider</th><th>Model</th><th>Fresh</th><th>Cached</th><th>Written</th><th>Output</th></tr></thead>` +
236
+ `<tbody>${rows || '<tr><td colspan="8">No auxiliary usage recorded.</td></tr>'}</tbody></table></div></div>`;
237
+ }
184
238
 
185
239
  // ---------------------------------------------------------------------------
186
240
  // _line_chart port
@@ -271,7 +325,7 @@ function lineChart(title, series, opts = {}) {
271
325
  // every index while the visible broken polylines still draw the gaps.
272
326
  const full = `<polyline class="series-line" data-dark="${dark}" data-light="${light}" visibility="hidden" fill="none" stroke="${dark}" stroke-width="3" ` +
273
327
  `points="${values.map((v, i) => point(i, Number.isFinite(v) ? v : 0).map((c) => c.toFixed(1)).join(",")).join(" ")}"/>`;
274
- const labels = values.map((v) => !Number.isFinite(v) ? "N/A" : (percent ? `${(v * 100).toFixed(1)}%` : fmtInt(Math.trunc(v)))).join("|");
328
+ const labels = values.map((v) => !Number.isFinite(v) ? "Unknown" : (percent ? `${(v * 100).toFixed(1)}%` : fmtInt(Math.trunc(v)))).join("|");
275
329
  parts.push(
276
330
  `<g class="series" data-series-id="${seriesIndex}" data-name="${esc(name, true)}" data-labels="${esc(labels, true)}">` +
277
331
  full +
@@ -291,7 +345,7 @@ function lineChart(title, series, opts = {}) {
291
345
  );
292
346
  });
293
347
  parts.push(
294
- `<text class="axis-label" x="${(left + plotWidth / 2).toFixed(1)}" y="${height - 4}" text-anchor="middle">Model request</text></svg>`,
348
+ `<text class="axis-label" x="${(left + plotWidth / 2).toFixed(1)}" y="${height - 4}" text-anchor="middle">Main request sequence (not time)</text></svg>`,
295
349
  );
296
350
  return parts.join("");
297
351
  }
@@ -309,8 +363,6 @@ const figure = (chart) =>
309
363
 
310
364
  const MISS_THRESHOLD = 0.5; // hit rate below this gets an explanation row
311
365
  const PREFIX_REUSE_THRESHOLD = 0.5;
312
- const DEFAULT_TTL_MINUTES = 5;
313
- const LONG_TTL_MINUTES = 60;
314
366
 
315
367
  function reusablePrefix(request) {
316
368
  const prompt = request.fresh + request.cached + request.cacheWrite;
@@ -319,10 +371,6 @@ function reusablePrefix(request) {
319
371
  return explicitlyCached > 0 ? explicitlyCached : prompt;
320
372
  }
321
373
 
322
- function ttlMinutesAfter(request) {
323
- return request.cacheTtlMinutes ?? (request.cacheWrite1h > 0 ? LONG_TTL_MINUTES : DEFAULT_TTL_MINUTES);
324
- }
325
-
326
374
  export function analyzeMisses(requests, events) {
327
375
  const evByReq = new Map();
328
376
  for (const ev of events) {
@@ -347,30 +395,34 @@ export function analyzeMisses(requests, events) {
347
395
  const prefixReuse = expectedPrefix > 0 ? Math.min(1, r.cached / expectedPrefix) : 0;
348
396
 
349
397
  if (i === 0) {
350
- causes.push(["first request", "cold session: nothing cached yet; the prefix can only be reused by a later request"]);
398
+ causes.push(r.number === 1
399
+ ? ["first request", "first recorded main request; earlier server cache state is unknown, so a cold start is not established"]
400
+ : ["segment boundary", "first request in this view, not the first request of the session"]);
351
401
  }
352
402
  if (prev && (prev.provider !== r.provider || prev.model !== r.model)) {
353
- causes.push(["model switch", `${prev.provider}/${prev.model} -> ${r.provider}/${r.model}: prefix invalidated by provider/model change`]);
403
+ causes.push(["model switch", `${prev.provider}/${prev.model} -> ${r.provider}/${r.model}: provider/model changed; cache reuse across routes is unknown`]);
354
404
  }
355
405
  for (const ev of evByReq.get(r.number) || []) {
356
406
  if (ev.kind === "compaction") {
357
- causes.push(["compaction", "context was rewritten by compaction; cache rebuilds on the new prefix"]);
407
+ causes.push(["compaction", "observed compaction rewrote context; a cache rebuild is possible, not proven"]);
408
+ } else if (ev.kind === "context") {
409
+ causes.push(["context edit", `${ev.label}: recorded context transformation; cache impact is unknown`]);
358
410
  } else if (ev.kind === "model" && r.number > 1 && !causes.some(([c]) => c === "model switch")) {
359
- causes.push(["model switch", `${ev.label}: prefix invalidated by provider/model change`]);
411
+ causes.push(["model switch", `${ev.label}: provider/model changed; cache reuse across routes is unknown`]);
360
412
  } else if (ev.kind === "thinking" && r.number > 1) {
361
413
  causes.push(["thinking change", `${ev.label}: request parameters changed`]);
362
414
  }
363
415
  }
364
- if (prev && r.isoTs && prev.isoTs) {
365
- const gapMin = (Date.parse(r.isoTs) - Date.parse(prev.isoTs)) / 60000;
366
- const ttlMinutes = ttlMinutesAfter(prev);
367
- if (gapMin > ttlMinutes && prefixReuse < PREFIX_REUSE_THRESHOLD && !causes.some(([c]) => c === "compaction" || c === "model switch")) {
368
- causes.push(["possible TTL expiry", `idle gap ${gapMin.toFixed(0)}min > ${ttlMinutes}min heuristic; actual retention depends on the provider`]);
416
+ if (prev && r.startMs !== null && prev.startMs !== null && r.startMs > prev.startMs) {
417
+ const warmed = r.lastWarmAt !== null && r.lastWarmAt > prev.startMs && r.lastWarmAt <= r.startMs;
418
+ const reference = prev.policyReferenceSeconds;
419
+ if (!warmed && reference > 0 && (r.startMs - prev.startMs) / 1000 > reference && prefixReuse < PREFIX_REUSE_THRESHOLD && !causes.length) {
420
+ causes.push(["possible TTL expiry", `request-start gap ${((r.startMs - prev.startMs) / 60000).toFixed(1)}min crosses a recorded ${(reference / 60).toFixed(0)}min policy reference; not an idle gap or a server expiry measurement`]);
369
421
  }
370
422
  }
371
423
  if (prev && prefixReuse < PREFIX_REUSE_THRESHOLD && !causes.length) {
372
- const writeDetail = r.cacheWrite > 0 ? `; provider also reported ${r.cacheWrite.toLocaleString("en-US")} tokens written for the replacement prefix` : "";
373
- causes.push(["cache rebuild", `only ${(prefixReuse * 100).toFixed(1)}% of the prior reusable prefix was read; the cache was likely lost or invalidated${writeDetail}`]);
424
+ const writeDetail = r.cacheWrite > 0 ? `; provider also reported ${r.cacheWrite.toLocaleString("en-US")} tokens written (prefix identity unknown)` : "";
425
+ causes.push(["cache rebuild", `cache reads equal ${(prefixReuse * 100).toFixed(1)}% of the prior reported prompt reference; possible rebuild or changed prefix; actual server state and cause are unknown${writeDetail}`]);
374
426
  }
375
427
  if (r.cacheWrite > 0 && !causes.length) {
376
428
  causes.push(["cache write", `provider reported ${r.cacheWrite.toLocaleString("en-US")} tokens written; a low read-hit rate is expected while a prefix is created or extended`]);
@@ -379,10 +431,10 @@ export function analyzeMisses(requests, events) {
379
431
  const toolChars = (r.gapTools || []).reduce((sum, tool) => sum + tool.chars, 0);
380
432
  const toolTokens = Math.round(toolChars / 4);
381
433
  const toolDetail = toolTokens > 0 ? `, including ~${toolTokens.toLocaleString("en-US")} estimated tool-output tokens` : "";
382
- causes.push(["new input", `reused ${(prefixReuse * 100).toFixed(1)}% of the prior prefix; ${r.fresh.toLocaleString("en-US")} fresh tokens were newly appended${toolDetail}`]);
434
+ causes.push(["new input", `cache reads equal ${(prefixReuse * 100).toFixed(1)}% of the prior reported prompt reference; ${r.fresh.toLocaleString("en-US")} fresh tokens were reported; appended content is a possible explanation${toolDetail}`]);
383
435
  }
384
436
  if (!causes.length) {
385
- causes.push(["unclassified", "no rule matched; usually new content appended beyond the cached prefix (large tool results / long user input)"]);
437
+ causes.push(["unclassified", "no causal evidence matched; the server cache state is unknown"]);
386
438
  }
387
439
 
388
440
  for (const [cause] of causes) {
@@ -395,8 +447,7 @@ export function analyzeMisses(requests, events) {
395
447
  }
396
448
 
397
449
  const summaryArr = [...summary.entries()].sort((a, b) => b[1].fresh - a[1].fresh);
398
- return { misses, summary: summaryArr, noCacheReporting, threshold: MISS_THRESHOLD,
399
- ttlMinutes: DEFAULT_TTL_MINUTES, longTtlMinutes: LONG_TTL_MINUTES };
450
+ return { misses, summary: summaryArr, noCacheReporting, threshold: MISS_THRESHOLD };
400
451
  }
401
452
 
402
453
  // Streak detection: many consecutive requests stuck in a mid hit-rate band.
@@ -419,7 +470,7 @@ export function analyzeStreaks(requests, events = []) {
419
470
  let gaps = 0; // consecutive non-member requests inside the current run
420
471
  let prevKey = null;
421
472
  const resetBefore = new Set(events
422
- .filter((event) => event.kind === "compaction" || event.kind === "model")
473
+ .filter((event) => event.kind === "compaction" || event.kind === "model" || event.kind === "context")
423
474
  .map((event) => event.requestNumber));
424
475
  const close = () => {
425
476
  if (cur && cur.hits.length >= STREAK_MIN_LEN) streaks.push(cur);
@@ -479,13 +530,13 @@ function renderStreakPanel(streaks) {
479
530
  const pct = (x) => (x * 100).toFixed(1) + "%";
480
531
  return `<tr><td>#${s.from}–#${s.to}</td><td>${s.hits.length}</td><td>${pct(s.avgHit)}</td>` +
481
532
  `<td>${fmtInt(s.fresh)}</td>` +
482
- `<td style="white-space:normal">tool outputs: ${tools || "-"} · assistant replies: ${fmtInt(s.replyTokens)} · trace explains ~${pct(s.coverage)} of fresh</td></tr>`;
533
+ `<td style="white-space:normal">tool outputs: ${tools || "-"} · assistant replies: ${fmtInt(s.replyTokens)} · trace estimate / fresh ~${pct(s.coverage)} of fresh</td></tr>`;
483
534
  })
484
535
  .join("");
485
536
  return `<div class="usage-panel"><h2>Hit-rate streak analysis</h2>` +
486
537
  `<p class="usage-note">Rule-based, no LLM. A streak is ${STREAK_MIN_LEN}+ requests (single healthy outlier tolerated) with hit rate ` +
487
538
  `${(STREAK_LOW * 100).toFixed(0)}–${(STREAK_HIGH * 100).toFixed(1)}% (requests reporting cacheWrite break the streak; fresh ≥ ${STREAK_MIN_FRESH} tok, prefix ≥ ${STREAK_MIN_PROMPT}): ` +
488
- `usually each turn appends large fresh content (big tool outputs / long replies), not a cache failure. ` +
539
+ `appended tool outputs or replies are possible contributors, not proof of cache health or failure. ` +
489
540
  `Below ${(MISS_THRESHOLD * 100).toFixed(0)}% see the miss panel above. Tool tokens estimated at 4 chars/token.</p>` +
490
541
  `<div class="usage-table-wrap" style="max-height:260px"><table>` +
491
542
  `<thead><tr><th>Requests</th><th>Turns</th><th>Avg hit</th><th>Total fresh</th><th>Trace (fresh composition)</th></tr></thead>` +
@@ -508,9 +559,9 @@ function renderMissPanel(miss) {
508
559
  .join("");
509
560
  return `<div class="usage-panel"><h2>Cache miss analysis</h2>` +
510
561
  `<p class="usage-note">Rule-based attribution, no LLM. Rows are requests below ${(miss.threshold * 100).toFixed(0)}% hit rate. ` +
511
- `TTL attribution uses a ${miss.ttlMinutes}min idle-gap heuristic, or ${miss.longTtlMinutes}min after a reported 1h cache write; actual retention is provider-specific.</p>` +
562
+ `Observed usage and context events are facts; causes are possibilities. Policy references are not measured expiry times. Without historical request parameters, retention is unknown. No universal TTL is assumed.</p>` +
512
563
  `<div class="usage-table-wrap" style="max-height:340px"><table>` +
513
- `<thead><tr><th>#</th><th>Time</th><th>Hit rate</th><th>Fresh</th><th>Cause</th></tr></thead>` +
564
+ `<thead><tr><th>#</th><th>Time</th><th>Hit rate</th><th>Fresh</th><th>Evidence / possible cause</th></tr></thead>` +
514
565
  `<tbody>${rows || `<tr><td colspan="5" class="empty">None</td></tr>`}</tbody></table></div>` +
515
566
  `<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;margin-top:12px">${summaryHtml}</div></div>`;
516
567
  }
@@ -521,7 +572,7 @@ function renderMissPanel(miss) {
521
572
 
522
573
  export function buildContextSegments(requests, events) {
523
574
  const starts = new Set(events
524
- .filter((event) => (event.kind === "compaction" || event.kind === "model") && event.requestNumber >= 1 && event.requestNumber <= requests.length)
575
+ .filter((event) => (event.kind === "compaction" || event.kind === "model" || event.kind === "context") && event.requestNumber >= 1 && event.requestNumber <= requests.length)
525
576
  .map((event) => event.requestNumber));
526
577
  const segments = [];
527
578
  let start = 0;
@@ -571,7 +622,7 @@ function renderDashboardView(requests, events, toolCalls, compactions, scopeLabe
571
622
  const contextBreakBefore = new Set();
572
623
  const cacheBreakBefore = new Set();
573
624
  const resetBefore = new Set(events
574
- .filter((event) => event.kind === "compaction" || event.kind === "model")
625
+ .filter((event) => event.kind === "compaction" || event.kind === "model" || event.kind === "context")
575
626
  .map((event) => event.requestNumber));
576
627
  const cumulativeRates = [];
577
628
  let runCached = 0, runPrompt = 0;
@@ -581,7 +632,7 @@ function renderDashboardView(requests, events, toolCalls, compactions, scopeLabe
581
632
  for (let index = 0; index < requests.length; index++) {
582
633
  const r = requests[index];
583
634
  const key = `${r.provider}/${r.model}`;
584
- const contextReset = index > 0 && events.some((event) => (event.kind === "compaction" || event.kind === "model") && event.requestNumber === r.number);
635
+ const contextReset = index > 0 && events.some((event) => (event.kind === "compaction" || event.kind === "model" || event.kind === "context") && event.requestNumber === r.number);
585
636
  const cacheReset = index > 0 && (resetBefore.has(r.number) || (previousKey !== null && key !== previousKey));
586
637
  if (contextReset) contextBreakBefore.add(index);
587
638
  if (cacheReset) {
@@ -633,7 +684,7 @@ function renderDashboardView(requests, events, toolCalls, compactions, scopeLabe
633
684
  ];
634
685
  if (cacheHitRate !== null) {
635
686
  charts.push(figure(lineChart("Cache hit rate",
636
- [["request", rates], ["cumulative", cumulativeRates]],
687
+ [["request", rates], ["segment cumulative", cumulativeRates]],
637
688
  { yMax: 1.0, percent: true, timestamps, requestNumbers, events: eventMarkers, breakBefore: cacheBreakBefore })));
638
689
  }
639
690
  charts.push(figure(lineChart("Output and reasoning tokens",
@@ -647,7 +698,10 @@ function renderDashboardView(requests, events, toolCalls, compactions, scopeLabe
647
698
  `<td>${esc(r.model)}</td><td>${fmtInt(r.fresh)}</td><td>${fmtInt(r.cached)}</td>` +
648
699
  `<td>${fmtInt(r.cacheWrite)}</td><td>${fmtInt(r.fresh + r.cached + r.cacheWrite)}</td>` +
649
700
  `<td>${showHit ? fmtPct1(r.cached / Math.max(1, r.fresh + r.cached + r.cacheWrite)) : "N/A"}</td>` +
650
- `<td>${fmtInt(r.output)}</td><td>${esc(r.stopReason, true)}</td></tr>`,
701
+ `<td>${fmtInt(r.output)}</td><td>${Number.isFinite(r.reasoning) ? fmtInt(r.reasoning) : "Unknown"}</td>` +
702
+ `<td>${fmtDuration(r.durationMs)}</td><td>${fmtDuration(r.startGapMs)}</td><td>${fmtDuration(r.idleMs)}</td>` +
703
+ `<td>${esc(hhmmss(r.isoTs))}</td><td>${esc(policyLabel(r))}</td>` +
704
+ `<td>${r.lastWarmAt === null ? "Unknown" : esc(hhmmss(r.lastWarmAt))}</td><td>${esc(r.stopReason, true)}</td></tr>`,
651
705
  )
652
706
  .join("");
653
707
 
@@ -657,15 +711,16 @@ function renderDashboardView(requests, events, toolCalls, compactions, scopeLabe
657
711
 
658
712
  return (
659
713
  `<div class="usage-cards">${cardsHtml}</div>` +
660
- `<p class="usage-note">Prompt and output lines break at a Context segment boundary (compaction or model change). Cache-window cumulative hit rate also resets at a model change. ` +
661
- `TTL and service interruptions are shown only when recorded or inferred by the miss analysis; they do not create a Context segment.</p>` +
714
+ `<p class="usage-note">Prompt and output lines break at a Context segment boundary (compaction, context edit, or model change). Segment cumulative hit rate resets at each such boundary. ` +
715
+ `The x-axis is request sequence, not elapsed time. Cache hit rate = cacheRead / (input + cacheRead + cacheWrite). These are provider/Pi-reported usage values, not server cache telemetry. Auxiliary calls are separate; unreported reasoning is Unknown.</p>` +
662
716
  `<div class="usage-charts">${charts.join("")}</div>` +
663
717
  renderMissPanel(analyzeMisses(requests, events)) +
664
718
  renderStreakPanel(analyzeStreaks(requests, events)) +
665
719
  `<div class="usage-details">` +
666
720
  `<div class="usage-panel"><h2>Requests</h2><div class="usage-table-wrap"><table>` +
667
- `<thead><tr><th>#</th><th>Time</th><th>Provider</th><th>Response Provider</th><th>Model</th>` +
721
+ `<thead><tr><th>#</th><th>Request start</th><th>Provider</th><th>Response Provider</th><th>Model</th>` +
668
722
  `<th>Fresh</th><th>Cached</th><th>Written</th><th>Prompt</th><th>Hit rate</th><th>Output</th>` +
723
+ `<th>Reasoning</th><th>Response duration</th><th>Start-to-start</th><th>Gap after response</th><th>Recorded at</th><th>Policy reference (not expiry)</th><th>Last completed warm with cache usage</th>` +
669
724
  `<th>Stop</th></tr></thead><tbody>${rows}</tbody></table></div></div>` +
670
725
  `<div class="usage-panel"><h2>Tool calls</h2>${toolRows}</div>` +
671
726
  `</div>`
@@ -675,7 +730,7 @@ function renderDashboardView(requests, events, toolCalls, compactions, scopeLabe
675
730
  export function renderDashboard(data) {
676
731
  const { requests, events, toolCalls, compactions } = data;
677
732
  if (!requests.length) {
678
- return `<p class="empty">No assistant responses with token usage were found in this session.</p>`;
733
+ return `<p class="empty">No main assistant responses with token usage were found in this session.</p>` + renderAuxiliaryUsage(data.auxiliary);
679
734
  }
680
735
 
681
736
  const segments = buildContextSegments(requests, events);
@@ -716,7 +771,7 @@ export function renderDashboard(data) {
716
771
  return `<div class="usage-view-controls"><label for="context-segment-select">View</label>` +
717
772
  `<select id="context-segment-select" data-context-segment-select>${options}</select>` +
718
773
  `<span>Default: latest Context segment. All-history values remain available; cache metrics reset where compaction rewrites the prefix or the model changes.</span></div>` +
719
- views +
774
+ views + renderAuxiliaryUsage(data.auxiliary) +
720
775
  `<div class="usage-tooltip" role="status" aria-live="polite"></div>`;
721
776
  }
722
777
 
@@ -778,6 +833,12 @@ document.querySelectorAll(".usage-chart").forEach((chart) => {
778
833
  if (!tooltip) return;
779
834
  const m = tooltip.textContent.match(/^Request (\\d+)/);
780
835
  if (!m) return;
836
+ const index = Number(m[1]) - 1;
837
+ chart.querySelectorAll(".series").forEach((series) => {
838
+ if ((series.dataset.labels || "").split("|")[index] === "Unknown") {
839
+ series.querySelector(".hover-point")?.setAttribute("visibility", "hidden");
840
+ }
841
+ });
781
842
  const global = nums[Number(m[1]) - 1];
782
843
  if (global && global !== Number(m[1])) {
783
844
  tooltip.textContent = tooltip.textContent.replace(/^Request \\d+/, "Request " + global);
@@ -11,7 +11,7 @@
11
11
  // Run: node tests/e2e-browser.mjs (from packages/cache-export)
12
12
 
13
13
  import { spawn, execFileSync } from "node:child_process";
14
- import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
14
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
15
15
  import net from "node:net";
16
16
  import { tmpdir } from "node:os";
17
17
  import { join } from "node:path";
@@ -183,6 +183,8 @@ async function run() {
183
183
  }
184
184
  const usesWindowsChrome = IS_WSL && /\.exe$/i.test(chrome);
185
185
  let windowsPid = 0;
186
+ let chromeProcess;
187
+ let cdp;
186
188
  const port = await freePort();
187
189
  const userDataDir = usesWindowsChrome
188
190
  ? commandOutput("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command",
@@ -202,7 +204,8 @@ async function run() {
202
204
  `$p = Start-Process -FilePath '${winPath}' -ArgumentList @(${quoted}) -PassThru; $p.Id`]);
203
205
  windowsPid = Number(out.trim());
204
206
  } else {
205
- spawn(chrome, chromeArgs, { stdio: "ignore", detached: true }).unref();
207
+ chromeProcess = spawn(chrome, chromeArgs, { stdio: "ignore", detached: true });
208
+ chromeProcess.unref();
206
209
  }
207
210
 
208
211
  try {
@@ -212,7 +215,7 @@ async function run() {
212
215
  }
213
216
  const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
214
217
  const page = list.find((t) => t.type === "page");
215
- const cdp = await cdpConnect(page.webSocketDebuggerUrl);
218
+ cdp = await cdpConnect(page.webSocketDebuggerUrl);
216
219
  await cdp.send("Page.enable");
217
220
  await cdp.send("Emulation.setDeviceMetricsOverride", { width: 1600, height: 1200, deviceScaleFactor: 1, mobile: false });
218
221
  await cdp.send("Page.navigate", { url: "data:text/html;charset=utf-8," + encodeURIComponent(html) });
@@ -304,11 +307,48 @@ async function run() {
304
307
  ev && ev.dataRequest === "1" && ev.info.includes("before request 25"),
305
308
  JSON.stringify(ev));
306
309
 
307
- cdp.close();
310
+ // Exercise the actual native selector with CDP input, not a synthetic change.
311
+ const selectBox = await evalJs(`(() => {
312
+ const select = document.querySelector("[data-context-segment-select]");
313
+ select.scrollIntoView(); const r = select.getBoundingClientRect();
314
+ return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
315
+ })()`);
316
+ for (const type of ["mousePressed", "mouseReleased"]) await cdp.send("Input.dispatchMouseEvent", { type, ...selectBox, button: "left", clickCount: 1 });
317
+ for (const [key, code] of [["Home", 36], ["Enter", 13]]) {
318
+ for (const type of ["keyDown", "keyUp"]) await cdp.send("Input.dispatchKeyEvent", { type, key, windowsVirtualKeyCode: code });
319
+ }
320
+ check("e2e real click/keyboard selects all-history", await evalJs(`document.querySelector("[data-context-segment-select]").value === "all"`));
321
+ check("e2e scope, formula, auxiliary and segment cumulative labels", await evalJs(`document.body.textContent.includes("cacheRead / (input + cacheRead + cacheWrite)") && document.body.textContent.includes("Auxiliary usage") && !!document.querySelector('[data-name="segment cumulative"]')`));
322
+ const plot = await evalJs(`(() => {
323
+ const chart = document.querySelector('[data-context-view="all"] [aria-label="Output and reasoning tokens"]');
324
+ chart.scrollIntoView({ block: "center" }); const r = chart.getBoundingClientRect();
325
+ return { x: r.x + r.width * 0.6, y: r.y + r.height * 0.6 };
326
+ })()`);
327
+ await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", ...plot });
328
+ check("e2e unknown reasoning is neither a zero line nor a zero hover point", await evalJs(`(() => {
329
+ const series = document.querySelector('[data-context-view="all"] [data-name="reasoning"]');
330
+ return series.dataset.labels.split("|").every(value => value === "Unknown") &&
331
+ !series.querySelector(".series-line:not([visibility])") &&
332
+ series.querySelector(".hover-point").getAttribute("visibility") === "hidden" &&
333
+ document.querySelector(".usage-tooltip").textContent.includes("Unknown");
334
+ })()`));
335
+ if (process.env.CACHE_EXPORT_SCREENSHOT_DIR) {
336
+ mkdirSync(process.env.CACHE_EXPORT_SCREENSHOT_DIR, { recursive: true });
337
+ const { data } = await cdp.send("Page.captureScreenshot", { format: "png" });
338
+ writeFileSync(join(process.env.CACHE_EXPORT_SCREENSHOT_DIR, "unknown-reasoning.png"), Buffer.from(data, "base64"));
339
+ await evalJs("window.scrollTo(0, 0)");
340
+ const overview = await cdp.send("Page.captureScreenshot", { format: "png" });
341
+ writeFileSync(join(process.env.CACHE_EXPORT_SCREENSHOT_DIR, "screenshot.png"), Buffer.from(overview.data, "base64"));
342
+ }
343
+ } finally {
344
+ if (cdp) { await cdp.send("Browser.close").catch(() => {}); cdp.close(); }
308
345
  if (usesWindowsChrome && windowsPid) {
309
346
  try { execFileSync("taskkill.exe", ["/PID", String(windowsPid), "/T", "/F"]); } catch { /* best effort */ }
310
347
  }
311
- } finally {
348
+ if (chromeProcess?.pid) {
349
+ try { process.kill(-chromeProcess.pid, "SIGTERM"); } catch (error) { if (error.code !== "ESRCH") throw error; }
350
+ }
351
+ await new Promise((resolve) => setTimeout(resolve, 300));
312
352
  if (!usesWindowsChrome) rmSync(userDataDir, { recursive: true, force: true });
313
353
  }
314
354
  }
@@ -0,0 +1,113 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import register from "../extensions/cache-export.ts";
6
+ import { parseEntries, analyzeMisses, buildContextSegments, renderDashboard } from "../src/render.ts";
7
+
8
+ const origin = Date.parse("2026-01-01T00:00:00Z");
9
+ const iso = (seconds) => new Date(origin + seconds * 1000).toISOString();
10
+ const usage = (read = 0, write = 0) => ({ input: 10000, cacheRead: read, cacheWrite: write, output: 100 });
11
+ const request = (start, duration, read = 0, extraUsage = {}) => ({
12
+ type: "message", timestamp: iso(start + duration),
13
+ message: { role: "assistant", provider: "p", model: "m", api: "openai-codex-responses", timestamp: origin + start * 1000, durationMs: duration * 1000, content: [], usage: { ...usage(read), ...extraUsage } },
14
+ });
15
+ const misses = (data) => analyzeMisses(data.requests, data.events).misses;
16
+ const hasTtl = (data) => misses(data).some((miss) => miss.causes.some(([cause]) => cause === "possible TTL expiry"));
17
+ {
18
+ const data = parseEntries([request(0, 6, 50000), request(12, 408)]);
19
+ assert.equal(data.requests[1].startGapMs, 12000, "response persistence times are not request starts");
20
+ assert.equal(data.requests[1].idleMs, 6000);
21
+ assert.equal(data.requests[1].durationMs, 408000);
22
+ assert.equal(hasTtl(data), false);
23
+ assert.equal(data.requests[0].policyReferenceSeconds, null, "never assign Codex a generic five-minute TTL");
24
+ assert.equal(data.requests[0].reasoning, null, "unreported reasoning is unknown, not zero");
25
+ const html = renderDashboard(data);
26
+ assert.match(html, /Unknown/);
27
+ assert.match(html, /segment cumulative/);
28
+ assert.match(html, /request sequence/);
29
+ }
30
+ {
31
+ const data = parseEntries([
32
+ request(0, 1, 0, { cacheWrite: 50000, cacheWrite1h: 50000 }),
33
+ request(59 * 60, 1, 50000), request(61 * 60, 1, 50000), request(70 * 60, 1),
34
+ ]);
35
+ assert.equal(data.requests[0].policyReferenceSeconds, 3600);
36
+ assert.equal(data.requests[2].policyReferenceSeconds, null, "do not invent a five-minute expiry after an old one-hour write");
37
+ assert.equal(hasTtl(data), false, "reported reads do not reveal the server's next expiry time");
38
+ }
39
+ {
40
+ const warm = { type: "usage", kind: "cache_warm", timestamp: iso(480), provider: "p", model: "m", usage: usage(50000) };
41
+ const data = parseEntries([request(0, 1, 50000), warm, { type: "compaction", timestamp: iso(540), usage: usage(10) }, request(600, 1)]);
42
+ assert.equal(data.requests.length, 2);
43
+ assert.equal(data.auxiliary.length, 2);
44
+ assert.match(renderDashboard(data), /Auxiliary usage/);
45
+ const warmed = parseEntries([request(0, 1, 50000), warm, request(600, 1)]);
46
+ assert.equal(warmed.requests[1].lastWarmAt, origin + 480000);
47
+ const planned = parseEntries([request(0, 1, 50000), { type: "custom", customType: "cache_warming_decision", timestamp: iso(480) }, request(600, 1)]);
48
+ assert.equal(planned.auxiliary.length, 0, "preparing a warm request is not completed usage");
49
+ assert.equal(planned.requests[1].lastWarmAt, null);
50
+ }
51
+ {
52
+ const data = parseEntries([request(0, 1, 50000), { type: "context_edit", timestamp: iso(30) }, request(60, 1)]);
53
+ const segments = buildContextSegments(data.requests, data.events);
54
+ assert.equal(segments.length, 2);
55
+ const segmentMiss = analyzeMisses(segments[1].requests, data.events).misses[0];
56
+ assert.ok(segmentMiss.causes.some(([cause]) => cause === "context edit"));
57
+ assert.ok(!segmentMiss.causes.some(([cause]) => cause === "first request"), "a segment is not the session's first request");
58
+ }
59
+ {
60
+ const root = mkdtempSync(join(tmpdir(), "cache-evidence-"));
61
+ try {
62
+ const handlers = new Map(), entries = [], saved = [];
63
+ let command;
64
+ register({
65
+ on: (name, handler) => handlers.set(name, handler),
66
+ appendEntry: (customType, data) => { saved.push(data); entries.push({ type: "custom", customType, data }); },
67
+ registerCommand: (_name, value) => { command = value; },
68
+ });
69
+ const ctx = { cwd: root, model: { provider: "p", id: "m", api: "openai-responses", promptCache: { short: 300, long: 86400 } }, sessionManager: { getBranch: () => entries }, ui: { notify() {} } };
70
+ const message = request(0, 1, 50000);
71
+ message.message.api = "openai-responses";
72
+ await handlers.get("turn_start")({}, ctx);
73
+ await handlers.get("before_provider_request")({ payload: { prompt_cache_retention: "24h", prompt_cache_key: "PRIVATE_KEY", input: "PRIVATE_PROMPT", headers: { Authorization: "PRIVATE_TOKEN" } } }, ctx);
74
+ await handlers.get("message_end")({ message: message.message }, ctx);
75
+ entries.push(message);
76
+ assert.equal(saved.length, 1);
77
+ assert.doesNotMatch(JSON.stringify(saved), /PRIVATE|Authorization|prompt_cache_key/);
78
+ assert.equal(parseEntries(entries).requests[0].policyReferenceSeconds, 86400);
79
+ await command.handler("report.html", ctx);
80
+ assert.ok(readFileSync(join(root, "report.html"), "utf8").includes("cache export"));
81
+ assert.equal(parseEntries([message]).requests[0].policyReferenceSeconds, null, "current model configuration must never backfill old entries");
82
+ const longGap = request(90000, 1);
83
+ const crossed = parseEntries([...entries, longGap]);
84
+ assert.equal(hasTtl(crossed), true, "crossing an observed reference may be labeled possible, never proven");
85
+ assert.match(JSON.stringify(misses(crossed)), /not an idle gap or a server expiry measurement/);
86
+ const warm = { type: "usage", kind: "cache_warm", timestamp: iso(89990), provider: "p", model: "m", usage: usage(10000) };
87
+ assert.equal(hasTtl(parseEntries([...entries, warm, longGap])), false, "do not use the main-request gap as if completed warming did not occur");
88
+ const mixedMessage = request(100000, 1, 50000, { cacheWrite: 10000, cacheWrite1h: 5000 });
89
+ mixedMessage.message.api = "anthropic-messages";
90
+ ctx.model.api = "anthropic-messages";
91
+ await handlers.get("turn_start")({}, ctx);
92
+ await handlers.get("before_provider_request")({ payload: { messages: [{ content: [
93
+ { text: "PRIVATE_TEXT", cache_control: { type: "ephemeral", ttl: "5m" } },
94
+ { cache_control: { type: "ephemeral", ttl: "1h" } },
95
+ ] }] } }, ctx);
96
+ await handlers.get("message_end")({ message: mixedMessage.message }, ctx);
97
+ const mixedEvidence = entries.at(-1);
98
+ const later = request(104001, 1);
99
+ later.message.api = "anthropic-messages";
100
+ const mixedData = parseEntries([mixedEvidence, mixedMessage, later]);
101
+ assert.deepEqual(mixedData.requests[0].policy.explicitTtlsSeconds, [300, 3600]);
102
+ assert.equal(mixedData.requests[0].policyReferenceSeconds, null, "a partial one-hour write cannot override known mixed TTLs");
103
+ assert.equal(hasTtl(mixedData), false, "do not attribute the later miss to an invented whole-request TTL");
104
+ assert.equal(parseEntries([mixedMessage]).requests[0].policyReferenceSeconds, null, "mixed write counters alone also preclude a whole-request TTL");
105
+ for (const oneHourWrite of [0, 10000]) {
106
+ const variant = structuredClone(mixedMessage);
107
+ variant.message.usage.cacheWrite1h = oneHourWrite;
108
+ assert.equal(parseEntries([mixedEvidence, variant]).requests[0].policyReferenceSeconds, null, "known mixed policy remains unknown regardless of this response's write mix");
109
+ }
110
+ assert.doesNotMatch(JSON.stringify(saved), /PRIVATE/);
111
+ } finally { rmSync(root, { recursive: true, force: true }); }
112
+ }
113
+ console.log("PASS cache evidence: timing, unknown retention/reasoning, auxiliary usage, context edits, safe history, session cwd");