@maxiaochao/pi-toolkit 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## 2026-09-09 - v0.2.5
2
+
3
+ - fix cache export: segment views lost the hover tooltip (it rendered inside the hidden all-history section); move to one page-level tooltip; e2e now asserts real rendering
4
+
5
+ ## 2026-09-09 - v0.2.4
6
+
7
+ - fix cache export: hover tooltips now work across segment breaks; context segments also break on model changes; add headless-browser e2e tests
8
+
1
9
  ## 2026-09-08 - v0.2.3
2
10
 
3
11
  - add cache export Context segment views
package/README.md CHANGED
@@ -40,7 +40,7 @@ bash scripts/install.sh
40
40
 
41
41
  The `/btw` side chat is a separate agent session. It can inspect the main conversation and repository, and its thread stays out of the main context until you close it and choose to inject a summary. Use `/btw` to open it or `/btw <question>` to ask directly. While the BTW overlay has focus, use `Up` / `Down` to scroll its transcript one line at a time and `PageUp` / `PageDown` to move by one transcript page.
42
42
 
43
- Use `/cache_export [path]` to write an interactive per-request cache dashboard. It defaults to the latest compaction-defined Context segment for current-state diagnosis, and the View selector can show any earlier segment or all session history. Compaction breaks context charts; cache-hit accumulation also resets at model changes, while TTL and other inferred interruptions remain diagnostic events rather than segment boundaries.
43
+ Use `/cache_export [path]` to write an interactive per-request cache dashboard. It defaults to the latest Context segment for current-state diagnosis, and the View selector can show any earlier segment or all session history. Compaction and model changes both break context charts into segments; cache-hit accumulation also resets at model changes, while TTL and other inferred interruptions remain diagnostic events rather than segment boundaries.
44
44
 
45
45
  The bundled `nightowl` theme is available to Pi through the package manifest and is selected automatically on npm installation only when no theme is already configured.
46
46
 
@@ -254,13 +254,21 @@ function lineChart(title, series, opts = {}) {
254
254
  if (Number.isFinite(value)) run.push(point(i, value).map((c) => c.toFixed(1)).join(","));
255
255
  });
256
256
  if (run.length) runs.push(run);
257
- const polylines = runs.map((points) =>
257
+ const broken = runs.map((points) =>
258
258
  `<polyline class="series-line" data-dark="${dark}" data-light="${light}" points="${points.join(" ")}" fill="none" stroke="${dark}" stroke-width="3"/>`,
259
259
  ).join("");
260
+ // tau's tooltip script reads `series.querySelector(".series-line").points.getItem(i)`,
261
+ // which returns only the FIRST polyline; with break splits that one is short and
262
+ // getItem(i) throws IndexSizeError past the first run, killing the tooltip. Keep a
263
+ // hidden full-coverage `.series-line` first in the group so the tooltip works at
264
+ // every index while the visible broken polylines still draw the gaps.
265
+ const full = `<polyline class="series-line" data-dark="${dark}" data-light="${light}" visibility="hidden" fill="none" stroke="${dark}" stroke-width="3" ` +
266
+ `points="${values.map((v, i) => point(i, Number.isFinite(v) ? v : 0).map((c) => c.toFixed(1)).join(",")).join(" ")}"/>`;
260
267
  const labels = values.map((v) => !Number.isFinite(v) ? "N/A" : (percent ? `${(v * 100).toFixed(1)}%` : fmtInt(Math.trunc(v)))).join("|");
261
268
  parts.push(
262
269
  `<g class="series" data-series-id="${seriesIndex}" data-name="${esc(name, true)}" data-labels="${esc(labels, true)}">` +
263
- polylines +
270
+ full +
271
+ broken +
264
272
  `<circle class="hover-point" data-dark="${dark}" data-light="${light}" r="5" fill="${dark}" visibility="hidden"/></g>`,
265
273
  );
266
274
  });
