@maxiaochao/pi-toolkit 0.1.3 → 0.1.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-08-18 - v0.1.5
2
+
3
+ - fix cache rebuild attribution and cache retention reporting
4
+
5
+ ## 2026-08-18 - v0.1.4
6
+
7
+ - tbd
8
+
1
9
  ## 2026-08-18 - v0.1.3
2
10
 
3
11
  - tbd
@@ -32,6 +32,7 @@ export function parseEntries(entries) {
32
32
  const toolCounts = {};
33
33
  let compactions = 0;
34
34
  let prevKey = null;
35
+ const longCacheUntilByKey = new Map();
35
36
  let gapTools = []; // tool results accumulated since the previous request
36
37
 
37
38
  const addEvent = (num, kind, label, ts) =>
@@ -88,12 +89,27 @@ export function parseEntries(entries) {
88
89
  const num = requests.length + 1;
89
90
  const provider = m.provider || "?";
90
91
  const model = m.model || "?";
92
+ const api = m.api || "";
91
93
  const key = `${provider}/${model}`;
92
94
  if (prevKey !== null && key !== prevKey && !events.some((ev) => ev.requestNumber === num && ev.kind === "model")) {
93
95
  addEvent(num, "model", `Model changed to ${provider}/${model}`, ts);
94
96
  }
95
97
  prevKey = key;
96
98
 
99
+ const tsMs = Date.parse(String(ts || ""));
100
+ let longCacheUntil = longCacheUntilByKey.get(key) || 0;
101
+ if (u.cacheRead === 0 && u.cacheWrite > 0 && !u.cacheWrite1h) {
102
+ longCacheUntil = 0;
103
+ longCacheUntilByKey.delete(key);
104
+ }
105
+ if (u.cacheWrite1h > 0 && Number.isFinite(tsMs)) {
106
+ longCacheUntil = Math.max(longCacheUntil, tsMs + LONG_TTL_MINUTES * 60000);
107
+ longCacheUntilByKey.set(key, longCacheUntil);
108
+ }
109
+ const cacheTtlMinutes = Number.isFinite(tsMs) && tsMs <= longCacheUntil
110
+ ? LONG_TTL_MINUTES
111
+ : DEFAULT_TTL_MINUTES;
112
+
97
113
  const cost = u.cost && typeof u.cost.total === "number" ? u.cost.total : null;
98
114
  requests.push({
99
115
  number: num,
@@ -101,11 +117,13 @@ export function parseEntries(entries) {
101
117
  timestamp: hhmmss(ts),
102
118
  provider,
103
119
  model,
120
+ api,
104
121
  responseProvider: null,
105
122
  fresh,
106
123
  cached,
107
124
  cacheWrite,
108
125
  cacheWrite1h: u.cacheWrite1h || 0,
126
+ cacheTtlMinutes,
109
127
  output,
110
128
  reasoning: u.reasoning || 0,
111
129
  stopReason: m.stopReason || "",
@@ -116,11 +134,6 @@ export function parseEntries(entries) {
116
134
  gapTools = [];
117
135
  }
118
136
 
119
- // tau flushes trailing events onto the last request so chart tooltips can find them
120
- if (requests.length > 0) {
121
- for (const ev of events) ev.requestNumber = Math.min(ev.requestNumber, requests.length);
122
- }
123
-
124
137
  const toolCalls = Object.entries(toolCounts).sort((a, b) => b[1] - a[1]);
125
138
  return { requests, events, toolCalls, compactions };
126
139
  }
@@ -211,18 +224,20 @@ function lineChart(title, series, opts = {}) {
211
224
  `<line class="hover-line" x1="${left}" y1="${top}" x2="${left}" y2="${top + plotHeight}" visibility="hidden"/>`,
212
225
  );
213
226
  const [evDark, evLight] = seriesColor("event");
214
- events.forEach((event, eventIndex) => {
215
- const [x] = point(Math.max(0, Math.min(count - 1, event.requestNumber - 1)), 0);
216
- const markerY = top + 7 + (eventIndex % 3) * 9;
217
- const description = `${event.label} before request ${event.requestNumber} at ${event.timestamp}`;
218
- parts.push(
219
- `<g class="usage-event usage-event-${esc(event.kind, true)}" data-request="${event.requestNumber}" ` +
220
- `data-event-info="${esc(description, true)}" role="img" aria-label="${esc(description, true)}">` +
221
- `<title>${esc(description)}</title>` +
222
- `<line class="event-line" data-dark="${evDark}" data-light="${evLight}" x1="${x.toFixed(1)}" y1="${top}" x2="${x.toFixed(1)}" y2="${top + plotHeight}" stroke="${evDark}"/>` +
223
- `<circle class="event-marker" data-dark="${evDark}" data-light="${evLight}" cx="${x.toFixed(1)}" cy="${markerY.toFixed(1)}" r="4" fill="${evDark}"/></g>`,
224
- );
225
- });
227
+ events
228
+ .filter((event) => event.requestNumber >= 1 && event.requestNumber <= count)
229
+ .forEach((event, eventIndex) => {
230
+ const [x] = point(Math.max(0, Math.min(count - 1, event.requestNumber - 1)), 0);
231
+ const markerY = top + 7 + (eventIndex % 3) * 9;
232
+ const description = `${event.label} before request ${event.requestNumber} at ${event.timestamp}`;
233
+ parts.push(
234
+ `<g class="usage-event usage-event-${esc(event.kind, true)}" data-request="${event.requestNumber}" ` +
235
+ `data-event-info="${esc(description, true)}" role="img" aria-label="${esc(description, true)}">` +
236
+ `<title>${esc(description)}</title>` +
237
+ `<line class="event-line" data-dark="${evDark}" data-light="${evLight}" x1="${x.toFixed(1)}" y1="${top}" x2="${x.toFixed(1)}" y2="${top + plotHeight}" stroke="${evDark}"/>` +
238
+ `<circle class="event-marker" data-dark="${evDark}" data-light="${evLight}" cx="${x.toFixed(1)}" cy="${markerY.toFixed(1)}" r="4" fill="${evDark}"/></g>`,
239
+ );
240
+ });
226
241
  series.forEach(([name, values], seriesIndex) => {
227
242
  const [dark, light] = seriesColor(name);
228
243
  const points = values.map((value, i) => point(i, value).map((c) => c.toFixed(1)).join(",")).join(" ");
@@ -262,7 +277,20 @@ const figure = (chart) =>
262
277
  // ---------------------------------------------------------------------------
263
278
 
264
279
  const MISS_THRESHOLD = 0.5; // hit rate below this gets an explanation row
265
- const TTL_MINUTES = 5; // Anthropic-style default prefix retention
280
+ const PREFIX_REUSE_THRESHOLD = 0.5;
281
+ const DEFAULT_TTL_MINUTES = 5;
282
+ const LONG_TTL_MINUTES = 60;
283
+
284
+ function reusablePrefix(request) {
285
+ const prompt = request.fresh + request.cached + request.cacheWrite;
286
+ if (request.api !== "anthropic-messages") return prompt;
287
+ const explicitlyCached = request.cached + request.cacheWrite;
288
+ return explicitlyCached > 0 ? explicitlyCached : prompt;
289
+ }
290
+
291
+ function ttlMinutesAfter(request) {
292
+ return request.cacheTtlMinutes ?? (request.cacheWrite1h > 0 ? LONG_TTL_MINUTES : DEFAULT_TTL_MINUTES);
293
+ }
266
294
 
267
295
  export function analyzeMisses(requests, events) {
268
296
  const evByReq = new Map();
@@ -284,9 +312,11 @@ export function analyzeMisses(requests, events) {
284
312
 
285
313
  const causes = [];
286
314
  const prev = i > 0 ? requests[i - 1] : null;
315
+ const expectedPrefix = prev ? reusablePrefix(prev) : 0;
316
+ const prefixReuse = expectedPrefix > 0 ? Math.min(1, r.cached / expectedPrefix) : 0;
287
317
 
288
318
  if (i === 0) {
289
- causes.push(["first request", "cold session: nothing cached yet, first write builds the prefix"]);
319
+ causes.push(["first request", "cold session: nothing cached yet; the prefix can only be reused by a later request"]);
290
320
  }
291
321
  if (prev && (prev.provider !== r.provider || prev.model !== r.model)) {
292
322
  causes.push(["model switch", `${prev.provider}/${prev.model} -> ${r.provider}/${r.model}: prefix invalidated by provider/model change`]);
@@ -302,19 +332,23 @@ export function analyzeMisses(requests, events) {
302
332
  }
303
333
  if (prev && r.isoTs && prev.isoTs) {
304
334
  const gapMin = (Date.parse(r.isoTs) - Date.parse(prev.isoTs)) / 60000;
305
- if (gapMin > TTL_MINUTES && !causes.some(([c]) => c === "compaction" || c === "model switch")) {
306
- causes.push(["TTL expiry", `gap ${gapMin.toFixed(0)}min > ${TTL_MINUTES}min retention: cached prefix likely expired`]);
335
+ const ttlMinutes = ttlMinutesAfter(prev);
336
+ if (gapMin > ttlMinutes && prefixReuse < PREFIX_REUSE_THRESHOLD && !causes.some(([c]) => c === "compaction" || c === "model switch")) {
337
+ causes.push(["possible TTL expiry", `idle gap ${gapMin.toFixed(0)}min > ${ttlMinutes}min heuristic; actual retention depends on the provider`]);
307
338
  }
308
339
  }
309
- if (r.cacheWrite > 0 && r.fresh > r.cached && !causes.length) {
310
- causes.push(["cache rebuild", "fresh prefix written this turn (write-heavy); next turn should read it back"]);
340
+ if (prev && prefixReuse < PREFIX_REUSE_THRESHOLD && !causes.length) {
341
+ const writeDetail = r.cacheWrite > 0 ? `; provider also reported ${r.cacheWrite.toLocaleString("en-US")} tokens written for the replacement prefix` : "";
342
+ causes.push(["cache rebuild", `only ${(prefixReuse * 100).toFixed(1)}% of the prior reusable prefix was read; the cache was likely lost or invalidated${writeDetail}`]);
343
+ }
344
+ if (r.cacheWrite > 0 && !causes.length) {
345
+ 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`]);
311
346
  }
312
347
  if (prev && !causes.length) {
313
- const pPrompt = prev.fresh + prev.cached + prev.cacheWrite;
314
- const pHit = pPrompt > 0 ? prev.cached / pPrompt : 0;
315
- if (pHit < MISS_THRESHOLD) {
316
- causes.push(["cache rebuild", "previous turn lost the cache; this turn still pays fresh input while the new prefix is rebuilt"]);
317
- }
348
+ const toolChars = (r.gapTools || []).reduce((sum, tool) => sum + tool.chars, 0);
349
+ const toolTokens = Math.round(toolChars / 4);
350
+ const toolDetail = toolTokens > 0 ? `, including ~${toolTokens.toLocaleString("en-US")} estimated tool-output tokens` : "";
351
+ causes.push(["new input", `reused ${(prefixReuse * 100).toFixed(1)}% of the prior prefix; ${r.fresh.toLocaleString("en-US")} fresh tokens were newly appended${toolDetail}`]);
318
352
  }
319
353
  if (!causes.length) {
320
354
  causes.push(["unclassified", "no rule matched; usually new content appended beyond the cached prefix (large tool results / long user input)"]);
@@ -330,7 +364,8 @@ export function analyzeMisses(requests, events) {
330
364
  }
331
365
 
332
366
  const summaryArr = [...summary.entries()].sort((a, b) => b[1].fresh - a[1].fresh);
333
- return { misses, summary: summaryArr, noCacheReporting, threshold: MISS_THRESHOLD, ttlMinutes: TTL_MINUTES };
367
+ return { misses, summary: summaryArr, noCacheReporting, threshold: MISS_THRESHOLD,
368
+ ttlMinutes: DEFAULT_TTL_MINUTES, longTtlMinutes: LONG_TTL_MINUTES };
334
369
  }
335
370
 
336
371
  // Streak detection: many consecutive requests stuck in a mid hit-rate band.
@@ -344,20 +379,31 @@ const STREAK_HIGH = 0.975;
344
379
  const STREAK_MIN_LEN = 3;
345
380
  const STREAK_MIN_FRESH = 800; // tokens per request to count as a streak member
346
381
  const STREAK_MIN_PROMPT = 4000; // ignore tiny prefixes where ratios are meaningless
347
- // Membership hit rate excludes cacheWrite from the denominator: during an
348
- // Anthropic-style rebuild cacheWrite is huge and would fake a mid-band hit.
382
+ // Requests that report cacheWrite are cache-creation/rebuild events, not
383
+ // append-heavy read-hit streak members, so they terminate the current streak.
349
384
 
350
- export function analyzeStreaks(requests) {
385
+ export function analyzeStreaks(requests, events = []) {
351
386
  const streaks = [];
352
387
  let cur = null;
353
388
  let gaps = 0; // consecutive non-member requests inside the current run
389
+ let prevKey = null;
390
+ const resetBefore = new Set(events
391
+ .filter((event) => event.kind === "compaction" || event.kind === "model")
392
+ .map((event) => event.requestNumber));
354
393
  const close = () => {
355
394
  if (cur && cur.hits.length >= STREAK_MIN_LEN) streaks.push(cur);
356
395
  cur = null;
357
396
  gaps = 0;
358
397
  };
359
398
  for (const r of requests) {
360
- const prompt = r.fresh + r.cached; // cacheWrite excluded (see note above)
399
+ const key = `${r.provider}/${r.model}`;
400
+ if ((prevKey !== null && key !== prevKey) || resetBefore.has(r.number)) close();
401
+ prevKey = key;
402
+ if (r.cacheWrite > 0) {
403
+ close();
404
+ continue;
405
+ }
406
+ const prompt = r.fresh + r.cached;
361
407
  let hit = 0;
362
408
  let member = false;
363
409
  if (prompt >= STREAK_MIN_PROMPT) {
@@ -407,7 +453,7 @@ function renderStreakPanel(streaks) {
407
453
  .join("");
408
454
  return `<div class="usage-panel"><h2>Hit-rate streak analysis</h2>` +
409
455
  `<p class="usage-note">Rule-based, no LLM. A streak is ${STREAK_MIN_LEN}+ requests (single healthy outlier tolerated) with hit rate ` +
410
- `${(STREAK_LOW * 100).toFixed(0)}–${(STREAK_HIGH * 100).toFixed(1)}% (hit excludes cacheWrite; fresh ≥ ${STREAK_MIN_FRESH} tok, prefix ≥ ${STREAK_MIN_PROMPT}): ` +
456
+ `${(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}): ` +
411
457
  `usually each turn appends large fresh content (big tool outputs / long replies), not a cache failure. ` +
412
458
  `Below ${(MISS_THRESHOLD * 100).toFixed(0)}% see the miss panel above. Tool tokens estimated at 4 chars/token.</p>` +
413
459
  `<div class="usage-table-wrap" style="max-height:260px"><table>` +
@@ -418,7 +464,7 @@ function renderStreakPanel(streaks) {
418
464
  function renderMissPanel(miss) {
419
465
  if (miss.noCacheReporting) {
420
466
  return `<div class="usage-panel"><h2>Cache miss analysis</h2>` +
421
- `<p class="empty">This provider reports no cache token fields (cacheRead/cacheWrite always 0), so hit rate is always 0 and misses cannot be attributed.</p></div>`;
467
+ `<p class="empty">No cache reads or writes were reported. The provider may omit cache token fields, caching may be disabled, or this session may not have reused a prefix; hit rate cannot be determined.</p></div>`;
422
468
  }
423
469
  const summaryHtml = miss.summary
424
470
  .map(([cause, s]) => `<div class="usage-tool"><span>${esc(cause)} (${s.count})</span><strong>${fmtInt(s.fresh)} fresh</strong></div>`)
@@ -431,7 +477,7 @@ function renderMissPanel(miss) {
431
477
  .join("");
432
478
  return `<div class="usage-panel"><h2>Cache miss analysis</h2>` +
433
479
  `<p class="usage-note">Rule-based attribution, no LLM. Rows are requests below ${(miss.threshold * 100).toFixed(0)}% hit rate. ` +
434
- `TTL rule assumes ${miss.ttlMinutes}min prefix retention.</p>` +
480
+ `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>` +
435
481
  `<div class="usage-table-wrap" style="max-height:340px"><table>` +
436
482
  `<thead><tr><th>#</th><th>Time</th><th>Hit rate</th><th>Fresh</th><th>Cause</th></tr></thead>` +
437
483
  `<tbody>${rows || `<tr><td colspan="5" class="empty">None</td></tr>`}</tbody></table></div>` +
@@ -474,7 +520,7 @@ export function renderDashboard(data) {
474
520
  ["Model requests", fmtInt(requests.length)],
475
521
  ["Cache hit rate", cacheHitRate !== null ? fmtPct1(cacheHitRate) : "N/A"],
476
522
  ["Cached input", fmtInt(totalCached)],
477
- ["Cache writes", fmtInt(totalWrite)],
523
+ ["Reported cache writes", fmtInt(totalWrite)],
478
524
  ["Fresh input", fmtInt(totalFresh)],
479
525
  ["Total prompt input", fmtInt(totalPrompt)],
480
526
  ["Output tokens", fmtInt(totalOutput)],
@@ -517,10 +563,10 @@ export function renderDashboard(data) {
517
563
  `<div class="usage-cards">${cardsHtml}</div>` +
518
564
  `<p class="usage-note">Hover a request for exact values, ` +
519
565
  `select a legend item to hide a series, and use PNG to save a chart. Event markers show ` +
520
- `compactions, model or thinking changes.</p>` +
566
+ `compactions, model or thinking changes. Some providers report cache reads but not cache writes.</p>` +
521
567
  `<div class="usage-charts">${charts.join("")}</div>` +
522
568
  renderMissPanel(analyzeMisses(requests, events)) +
523
- renderStreakPanel(analyzeStreaks(requests)) +
569
+ renderStreakPanel(analyzeStreaks(requests, events)) +
524
570
  `<div class="usage-details">` +
525
571
  `<div class="usage-panel"><h2>Requests</h2><div class="usage-table-wrap"><table>` +
526
572
  `<thead><tr><th>#</th><th>Time</th><th>Provider</th><th>Response Provider</th><th>Model</th>` +
@@ -1,6 +1,6 @@
1
1
  // Deterministic test cases for analyzeStreaks / analyzeMisses / parseEntries.
2
2
  // Run: node --experimental-strip-types tests/run-tests.mjs
3
- import { parseEntries, analyzeStreaks, analyzeMisses } from "../render.ts";
3
+ import { parseEntries, analyzeStreaks, analyzeMisses, lineChartForTest } from "../render.ts";
4
4
 
5
5
  let pass = 0, fail = 0;
6
6
  function check(name, cond, detail = "") {
@@ -10,9 +10,10 @@ function check(name, cond, detail = "") {
10
10
 
11
11
  // helpers -------------------------------------------------------------
12
12
  const T0 = "2026-01-01T00:00:00Z";
13
- function req({ cached, fresh, write = 0, out = 100, min = 0, provider = "p", model = "m", content = [] }) {
13
+ function req({ cached, fresh, write = 0, write1h = 0, out = 100, min = 0, provider = "p", model = "m", api = "openai-responses", content = [] }) {
14
14
  return { type: "message", timestamp: `2026-01-01T00:${String(min).padStart(2, "0")}:00Z`,
15
- message: { role: "assistant", provider, model, stopReason: "stop", content, usage: { input: fresh, cacheRead: cached, cacheWrite: write, output: out } } };
15
+ message: { role: "assistant", provider, model, api, stopReason: "stop", content,
16
+ usage: { input: fresh, cacheRead: cached, cacheWrite: write, cacheWrite1h: write1h, output: out } } };
16
17
  }
17
18
  function toolResult(text, tool = "bash") {
18
19
  return { type: "message", message: { role: "toolResult", toolName: tool, content: [{ type: "text", text }] } };
@@ -120,10 +121,66 @@ const hitOf = (r) => r.cached / (r.fresh + r.cached + r.cacheWrite);
120
121
  req({ cached: 5000, fresh: 15000, min: 40 }), // 39min gap same model, hit .25
121
122
  ];
122
123
  const m = analyzeMisses(parseEntries(entries).requests, parseEntries(entries).events);
123
- check("H 39min gap + low hit -> TTL expiry", m.misses.length === 1 && m.misses[0].causes.some(([c]) => c === "TTL expiry"),
124
+ check("H 39min gap + low hit -> possible TTL expiry", m.misses.length === 1 && m.misses[0].causes.some(([c]) => c === "possible TTL expiry"),
124
125
  JSON.stringify(m.misses.map((x) => x.causes)));
125
126
  }
126
127
 
128
+ // H2. long idle gap with retained prefix is new input, not TTL expiry -------
129
+ {
130
+ const entries = [
131
+ req({ cached: 0, fresh: 14000, min: 1 }),
132
+ toolResult("x".repeat(80000), "read"),
133
+ req({ cached: 13824, fresh: 20000, min: 40 }),
134
+ ];
135
+ const d = parseEntries(entries);
136
+ const two = analyzeMisses(d.requests, d.events).misses.find((x) => x.number === 2);
137
+ check("H2 long gap with 98% prefix reuse -> new input, not TTL",
138
+ two && two.causes.some(([cause]) => cause === "new input") && !two.causes.some(([cause]) => cause === "possible TTL expiry"),
139
+ JSON.stringify(two && two.causes));
140
+ }
141
+
142
+ // H3. Anthropic 1h writes suppress the 5min TTL heuristic -----------------
143
+ {
144
+ const entries = [
145
+ req({ cached: 0, fresh: 2000, write: 50000, write1h: 50000, min: 1, api: "anthropic-messages" }),
146
+ req({ cached: 5000, fresh: 15000, min: 40, api: "anthropic-messages" }),
147
+ ];
148
+ const d = parseEntries(entries);
149
+ const two = analyzeMisses(d.requests, d.events).misses.find((x) => x.number === 2);
150
+ check("H3 39min gap after 1h write -> rebuild, not TTL expiry",
151
+ two && two.causes.some(([cause]) => cause === "cache rebuild") && !two.causes.some(([cause]) => cause === "possible TTL expiry"),
152
+ JSON.stringify(two && two.causes));
153
+ }
154
+
155
+ // H4. 1h retention survives intervening read-only requests -----------------
156
+ {
157
+ const entries = [
158
+ req({ cached: 0, fresh: 2000, write: 50000, write1h: 50000, min: 1, api: "anthropic-messages" }),
159
+ req({ cached: 50000, fresh: 2000, min: 2, api: "anthropic-messages" }),
160
+ req({ cached: 5000, fresh: 15000, min: 40, api: "anthropic-messages" }),
161
+ ];
162
+ const d = parseEntries(entries);
163
+ const three = analyzeMisses(d.requests, d.events).misses.find((x) => x.number === 3);
164
+ check("H4 inherited 1h window -> rebuild, not TTL expiry",
165
+ d.requests[1].cacheTtlMinutes === 60 && three && three.causes.some(([cause]) => cause === "cache rebuild") &&
166
+ !three.causes.some(([cause]) => cause === "possible TTL expiry"),
167
+ JSON.stringify({ ttl: d.requests[1].cacheTtlMinutes, causes: three && three.causes }));
168
+ }
169
+
170
+ // H5. short-lived replacement clears an older 1h retention window ----------
171
+ {
172
+ const entries = [
173
+ req({ cached: 0, fresh: 2000, write: 50000, write1h: 50000, min: 1, api: "anthropic-messages" }),
174
+ req({ cached: 0, fresh: 2000, write: 50000, min: 2, api: "anthropic-messages" }),
175
+ req({ cached: 5000, fresh: 15000, min: 12, api: "anthropic-messages" }),
176
+ ];
177
+ const d = parseEntries(entries);
178
+ const three = analyzeMisses(d.requests, d.events).misses.find((x) => x.number === 3);
179
+ check("H5 short replacement resets TTL to 5min",
180
+ d.requests[1].cacheTtlMinutes === 5 && three && three.causes.some(([cause]) => cause === "possible TTL expiry"),
181
+ JSON.stringify({ ttl: d.requests[1].cacheTtlMinutes, causes: three && three.causes }));
182
+ }
183
+
127
184
  // I. cache rebuild (prev turn was a miss, same model) -------------------
128
185
  {
129
186
  const entries = [
@@ -136,7 +193,38 @@ const hitOf = (r) => r.cached / (r.fresh + r.cached + r.cacheWrite);
136
193
  check("I request2 -> cache rebuild (prev lost cache)", two && two.causes.some(([c]) => c === "cache rebuild"), JSON.stringify(m.misses.map((x) => [x.number, x.causes.map((y) => y[0])])));
137
194
  }
138
195
 
139
- // J. trailing event clamp + tool trace ----------------------------------
196
+ // I2. cold start followed by large tool output keeps the prior prefix -------
197
+ {
198
+ const entries = [
199
+ req({ cached: 0, fresh: 14080, min: 1 }),
200
+ toolResult("a".repeat(30142), "fetch_content"),
201
+ toolResult("b".repeat(50662), "read"),
202
+ toolResult("c".repeat(30719), "read"),
203
+ req({ cached: 13824, fresh: 27347, min: 2 }),
204
+ ];
205
+ const d = parseEntries(entries);
206
+ const two = analyzeMisses(d.requests, d.events).misses.find((x) => x.number === 2);
207
+ check("I2 request2 retained 98% prior prefix -> new input, not rebuild",
208
+ two && two.causes.some(([c]) => c === "new input") && !two.causes.some(([c]) => c === "cache rebuild"),
209
+ JSON.stringify(two && two.causes));
210
+ check("I2 new-input detail includes estimated tool tokens",
211
+ two && two.causes.some(([, detail]) => detail.includes("27,881 estimated tool-output tokens")),
212
+ JSON.stringify(two && two.causes));
213
+ }
214
+
215
+ // I3. OpenAI previous fresh suffix remains part of reusable prefix --------
216
+ {
217
+ const entries = [
218
+ req({ cached: 80000, fresh: 20000, min: 1 }),
219
+ req({ cached: 45000, fresh: 55000, min: 2 }),
220
+ ];
221
+ const d = parseEntries(entries);
222
+ const two = analyzeMisses(d.requests, d.events).misses.find((x) => x.number === 2);
223
+ check("I3 OpenAI 45% of prior total prompt -> rebuild",
224
+ two && two.causes.some(([cause]) => cause === "cache rebuild"), JSON.stringify(two && two.causes));
225
+ }
226
+
227
+ // J. trailing event stays after the final request + tool trace ------------
140
228
  {
141
229
  const entries = [
142
230
  req({ cached: 90000, fresh: 10000, min: 1, content: [{ type: "toolCall", tool: "bash", name: "bash" }] }),
@@ -148,7 +236,9 @@ const hitOf = (r) => r.cached / (r.fresh + r.cached + r.cacheWrite);
148
236
  ];
149
237
  const d = parseEntries(entries);
150
238
  const lastEv = d.events[d.events.length - 1];
151
- check("J trailing compaction clamped to last request", lastEv.kind === "compaction" && lastEv.requestNumber === 3, JSON.stringify(lastEv));
239
+ check("J trailing compaction remains after request 3", lastEv.kind === "compaction" && lastEv.requestNumber === 4, JSON.stringify(lastEv));
240
+ const chart = lineChartForTest("events", [["cached", [1, 2, 3]]], { events: d.events });
241
+ check("J trailing compaction is not drawn as if it preceded request 3", !chart.includes("usage-event-compaction"));
152
242
  const s = analyzeStreaks(d.requests);
153
243
  check("J streak tool trace has bash~3000 + read~1000 tokens",
154
244
  s.length === 1 && s[0].tools.get("bash") === 3000 && s[0].tools.get("read") === 1000,
@@ -167,13 +257,30 @@ const hitOf = (r) => r.cached / (r.fresh + r.cached + r.cacheWrite);
167
257
  s.length === 1 && s[0].hits.length === 4, JSON.stringify(s.map(x => x.hits.length)));
168
258
  }
169
259
 
170
- // M. write-heavy rebuild (Anthropic-style) must not fake a mid-band streak --
260
+ // M. cacheWrite is excluded from the read-hit streak metric --------------
171
261
  {
172
262
  const entries = Array.from({ length: 3 }, (_, i) =>
173
- req({ cached: 5000, fresh: 2000, write: 50000, min: i + 1 })); // std hit 8.8%, write-excluded hit .714
174
- const s = analyzeStreaks(parseEntries(entries).requests);
175
- check("M write-heavy rebuild counts members by write-excluded hit",
176
- s.length === 1 && s[0].hits.length === 3, JSON.stringify(s.map(x => x.hits.length)));
263
+ req({ cached: 5000, fresh: 2000, write: 50000, min: i + 1, api: "anthropic-messages" })); // read hit 8.8%
264
+ const d = parseEntries(entries);
265
+ const s = analyzeStreaks(d.requests);
266
+ const m = analyzeMisses(d.requests, d.events);
267
+ check("M cacheWrite requests break append-heavy streaks", s.length === 0, JSON.stringify(s));
268
+ check("M repeated writes with only 9% prior-prefix reads are rebuilds",
269
+ m.misses.length === 3 && m.misses.slice(1).every((miss) => miss.causes.some(([cause]) => cause === "cache rebuild")),
270
+ JSON.stringify(m.misses.map((miss) => miss.causes)));
271
+ }
272
+
273
+ // M2. retained Anthropic prefix plus a new write is cache extension --------
274
+ {
275
+ const entries = [
276
+ req({ cached: 50000, fresh: 2000, min: 1, api: "anthropic-messages" }),
277
+ req({ cached: 50000, fresh: 2000, write: 60000, min: 2, api: "anthropic-messages" }),
278
+ ];
279
+ const d = parseEntries(entries);
280
+ const two = analyzeMisses(d.requests, d.events).misses.find((x) => x.number === 2);
281
+ check("M2 retained prefix plus write -> cache write, not rebuild",
282
+ two && two.causes.some(([cause]) => cause === "cache write") && !two.causes.some(([cause]) => cause === "cache rebuild"),
283
+ JSON.stringify(two && two.causes));
177
284
  }
178
285
 
179
286
  // N. tiny prefix (fresh>=800 but prompt<4000) must not streak ---------------
@@ -183,6 +290,30 @@ const hitOf = (r) => r.cached / (r.fresh + r.cached + r.cacheWrite);
183
290
  check("N tiny prefix (<4k) -> no streak even though fresh>=800", s.length === 0, `got ${s.length}`);
184
291
  }
185
292
 
293
+ // O. model switch must split mid-band streaks immediately ----------------
294
+ {
295
+ const entries = [
296
+ ...Array.from({ length: 3 }, (_, i) => req({ cached: 90000, fresh: 10000, min: i + 1, model: "a" })),
297
+ ...Array.from({ length: 3 }, (_, i) => req({ cached: 90000, fresh: 10000, min: i + 4, model: "b" })),
298
+ ];
299
+ const s = analyzeStreaks(parseEntries(entries).requests);
300
+ check("O model switch splits streaks into 3 + 3",
301
+ s.length === 2 && s.every((x) => x.hits.length === 3), JSON.stringify(s.map((x) => [x.from, x.to])));
302
+ }
303
+
304
+ // P. compaction must split mid-band streaks -------------------------------
305
+ {
306
+ const entries = [
307
+ ...Array.from({ length: 3 }, (_, i) => req({ cached: 90000, fresh: 10000, min: i + 1 })),
308
+ { type: "compaction", timestamp: "2026-01-01T00:03:30Z" },
309
+ ...Array.from({ length: 3 }, (_, i) => req({ cached: 90000, fresh: 10000, min: i + 4 })),
310
+ ];
311
+ const d = parseEntries(entries);
312
+ const s = analyzeStreaks(d.requests, d.events);
313
+ check("P compaction splits streaks into 3 + 3",
314
+ s.length === 2 && s.every((x) => x.hits.length === 3), JSON.stringify(s.map((x) => [x.from, x.to])));
315
+ }
316
+
186
317
  // K. degenerate inputs ---------------------------------------------------
187
318
  {
188
319
  check("K empty session -> no crash, no streaks", analyzeStreaks(parseEntries([]).requests).length === 0);
@@ -0,0 +1,91 @@
1
+ # AGENTS.md
2
+
3
+ Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
4
+
5
+ **Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
6
+
7
+ ## 1. Think Before Coding
8
+
9
+ **Don't assume. Don't hide confusion. Surface tradeoffs.**
10
+
11
+ Before implementing:
12
+ - State your assumptions explicitly. If uncertain, ask.
13
+ - If multiple interpretations exist, present them - don't pick silently.
14
+ - If a simpler approach exists, say so. Push back when warranted.
15
+ - If something is unclear, stop. Name what's confusing. Ask.
16
+
17
+ ## 2. Simplicity First
18
+
19
+ **Minimum code that solves the problem. Nothing speculative.**
20
+
21
+ - No features beyond what was asked.
22
+ - No abstractions for single-use code.
23
+ - No "flexibility" or "configurability" that wasn't requested.
24
+ - No error handling for impossible scenarios.
25
+ - If you write 200 lines and it could be 50, rewrite.
26
+
27
+ Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
28
+
29
+ ## 3. Surgical Changes
30
+
31
+ **Touch only what you must. Clean up only your own mess.**
32
+
33
+ When editing existing code:
34
+ - Don't "improve" adjacent code, comments, or formatting.
35
+ - Don't refactor things that aren't broken.
36
+ - Match existing style, even if you'd do it differently.
37
+ - If you notice unrelated dead code, mention it - don't delete it.
38
+
39
+ When your changes create orphans:
40
+ - Remove imports/variables/functions that YOUR changes made unused.
41
+ - Don't remove pre-existing dead code unless asked.
42
+
43
+ The test: Every changed line should trace directly to the user's request.
44
+
45
+ ## 4. Goal-Driven Execution
46
+
47
+ **Define success criteria. Loop until verified.**
48
+
49
+ Transform tasks into verifiable goals:
50
+ - "Add validation" -> "Write tests for invalid inputs, then make them pass"
51
+ - "Fix the bug" -> "Write a test that reproduces it, then make it pass"
52
+ - "Refactor X" -> "Ensure tests pass before and after"
53
+
54
+ For multi-step tasks, state a brief plan:
55
+ 1. [Step] -> verify: [check]
56
+ 2. [Step] -> verify: [check]
57
+ 3. [Step] -> verify: [check]
58
+
59
+ Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
60
+
61
+ ## 5. Read Before You Write
62
+
63
+ **Understand adjacent code before changing it. "Looks orthogonal" is a dangerous assumption.**
64
+
65
+ Before adding code to any file:
66
+ - Read the file's exports and public API.
67
+ - Read the immediate callers of the function you're modifying.
68
+ - Check shared utilities in the same module.
69
+ - If you're unsure why code is structured a certain way, ask before writing.
70
+
71
+ ## 6. Checkpoint Long-Running Work
72
+
73
+ **Multi-step work needs checkpoints. Don't keep going from a state you can't describe.**
74
+
75
+ After each significant step:
76
+ - Summarize what was done.
77
+ - State what was verified.
78
+ - State what remains.
79
+ - If you can't describe the current state back, stop and restate instead of continuing.
80
+
81
+ ## 7. Fail Visibly, Not Silently
82
+
83
+ **Silent skips are failures. Surface uncertainty instead of hiding it.**
84
+
85
+ - "Completed" is wrong if anything was silently skipped.
86
+ - "Tests pass" is wrong if any test was skipped.
87
+ - Default to surfacing uncertainty. When in doubt, over-communicate what you're unsure about.
88
+
89
+ ---
90
+
91
+ **These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-toolkit",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Personal pi coding agent toolkit: /todos extension + /cache_export interactive cache dashboard.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -10,7 +10,9 @@
10
10
  "files": [
11
11
  "extensions",
12
12
  "README.md",
13
- "CHANGELOG.md"
13
+ "CHANGELOG.md",
14
+ "global/AGENTS.md",
15
+ "scripts/install-agents.mjs"
14
16
  ],
15
17
  "repository": {
16
18
  "type": "git",
@@ -30,7 +32,8 @@
30
32
  },
31
33
  "scripts": {
32
34
  "test": "cd extensions/cache-export && node --experimental-strip-types tests/run-tests.mjs",
33
- "load-test": "pi -e ./extensions/todo.ts && pi -e ./extensions/cache-export/index.ts"
35
+ "load-test": "pi -ne -e ./extensions/todo.ts && pi -ne -e ./extensions/cache-export/index.ts",
36
+ "postinstall": "node scripts/install-agents.mjs"
34
37
  },
35
38
  "publishConfig": {
36
39
  "access": "public"
@@ -0,0 +1,15 @@
1
+ // postinstall hook: sync the bundled global AGENTS.md to ~/.pi/agent/AGENTS.md
2
+ // (pi's native global context file). Idempotent: writes only when different.
3
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const bundled = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "global", "AGENTS.md"), "utf8");
9
+ const target = join(homedir(), ".pi", "agent", "AGENTS.md");
10
+
11
+ if (!existsSync(target) || readFileSync(target, "utf8") !== bundled) {
12
+ mkdirSync(dirname(target), { recursive: true });
13
+ writeFileSync(target, bundled, "utf8");
14
+ console.log("[pi-toolkit] global AGENTS.md synced ->", target);
15
+ }