@@ -506,7 +514,7 @@ function renderMissPanel(miss) {
506
514
 
507
515
  export function buildContextSegments(requests, events) {
508
516
  const starts = new Set(events
509
- .filter((event) => event.kind === "compaction" && event.requestNumber >= 1 && event.requestNumber <= requests.length)
517
+ .filter((event) => (event.kind === "compaction" || event.kind === "model") && event.requestNumber >= 1 && event.requestNumber <= requests.length)
510
518
  .map((event) => event.requestNumber));
511
519
  const segments = [];
512
520
  let start = 0;
@@ -562,7 +570,7 @@ function renderDashboardView(requests, events, toolCalls, compactions, scopeLabe
562
570
  for (let index = 0; index < requests.length; index++) {
563
571
  const r = requests[index];
564
572
  const key = `${r.provider}/${r.model}`;
565
- const contextReset = index > 0 && events.some((event) => event.kind === "compaction" && event.requestNumber === r.number);
573
+ const contextReset = index > 0 && events.some((event) => (event.kind === "compaction" || event.kind === "model") && event.requestNumber === r.number);
566
574
  const cacheReset = index > 0 && (resetBefore.has(r.number) || (previousKey !== null && key !== previousKey));
567
575
  if (contextReset) contextBreakBefore.add(index);
568
576
  if (cacheReset) {
@@ -638,7 +646,7 @@ function renderDashboardView(requests, events, toolCalls, compactions, scopeLabe
638
646
 
639
647
  return (
640
648
  `<div class="usage-cards">${cardsHtml}</div>` +
641
- `<p class="usage-note">Prompt and output lines break at a Context segment boundary (compaction). Cache-window cumulative hit rate also resets at a model change. ` +
649
+ `<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. ` +
642
650
  `TTL and service interruptions are shown only when recorded or inferred by the miss analysis; they do not create a Context segment.</p>` +
643
651
  `<div class="usage-charts">${charts.join("")}</div>` +
644
652
  renderMissPanel(analyzeMisses(requests, events)) +
@@ -649,7 +657,7 @@ function renderDashboardView(requests, events, toolCalls, compactions, scopeLabe
649
657
  `<th>Fresh</th><th>Cached</th><th>Written</th><th>Prompt</th><th>Hit rate</th><th>Output</th>` +
650
658
  `<th>Stop</th></tr></thead><tbody>${rows}</tbody></table></div></div>` +
651
659
  `<div class="usage-panel"><h2>Tool calls</h2>${toolRows}</div>` +
652
- `</div><div class="usage-tooltip" role="status" aria-live="polite"></div>`
660
+ `</div>`
653
661
  );
654
662
  }
655
663
 
@@ -665,7 +673,9 @@ export function renderDashboard(data) {
665
673
  const segmentViews = segments.map((segment) => {
666
674
  const segmentEvents = eventsForRequests(events, segment.requests);
667
675
  const startsWithCompaction = segmentEvents.some((event) => event.kind === "compaction" && event.requestNumber === segment.requests[0].number);
668
- const label = `Context segment ${segment.number}${segment.number === latest.number ? " (latest)" : ""}`;
676
+ const first = segment.requests[0];
677
+ const model = first ? `${first.provider}/${first.model}` : "";
678
+ const label = `Context segment ${segment.number}${model ? ` · ${model}` : ""}${segment.number === latest.number ? " (latest)" : ""}`;
669
679
  return {
670
680
  id: `segment-${segment.number}`,
671
681
  label,
@@ -687,10 +697,16 @@ export function renderDashboard(data) {
687
697
  ...segmentViews.map((view) => `<section class="usage-context-view" data-context-view="${view.id}"${view.id === `segment-${latest.number}` ? "" : " hidden"}>${view.html}</section>`),
688
698
  ].join("");
689
699
 
700
+ // ONE shared tooltip for the whole page. tau's script grabs the first
701
+ // `.usage-tooltip` in the document; with one per view that element lived in
702
+ // the (hidden) all-history section, so segment views set text on an element
703
+ // whose ancestor was display:none -> nothing painted. A single tooltip
704
+ // outside the view sections renders under every view.
690
705
  return `<div class="usage-view-controls"><label for="context-segment-select">View</label>` +
691
706
  `<select id="context-segment-select" data-context-segment-select>${options}</select>` +
692
707
  `<span>Default: latest Context segment. All-history values remain available; cache metrics reset where compaction rewrites the prefix or the model changes.</span></div>` +
693
- views;
708
+ views +
709
+ `<div class="usage-tooltip" role="status" aria-live="polite"></div>`;
694
710
  }
695
711
 
696
712
  // ---------------------------------------------------------------------------
@@ -0,0 +1,298 @@
1
+ // Real-browser end-to-end test for the cache dashboard.
2
+ // Drives actual pointer events in headless Chromium via raw CDP (Node 22
3
+ // built-ins only: fetch + WebSocket) and asserts the hover tooltip keeps
4
+ // working across polyline break boundaries and inside segment views.
5
+ //
6
+ // Chrome discovery mirrors the web-browser skill (BROWSER_BIN, common Linux
7
+ // paths, Playwright cache, WSL Windows Chrome). If no browser is found the
8
+ // test prints SKIP and exits 0 so CI without Chrome still passes; if a
9
+ // browser IS found, failing assertions exit 1.
10
+ //
11
+ // Run: node tests/e2e-browser.mjs (from extensions/cache-export)
12
+
13
+ import { spawn, execFileSync } from "node:child_process";
14
+ import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
15
+ import net from "node:net";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { parseEntries, buildCacheExportHtml } from "../render.ts";
19
+
20
+ let pass = 0, fail = 0, skipped = 0;
21
+ function check(name, cond, detail = "") {
22
+ if (cond) { pass++; console.log(` PASS ${name}`); }
23
+ else { fail++; console.log(` FAIL ${name} ${detail}`); }
24
+ }
25
+
26
+ // --- Chrome discovery (mirrors the web-browser skill) ---------------------
27
+
28
+ const IS_WSL = Boolean(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
29
+
30
+ function commandOutput(command, args) {
31
+ return execFileSync(command, args, { encoding: "utf8" }).trim();
32
+ }
33
+
34
+ function findChrome() {
35
+ if (process.env.BROWSER_BIN && existsSync(process.env.BROWSER_BIN)) return process.env.BROWSER_BIN;
36
+ // Prefer Linux binaries (also run fine under WSL2 and CI). Windows Chrome is
37
+ // a last resort: its launch interacts with any already-running Chrome
38
+ // singleton and can silently skip binding the debugging port.
39
+ const candidates = [
40
+ "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable",
41
+ "/usr/bin/chromium", "/usr/bin/chromium-browser",
42
+ ];
43
+ for (const p of candidates) if (existsSync(p)) return p;
44
+ // Playwright-managed Chromium builds
45
+ const pwRoot = join(process.env.HOME || tmpdir(), ".cache", "ms-playwright");
46
+ if (existsSync(pwRoot)) {
47
+ for (const dir of readdirSync(pwRoot)) {
48
+ const p = join(pwRoot, dir, "chrome-linux", "chrome");
49
+ if (existsSync(p)) return p;
50
+ }
51
+ }
52
+ for (const p of [
53
+ "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe",
54
+ "/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe",
55
+ ]) if (existsSync(p)) return p;
56
+ return null;
57
+ }
58
+
59
+ // --- tiny CDP client ------------------------------------------------------
60
+
61
+ async function cdpConnect(wsUrl, timeoutMs = 5000) {
62
+ const ws = new WebSocket(wsUrl);
63
+ const pending = new Map();
64
+ let nextId = 1;
65
+ await new Promise((resolve, reject) => {
66
+ const timer = setTimeout(() => reject(new Error("ws connect timeout")), timeoutMs);
67
+ ws.onopen = () => { clearTimeout(timer); resolve(); };
68
+ ws.onerror = () => { clearTimeout(timer); reject(new Error("ws connect error")); };
69
+ });
70
+ ws.onmessage = (event) => {
71
+ const msg = JSON.parse(event.data);
72
+ if (msg.id && pending.has(msg.id)) {
73
+ const { resolve, reject } = pending.get(msg.id);
74
+ pending.delete(msg.id);
75
+ if (msg.error) reject(new Error(msg.error.message));
76
+ else resolve(msg.result);
77
+ }
78
+ };
79
+ return {
80
+ send(method, params = {}) {
81
+ const id = nextId++;
82
+ return new Promise((resolve, reject) => {
83
+ pending.set(id, { resolve, reject });
84
+ ws.send(JSON.stringify({ id, method, params }));
85
+ });
86
+ },
87
+ close() { ws.close(); },
88
+ };
89
+ }
90
+
91
+ function freePort() {
92
+ return new Promise((resolve) => {
93
+ const srv = net.createServer();
94
+ srv.listen(0, "127.0.0.1", () => {
95
+ const port = srv.address().port;
96
+ srv.close(() => resolve(port));
97
+ });
98
+ });
99
+ }
100
+
101
+ async function waitForEndpoint(port, timeoutMs = 15000) {
102
+ const deadline = Date.now() + timeoutMs;
103
+ while (Date.now() < deadline) {
104
+ try {
105
+ const resp = await fetch(`http://127.0.0.1:${port}/json/version`);
106
+ if (resp.ok) return true;
107
+ } catch { /* not up yet */ }
108
+ await new Promise((r) => setTimeout(r, 250));
109
+ }
110
+ return false;
111
+ }
112
+
113
+ // --- fixture --------------------------------------------------------------
114
+
115
+ function entry(i, model) {
116
+ const mm = String(i % 60).padStart(2, "0");
117
+ const ss = String(Math.floor(i / 60)).padStart(2, "0");
118
+ return {
119
+ type: "message",
120
+ timestamp: `2026-02-01T00:${mm}:${ss}Z`,
121
+ message: {
122
+ role: "assistant", provider: "p", model, api: "openai-responses",
123
+ stopReason: "stop", content: [],
124
+ usage: { input: 2000, cacheRead: i % 7 === 0 ? 3000 : 90000, cacheWrite: 0, output: 200 },
125
+ },
126
+ };
127
+ }
128
+
129
+ function fixtureEntries() {
130
+ const entries = [];
131
+ for (let i = 1; i <= 24; i++) entries.push(entry(i, "m1"));
132
+ entries.push({ type: "model_change", timestamp: "2026-02-01T00:24:30Z", provider: "p", modelId: "m2" });
133
+ entries.push({ type: "compaction", timestamp: "2026-02-01T00:24:45Z" });
134
+ for (let i = 25; i <= 32; i++) entries.push(entry(i, "m2"));
135
+ entries.push({ type: "model_change", timestamp: "2026-02-01T00:32:30Z", provider: "p", modelId: "m3" });
136
+ for (let i = 33; i <= 39; i++) entries.push(entry(i, "m3"));
137
+ entries.push({ type: "compaction", timestamp: "2026-02-01T00:39:30Z" });
138
+ for (let i = 40; i <= 45; i++) entries.push(entry(i, "m3"));
139
+ return entries;
140
+ }
141
+
142
+ const HOVER_SCRIPT = `
143
+ (() => {
144
+ window.__errs = [];
145
+ window.addEventListener("error", (e) => __errs.push(String(e.message)));
146
+ const sel = document.querySelector("[data-context-segment-select]");
147
+ function setView(value) {
148
+ sel.value = value; sel.dispatchEvent(new Event("change"));
149
+ }
150
+ function hover(label, frac) {
151
+ // Only the chart inside the currently visible context view can be hovered.
152
+ const chart = [...document.querySelectorAll("svg.usage-chart")]
153
+ .find((s) => s.getAttribute("aria-label") === label &&
154
+ s.closest(".usage-context-view") && !s.closest(".usage-context-view").hidden);
155
+ if (!chart) return { visible: false, text: "no visible chart" };
156
+ const r = chart.getBoundingClientRect();
157
+ chart.dispatchEvent(new PointerEvent("pointermove", {
158
+ clientX: r.left + 30 + r.width * frac, clientY: r.top + r.height / 2, bubbles: true,
159
+ }));
160
+ const t = document.querySelector(".usage-tooltip");
161
+ // Actually-rendered check: getClientRects() returns 0 when the element or
162
+ // any ancestor is display:none, even if the element's own computed
163
+ // display is block (the old per-view tooltip blind spot).
164
+ return { visible: t.getClientRects().length > 0 && getComputedStyle(t).display !== "none", text: t.textContent || "" };
165
+ }
166
+ const requestOf = (r) => {
167
+ const m = (r.text || "").match(/Request (\\d+)/);
168
+ return m ? Number(m[1]) : null;
169
+ };
170
+ window.__probe = { setView, hover, requestOf, errs: () => window.__errs };
171
+ return "ready";
172
+ })();
173
+ `;
174
+
175
+ async function run() {
176
+ const html = buildCacheExportHtml(parseEntries(fixtureEntries()), { title: "e2e fixture", sessionId: "e2e" });
177
+
178
+ const chrome = findChrome();
179
+ if (!chrome) {
180
+ console.log(" SKIP browser e2e (no Chrome/Chromium found; set BROWSER_BIN to enable)");
181
+ skipped++;
182
+ return;
183
+ }
184
+ const usesWindowsChrome = IS_WSL && /\.exe$/i.test(chrome);
185
+ let windowsPid = 0;
186
+ const port = await freePort();
187
+ const userDataDir = usesWindowsChrome
188
+ ? commandOutput("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command",
189
+ "[Environment]::GetFolderPath('LocalApplicationData')"]) + `\\agent-web\\e2e-${process.pid}`
190
+ : mkdtempSync(join(tmpdir(), "cache-export-e2e-"));
191
+
192
+ const chromeArgs = [
193
+ `--remote-debugging-port=${port}`,
194
+ `--user-data-dir=${userDataDir}`,
195
+ "--headless", "--disable-graphics", "--no-sandbox", "--disable-gpu",
196
+ "--no-first-run", "--no-default-browser-check", "--disable-extensions",
197
+ ];
198
+ if (usesWindowsChrome) {
199
+ const winPath = commandOutput("wslpath", ["-w", chrome]);
200
+ const quoted = chromeArgs.map((a) => `'${a.replaceAll("'", "''")}'`).join(", ");
201
+ const out = commandOutput("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command",
202
+ `$p = Start-Process -FilePath '${winPath}' -ArgumentList @(${quoted}) -PassThru; $p.Id`]);
203
+ windowsPid = Number(out.trim());
204
+ } else {
205
+ spawn(chrome, chromeArgs, { stdio: "ignore", detached: true }).unref();
206
+ }
207
+
208
+ try {
209
+ if (!(await waitForEndpoint(port))) {
210
+ check("browser endpoint comes up", false, "chrome did not expose :" + port);
211
+ return;
212
+ }
213
+ const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
214
+ const page = list.find((t) => t.type === "page");
215
+ const cdp = await cdpConnect(page.webSocketDebuggerUrl);
216
+ await cdp.send("Page.enable");
217
+ await cdp.send("Emulation.setDeviceMetricsOverride", { width: 1600, height: 1200, deviceScaleFactor: 1, mobile: false });
218
+ await cdp.send("Page.navigate", { url: "data:text/html;charset=utf-8," + encodeURIComponent(html) });
219
+ await cdp.send("Runtime.enable");
220
+
221
+ const evalJs = async (expression) => {
222
+ const res = await cdp.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
223
+ if (res.exceptionDetails) throw new Error(res.exceptionDetails.text || "eval failed");
224
+ return res.result.value;
225
+ };
226
+
227
+ // wait for load + the dashboard IIFE
228
+ for (let i = 0; i < 40; i++) {
229
+ if (await evalJs(`document.readyState === "complete" && !!document.querySelector(".usage-chart")`)) break;
230
+ await new Promise((r) => setTimeout(r, 250));
231
+ }
232
+ await evalJs(HOVER_SCRIPT);
233
+
234
+ // 1. segmentation: model switches create context segments
235
+ const segInfo = await evalJs(`
236
+ (() => {
237
+ const opts = [...document.querySelectorAll("[data-context-segment-select] option")]
238
+ .map((o) => ({ value: o.value, text: o.textContent }));
239
+ return { count: opts.length - 1, selected: document.querySelector("[data-context-segment-select]").value, opts };
240
+ })()
241
+ `);
242
+ check("e2e segments split at compactions AND model switches (4 expected)",
243
+ segInfo.count === 4, JSON.stringify(segInfo.opts.map((o) => o.text)));
244
+ check("e2e segment labels carry the model", segInfo.opts.some((o) => o.text.includes("· p/m2")), JSON.stringify(segInfo.opts));
245
+ check("e2e defaults to the latest segment", segInfo.selected === "segment-4", segInfo.selected);
246
+
247
+ // 2. All view: tooltip must work past every break, on every chart
248
+ await evalJs(`window.__probe.setView("all")`);
249
+ const allCharts = await evalJs(`
250
+ (() => {
251
+ const labels = [...document.querySelectorAll("svg.usage-chart")].map((s) => s.getAttribute("aria-label"));
252
+ const out = {};
253
+ for (const label of labels) {
254
+ const near = window.__probe.hover(label, 0.05);
255
+ const far = window.__probe.hover(label, 0.9);
256
+ out[label] = { near, far, nearReq: window.__probe.requestOf(near), farReq: window.__probe.requestOf(far) };
257
+ }
258
+ return { out, errs: window.__probe.errs() };
259
+ })()
260
+ `);
261
+ const labels = Object.keys(allCharts.out);
262
+ check("e2e All view has 3 charts", labels.length === 3, labels.join(","));
263
+ for (const label of labels) {
264
+ const { near, far, nearReq, farReq } = allCharts.out[label];
265
+ check(`e2e All "${label}" tooltip visible near start`, near.visible && nearReq !== null, JSON.stringify(near));
266
+ check(`e2e All "${label}" tooltip works past the last break`, far.visible && farReq !== null && farReq > 39, JSON.stringify(far));
267
+ check(`e2e All "${label}" tooltip tracks the cursor across breaks`, farReq !== nearReq, `${farReq} vs ${nearReq}`);
268
+ }
269
+ check("e2e no IndexSizeError / uncaught errors in All view",
270
+ !allCharts.errs.some((e) => /IndexSizeError|Uncaught/i.test(e)), JSON.stringify(allCharts.errs));
271
+
272
+ // 3. latest segment view (no internal breaks): full-width tooltips
273
+ await evalJs(`window.__probe.setView("segment-4")`);
274
+ const seg4 = await evalJs(`
275
+ (() => {
276
+ const near = window.__probe.hover("Prompt input by request", 0.05);
277
+ const far = window.__probe.hover("Prompt input by request", 0.9);
278
+ return { near, far, nearReq: window.__probe.requestOf(near), farReq: window.__probe.requestOf(far), errs: window.__probe.errs() };
279
+ })()
280
+ `);
281
+ check("e2e segment-4 tooltip works at start and end",
282
+ seg4.near.visible && seg4.far.visible && seg4.farReq !== seg4.nearReq,
283
+ JSON.stringify(seg4));
284
+ check("e2e segment view has no uncaught errors",
285
+ !seg4.errs.some((e) => /IndexSizeError|Uncaught/i.test(e)), JSON.stringify(seg4.errs));
286
+
287
+ cdp.close();
288
+ if (usesWindowsChrome && windowsPid) {
289
+ try { execFileSync("taskkill.exe", ["/PID", String(windowsPid), "/T", "/F"]); } catch { /* best effort */ }
290
+ }
291
+ } finally {
292
+ if (!usesWindowsChrome) rmSync(userDataDir, { recursive: true, force: true });
293
+ }
294
+ }
295
+
296
+ await run();
297
+ console.log(`\n${pass} passed, ${fail} failed, ${skipped} skipped (browser e2e)`);
298
+ process.exit(fail ? 1 : 0);
@@ -318,7 +318,7 @@ const hitOf = (r) => r.cached / (r.fresh + r.cached + r.cacheWrite);
318
318
  s.length === 2 && s.every((x) => x.hits.length === 3), JSON.stringify(s.map((x) => [x.from, x.to])));
319
319
  }
320
320
 
321
- // P2. Context segments are compaction-only; models reset cache windows -----
321
+ // P2. Context segments break at compaction AND model switches -------------
322
322
  {
323
323
  const entries = [
324
324
  req({ cached: 90000, fresh: 10000, min: 1, model: "a" }),
@@ -329,14 +329,20 @@ const hitOf = (r) => r.cached / (r.fresh + r.cached + r.cacheWrite);
329
329
  ];
330
330
  const d = parseEntries(entries);
331
331
  const segments = buildContextSegments(d.requests, d.events);
332
- check("P2 model switch does not create a Context segment",
333
- segments.length === 2 && segments[0].requests.length === 2 && segments[1].requests.length === 1,
332
+ check("P2 model switch and compaction each create a Context segment",
333
+ segments.length === 3 && segments.every((segment) => segment.requests.length === 1),
334
334
  JSON.stringify(segments.map((segment) => segment.requests.map((request) => request.number))));
335
- const chart = lineChartForTest("segmented", [["cached", [10, 20, 30]]], { breakBefore: new Set([2]) });
336
- check("P2 compaction chart boundary breaks the polyline", (chart.match(/<polyline /g) || []).length === 2, chart);
335
+ const chart = lineChartForTest("segmented", [["cached", [10, 20, 30, 40]]], { breakBefore: new Set([2]) });
336
+ const polys = [...chart.matchAll(/<polyline ([^>]*)points="([^"]*)"/g)].map((m) => [m[1], m[2]]);
337
+ check("P2 chart boundary splits the visible polyline and keeps a hidden full one first (tooltip fix)",
338
+ polys.length === 3 &&
339
+ polys[0][0].includes('visibility="hidden"') && polys[0][1].split(" ").length === 4 &&
340
+ !polys[1][0].includes("visibility") && polys[1][1].split(" ").length === 2 &&
341
+ !polys[2][0].includes("visibility") && polys[2][1].split(" ").length === 2,
342
+ chart.slice(0, 900));
337
343
  const dashboard = renderDashboard(d);
338
344
  check("P2 dashboard defaults to latest segment and keeps an all-history view",
339
- dashboard.includes('value="all"') && dashboard.includes('value="segment-2" selected') && dashboard.includes('data-context-view="all" hidden'),
345
+ dashboard.includes('value="all"') && dashboard.includes('value="segment-3" selected') && dashboard.includes('data-context-view="all" hidden'),
340
346
  dashboard.slice(0, 500));
341
347
  }
342
348
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-toolkit",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Personal pi coding agent toolkit: /todos, /btw, /cache_export extensions, browser automation, and bundled theme/skills.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -43,7 +43,7 @@
43
43
  "ws": "^8.19.0"
44
44
  },
45
45
  "scripts": {
46
- "test": "cd extensions/cache-export && node --experimental-strip-types tests/run-tests.mjs && node --experimental-strip-types ../tests/btw-history.mjs",
46
+ "test": "cd extensions/cache-export && node --experimental-strip-types tests/run-tests.mjs && node --experimental-strip-types ../tests/btw-history.mjs && node tests/e2e-browser.mjs",
47
47
  "load-test": "pi -ne -e ./extensions/todo.ts && pi -ne -e ./extensions/cache-export/index.ts && pi -ne -e ./extensions/btw.ts",
48
48
  "eval:agents": "node evaluations/agents-md/run.mjs",
49
49
  "eval:agents:test": "node evaluations/agents-md/tests/run-tests.mjs",