pi-mega-compact 0.6.6 → 0.6.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.
@@ -83,7 +83,52 @@ function readIndex() {
83
83
  inputRate: r.input_rate ?? null,
84
84
  outputRate: r.output_rate ?? null,
85
85
  lastSeen: Number(r.last_seen ?? 0),
86
+ // Defaults — enriched below from each repo's own store.
87
+ tokensKept: 0,
88
+ tokensDropped: 0,
89
+ sessions: 0,
90
+ contextWindow: null,
91
+ maxTokens: null,
92
+ reasoning: null,
86
93
  }));
94
+ // Enrich each repo with per-store token + model detail read directly via
95
+ // node:sqlite (same zero-dependency invariant as readIndex; no store graph
96
+ // import). Best-effort: a missing/corrupt store degrades to the defaults
97
+ // above so the dashboard never fails to render.
98
+ for (const repo of mapped) {
99
+ try {
100
+ const storePath = join(repo.stateDir, "sqlite.db");
101
+ if (existsSync(storePath)) {
102
+ const sdb = new DatabaseSync(storePath, { readOnly: true });
103
+ try {
104
+ const tok = sdb
105
+ .prepare(`SELECT COALESCE(SUM(token_estimate),0) AS kept,
106
+ COALESCE(SUM(original_token_estimate),0) AS dropped,
107
+ COUNT(DISTINCT session_id) AS sess
108
+ FROM context_chunks WHERE dedup_status != 'removed'`)
109
+ .get();
110
+ repo.tokensKept = Number(tok.kept ?? 0);
111
+ repo.tokensDropped = Number(tok.dropped ?? 0);
112
+ repo.sessions = Number(tok.sess ?? 0);
113
+ const mrow = sdb
114
+ .prepare(`SELECT context_window, max_tokens, reasoning
115
+ FROM model_snapshots ORDER BY captured_at DESC LIMIT 1`)
116
+ .get();
117
+ if (mrow) {
118
+ repo.contextWindow = Number(mrow.context_window ?? 0) || null;
119
+ repo.maxTokens = Number(mrow.max_tokens ?? 0) || null;
120
+ repo.reasoning = Number(mrow.reasoning ?? 0) === 1;
121
+ }
122
+ }
123
+ finally {
124
+ sdb.close();
125
+ }
126
+ }
127
+ }
128
+ catch {
129
+ /* best-effort — keep the defaults */
130
+ }
131
+ }
87
132
  // Defensive display hygiene (belt-and-suspenders — the real fix is that
88
133
  // tests now isolate via MEGACOMPACT_INDEX_DIR): drop transient test/temp
89
134
  // paths that should never have been real repos, and collapse duplicate
@@ -443,15 +488,23 @@ function dashboardHtml(tierName) {
443
488
  <thead>
444
489
  <tr>
445
490
  <th>Model</th><th>Provider</th>
446
- <th style="text-align:right">Repos</th>
491
+ <th style="text-align:right" title="Tokens dropped from context by compaction (the input reclaimed)">Tokens In</th>
492
+ <th style="text-align:right" title="Tokens kept as compacted summaries still in context (the output retained)">Tokens Out</th>
493
+ <th style="text-align:right">Freed</th>
494
+ <th style="text-align:right" title="Model context window (max input tokens the model accepts)">Ctx Window</th>
495
+ <th style="text-align:right" title="Model max output tokens per turn">Max Out</th>
496
+ <th style="text-align:right" title="Reasoning-capable model">Reas.</th>
497
+ <th style="text-align:right" title="Distinct sessions with at least one checkpoint">Sessions</th>
447
498
  <th style="text-align:right">Checkpoints</th>
448
- <th style="text-align:right">Tokens Saved</th>
499
+ <th style="text-align:right" title="USD per input token">In $/tok</th>
500
+ <th style="text-align:right" title="USD per output token">Out $/tok</th>
449
501
  <th style="text-align:right">$ Saved</th>
450
502
  <th style="text-align:right">Last Used</th>
451
503
  </tr>
452
504
  </thead>
453
- <tbody id="bm-rows"><tr><td colspan="7" class="repo-none">loading…</td></tr></tbody>
505
+ <tbody id="bm-rows"><tr><td colspan="14" class="repo-none">loading…</td></tr></tbody>
454
506
  </table>
507
+ <p class="legend-note" style="margin-top:8px">Tokens In = Σ original region tokens dropped by compaction. Tokens Out = Σ compacted summary tokens still retained in context. Freed = Tokens In − Tokens Out (net context reclaimed). Ctx Window / Max Out / Reas. come from the latest captured model snapshot for each repo.</p>
455
508
 
456
509
  <div class="updated" id="sm-updated"></div>
457
510
  </div>
@@ -673,33 +726,67 @@ function dashboardHtml(tierName) {
673
726
  var rows = document.getElementById('bm-rows');
674
727
  if (!rows) return;
675
728
  if (!repos || !repos.length) {
676
- rows.innerHTML = '<tr><td colspan="7" class="repo-none">No repositories registered yet.</td></tr>';
729
+ rows.innerHTML = '<tr><td colspan="14" class="repo-none">No repositories registered yet.</td></tr>';
677
730
  return;
678
731
  }
679
732
  var groups = {};
680
733
  for (var i = 0; i < repos.length; i++) {
681
734
  var r = repos[i];
682
735
  var key = (r.modelName && String(r.modelName).trim()) || '(unknown)';
683
- if (!groups[key]) groups[key] = { model: key, provider: r.providerName || r.provider || '—', repos: 0, checkpoints: 0, tokensSaved: 0, usd: 0, lastAt: 0, rates: [] };
736
+ if (!groups[key]) groups[key] = {
737
+ model: key, provider: r.providerName || r.provider || '—', repos: 0, checkpoints: 0,
738
+ tokensSaved: 0, tokensIn: 0, tokensOut: 0, sessions: 0, usd: 0, lastAt: 0,
739
+ inRates: [], outRates: [], ctxWindows: [], maxTokens: [], reasoning: null,
740
+ };
684
741
  var g = groups[key];
685
742
  g.repos++;
686
743
  g.checkpoints += (r.checkpointCount || 0);
687
744
  g.tokensSaved += (r.tokensSaved || 0);
688
- if (r.inputRate) { g.usd += (r.tokensSaved || 0) * r.inputRate; g.rates.push(r.inputRate); }
745
+ g.tokensIn += (r.tokensDropped || 0);
746
+ g.tokensOut += (r.tokensKept || 0);
747
+ g.sessions += (r.sessions || 0);
748
+ if (r.inputRate) { g.usd += (r.tokensSaved || 0) * r.inputRate; g.inRates.push(r.inputRate); }
749
+ if (r.outputRate) g.outRates.push(r.outputRate);
750
+ if (r.contextWindow) g.ctxWindows.push(r.contextWindow);
751
+ if (r.maxTokens) g.maxTokens.push(r.maxTokens);
752
+ if (r.reasoning != null) g.reasoning = r.reasoning;
689
753
  if (r.lastCompactedAt && r.lastCompactedAt > g.lastAt) g.lastAt = r.lastCompactedAt;
690
754
  }
691
755
  var arr = [];
692
756
  for (var k in groups) { if (Object.prototype.hasOwnProperty.call(groups, k)) arr.push(groups[k]); }
693
757
  arr.sort(function(a, b) { return b.tokensSaved - a.tokensSaved; });
758
+ // Helper: a set of numeric samples collapses to a single value when all
759
+ // repos in the group agree, otherwise shows the range (min–max) so the
760
+ // user can see mixed-config model groups at a glance.
761
+ function collapseNum(samples) {
762
+ if (!samples || !samples.length) return '—';
763
+ var lo = Math.min.apply(null, samples), hi = Math.max.apply(null, samples);
764
+ return lo === hi ? lo.toLocaleString() : lo.toLocaleString() + '–' + hi.toLocaleString();
765
+ }
766
+ function collapseRate(samples) {
767
+ if (!samples || !samples.length) return '—';
768
+ var lo = Math.min.apply(null, samples), hi = Math.max.apply(null, samples);
769
+ var fmt = function(v) { return '$' + v.toFixed(6); };
770
+ return lo === hi ? fmt(lo) : fmt(lo) + '–' + fmt(hi);
771
+ }
694
772
  rows.innerHTML = arr.map(function(g) {
773
+ var freed = (g.tokensIn || 0) - (g.tokensOut || 0);
695
774
  var usd = g.usd > 0 ? '$' + g.usd.toFixed(4) : '—';
696
775
  var when = g.lastAt ? new Date(g.lastAt).toLocaleString() : '—';
776
+ var reas = g.reasoning == null ? '—' : (g.reasoning ? 'yes' : 'no');
697
777
  return '<tr>' +
698
778
  '<td><span class="repo-model">' + sanitize(g.model) + '</span></td>' +
699
779
  '<td>' + sanitize(g.provider) + '</td>' +
700
- '<td class="num">' + g.repos.toLocaleString() + '</td>' +
780
+ '<td class="num">' + (g.tokensIn || 0).toLocaleString() + '</td>' +
781
+ '<td class="num">' + (g.tokensOut || 0).toLocaleString() + '</td>' +
782
+ '<td class="num">' + freed.toLocaleString() + '</td>' +
783
+ '<td class="num">' + collapseNum(g.ctxWindows) + '</td>' +
784
+ '<td class="num">' + collapseNum(g.maxTokens) + '</td>' +
785
+ '<td class="num">' + reas + '</td>' +
786
+ '<td class="num">' + g.sessions.toLocaleString() + '</td>' +
701
787
  '<td class="num">' + g.checkpoints.toLocaleString() + '</td>' +
702
- '<td class="num">' + g.tokensSaved.toLocaleString() + '</td>' +
788
+ '<td class="num">' + collapseRate(g.inRates) + '</td>' +
789
+ '<td class="num">' + collapseRate(g.outRates) + '</td>' +
703
790
  '<td class="num">' + sanitize(usd) + '</td>' +
704
791
  '<td class="num">' + sanitize(when) + '</td>' +
705
792
  '</tr>';
@@ -192,6 +192,10 @@ function doCompact(view, keepFrom, opts, sid, config, pi, ctx, runtime) {
192
192
  embedding: cp.embedding,
193
193
  }));
194
194
  if (leaves.length >= 2) {
195
+ // S25: stamp the tree with the newest checkpoint epoch so the
196
+ // freshness guard in raptorSearchHits can reject stale trees after a
197
+ // later compaction adds newer checkpoints.
198
+ const builtAt = all.length > 0 ? Math.max(...all.map((c) => c.timestamp)) : Date.now();
195
199
  runRaptor(leaves, {
196
200
  stateDir: runtime.currentStateDir,
197
201
  sessionId: sid,
@@ -199,6 +203,7 @@ function doCompact(view, keepFrom, opts, sid, config, pi, ctx, runtime) {
199
203
  clustersPerLevel: dd.RAPTOR_CLUSTERS_PER_LEVEL,
200
204
  consistencyThreshold: dd.RAPTOR_CONSISTENCY,
201
205
  logger: runtime.logger,
206
+ builtAt: Number.isFinite(builtAt) ? builtAt : Date.now(),
202
207
  });
203
208
  }
204
209
  }
@@ -332,9 +332,11 @@ export class MegaRuntime {
332
332
  const repoKept = repo.totalTokenEstimate;
333
333
  const repoFreed = repo.tokensSaved;
334
334
  const repoPct = repoIn > 0 ? repoFreed / repoIn : 0;
335
- // Retro gradient bar — 12 cells, each cell shaded by fill so it reads as a
336
- // smooth green→amber→red ramp instead of a flat block. Higher fill = more
337
- // reclaimed, so the bar trends green at the right end.
335
+ // Retro gradient bar — `w` cells, each shaded by fill position so it
336
+ // reads as a smooth green→amber→red ramp. Used for CONTEXT fill where
337
+ // low=green (room to spare) and high=red (near the limit) — the only
338
+ // live-moving metric worth a bar. Savings ratios saturate near 100% and
339
+ // are shown as explanatory numbers instead (see L2).
338
340
  const ramp = (pct, w = 12) => {
339
341
  const cells = ["▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"];
340
342
  const scaled = Math.max(0, Math.min(w, pct * w));
@@ -353,10 +355,16 @@ export class MegaRuntime {
353
355
  const sTxt = (sessPct * 100).toFixed(sessPct * 100 >= 10 ? 0 : 1);
354
356
  const rTxt = (repoPct * 100).toFixed(repoPct * 100 >= 10 ? 0 : 1);
355
357
  const lines = [
356
- // L1 — header: tier + ctx fill bar + tokens + checkpoints + agents
357
- ` ${C.amber}⚡ ${tierLabel}${C.reset} v${C.bold}${ownVersion()}${C.reset} ${ramp(ctxPct)} ${C.bold}${pctStr}${C.reset} ${tokStr}/${maxStr} ${st.checkpointCount} chk${agentStr}${turnStr}`,
358
- // L2 status + dedup + session + all-time savings bars
359
- ` ${triggerLabel} ${C.magenta}dup ${dedupStr}${C.reset} ${C.gray}sess${C.reset} ${ramp(sessPct)} ${C.green}${sTxt}%${C.reset} ${C.gray}all-time${C.reset} ${ramp(repoPct)} ${C.blue}${rTxt}%${C.reset}`,
358
+ // L1 — header: tier + ctx-fill bar (20-cell, green=room→red=full) +
359
+ // tokens + status glyph + checkpoints + agents/turn. Widened to use the
360
+ // terminal width; the context bar is the only live-moving bar.
361
+ ` ${C.amber} ${tierLabel}${C.reset} v${C.bold}${ownVersion()}${C.reset} ${ramp(ctxPct, 20)} ${C.bold}${pctStr}${C.reset} ${tokStr}/${maxStr} ${triggerLabel} ${st.checkpointCount} chk${agentStr}${turnStr}`,
362
+ // L2 — savings EXPLAINED, not bar'd. The freed/(freed+kept) ratio
363
+ // saturates near 100% once cumulative freed dwarfs live kept (4.8mil
364
+ // freed vs 612 kept), so a bar is visually useless. Instead show the
365
+ // compaction story: "in→kept (X% freed)" reads as "compacted N tokens
366
+ // down to M, freeing X%". Plus repo-wide chk/session counts.
367
+ ` ${C.magenta}dup ${dedupStr}${C.reset} │ ${C.gray}sess${C.reset} ${fmt(sessIn)}→${fmt(sessKept)} kept ${C.green}(${sTxt}% freed)${C.reset} · ${C.gray}all-time${C.reset} ${fmt(repoIn)}→${fmt(repoKept)} kept ${C.blue}(${rTxt}% freed)${C.reset} │ ${repo.checkpointCount} chk/${repo.sessionCount} sess`,
360
368
  ];
361
369
  // Live "now processing" line + why + recent deduped/compacted events,
362
370
  // collapsed to ONE rotating line (fresh only). The ticker ring buffer
@@ -379,11 +387,8 @@ export class MegaRuntime {
379
387
  else if (this.pulsing) {
380
388
  lines.push(` ${pulse}${C.teal}compacting…${C.reset}`);
381
389
  }
382
- // L4 accounting: session + all-time in/out/freed, one compact line.
383
- // in = dropped into compaction, out = kept summaries, freed = saved.
384
- if (lines.length < 10) {
385
- lines.push(` ${C.dim}session ↑${fmt(sessIn)} in ↓${fmt(sessKept)} out ↓${fmt(sessFreed)} freed · all-time ↑${fmt(repoIn)} in ↓${fmt(repoKept)} out ↓${fmt(repoFreed)} freed${C.reset}`);
386
- }
390
+ // (Accounting folded into L2's "in→kept (X% freed)" framing freed =
391
+ // in kept is implied, and the saturated-ratio bars are gone.)
387
392
  ctx.ui.setWidget(WIDGET_KEY, lines, { placement: "aboveEditor" });
388
393
  }
389
394
  }
package/dist/src/adapt.js CHANGED
@@ -36,25 +36,41 @@ export function messageToolName(m) {
36
36
  function contentText(content) {
37
37
  if (typeof content === "string")
38
38
  return content;
39
+ // PREVENT crash: pi blocks can arrive with `text: undefined` or a missing
40
+ // `content` field (tool/custom messages). Coerce to a string at the single
41
+ // choke point so every downstream `.matchAll`/`.split`/`.toLowerCase` is safe
42
+ // (extractive.ts, compact.ts, supersede.ts, summarizer.ts, boundary.ts).
43
+ if (!Array.isArray(content))
44
+ return "";
39
45
  return content
40
- .filter((c) => c.type === "text" && typeof c.text === "string")
46
+ .filter((c) => c?.type === "text" && typeof c.text === "string")
41
47
  .map((c) => c.text)
42
48
  .join("\n");
43
49
  }
44
50
  /** Project any AgentMessage into a single text blob the engine can reason on. */
45
51
  function messageText(m) {
52
+ let out;
46
53
  switch (m.role) {
47
54
  case "toolResult":
48
55
  case "user":
49
56
  case "assistant":
50
57
  case "custom":
51
- return contentText(m.content);
58
+ out = contentText(m.content);
59
+ break;
52
60
  case "bashExecution":
53
- return `${m.command}\n${m.output}`;
61
+ out = `${m.command ?? ""}\n${m.output ?? ""}`;
62
+ break;
54
63
  case "branchSummary":
55
64
  case "compactionSummary":
56
- return m.summary;
65
+ out = m.summary ?? "";
66
+ break;
67
+ default:
68
+ out = "";
69
+ break;
57
70
  }
71
+ // PREVENT crash: final safety net — never let `undefined`/`null` escape the
72
+ // adapter into the engine, which assumes `text: string` everywhere.
73
+ return out ?? "";
58
74
  }
59
75
  /**
60
76
  * Convert a pi message array into the engine's EngineMessage view, keeping
@@ -16,7 +16,11 @@ function truncate(s, max) {
16
16
  return s.length <= max ? s : `${s.slice(0, max)}…`;
17
17
  }
18
18
  function firstText(m) {
19
- const t = m.text.trim();
19
+ // PREVENT crash: pi can hand us a message with text: undefined (pure
20
+ // tool-call/tool-result). Guard the trim so the legacy summarizeMessages
21
+ // path can't throw the same undefined-text crash the extractive path did.
22
+ const raw = m.text ?? "";
23
+ const t = raw.trim();
20
24
  return t.length > 0 ? t : undefined;
21
25
  }
22
26
  /** Heuristic: does this text look like chatty filler we can collapse? */
@@ -35,7 +35,8 @@ export function runRaptor(leaves, opts) {
35
35
  clustersPerLevel: opts.clustersPerLevel,
36
36
  consistencyThreshold: opts.consistencyThreshold,
37
37
  });
38
- saveRaptorTree(opts.sessionId, tree, opts.stateDir);
38
+ const builtAt = opts.builtAt ?? Date.now();
39
+ saveRaptorTree(opts.sessionId, tree, builtAt, opts.stateDir);
39
40
  logger?.info("raptor_build", {
40
41
  sessionId: opts.sessionId,
41
42
  nodes: tree.nodes.size,
@@ -80,6 +81,12 @@ export function rehydrateRaptorTree(sessionId, stateDir) {
80
81
  const nodes = listRaptorNodes(sessionId, stateDir);
81
82
  if (nodes.length === 0)
82
83
  return null;
84
+ // S25: derive freshness + fallback metadata from the persisted nodes.
85
+ // builtAt = max node built_at (0 when unknown → caller treats as stale).
86
+ // timedOut = the tree's root is the extractive-fallback marker (level 99).
87
+ const builtAt = nodes.reduce((max, n) => Math.max(max, n.builtAt), 0);
88
+ const root = nodes.reduce((best, n) => (!best || n.level > (best?.level ?? -1) ? n : best), null);
89
+ const timedOut = root != null && root.level >= 99;
83
90
  const tree = {
84
91
  nodes: new Map(nodes.map((n) => [
85
92
  n.id,
@@ -94,9 +101,10 @@ export function rehydrateRaptorTree(sessionId, stateDir) {
94
101
  tokenEstimate: n.tokenEstimate,
95
102
  },
96
103
  ])),
97
- rootId: nodes.reduce((best, n) => (!best || n.level > (best?.level ?? -1) ? n : best), null)?.id ?? null,
104
+ rootId: root?.id ?? null,
98
105
  levels: Math.max(1, ...nodes.map((n) => n.level + 1)),
99
- timedOut: false,
106
+ timedOut,
107
+ builtAt,
100
108
  };
101
109
  return tree;
102
110
  }
@@ -179,15 +179,20 @@ export function extractiveSummarize(messages) {
179
179
  if (messages.length === 0) {
180
180
  return { topicSummary: "(empty)", keyDecisions: [], nextSteps: [], filesModified: [], tokenEstimate: 0 };
181
181
  }
182
- const toolMsgs = messages.filter((m) => m.role === "tool");
182
+ // PREVENT crash: tool/custom messages can arrive with `text: undefined` when
183
+ // only `input`/`output` is set (the type says string, but pi's runtime does
184
+ // not always fill it). Coerce to "" once at the entry so every downstream
185
+ // `.text` / `.matchAll` / `.split` access is safe.
186
+ const safe = messages.map((m) => ({ ...m, text: m.text ?? "" }));
187
+ const toolMsgs = safe.filter((m) => m.role === "tool");
183
188
  const tools = [...new Set(messages.flatMap((m) => (m.toolName ? [m.toolName] : [])))].sort();
184
- const recentUser = collectRecentUserRequests(messages, MAX_RECENT_USER);
185
- const currentWork = inferCurrentWork(messages);
186
- const keyFiles = collectKeyFiles(messages);
187
- const pending = inferPendingWork(messages);
188
- const keyDecisions = extractDecisions(messages);
189
+ const recentUser = collectRecentUserRequests(safe, MAX_RECENT_USER);
190
+ const currentWork = inferCurrentWork(safe);
191
+ const keyFiles = collectKeyFiles(safe);
192
+ const pending = inferPendingWork(safe);
193
+ const keyDecisions = extractDecisions(safe);
189
194
  const filesModified = extractFilesModified(toolMsgs);
190
- const topicSummary = buildTopicSummary(messages, tools, recentUser, currentWork, keyFiles, pending);
195
+ const topicSummary = buildTopicSummary(safe, tools, recentUser, currentWork, keyFiles, pending);
191
196
  const tokenEstimate = estimateBlockTokens(topicSummary);
192
197
  return { topicSummary, keyDecisions, nextSteps: pending, filesModified, tokenEstimate };
193
198
  }
@@ -4,6 +4,23 @@ import { extractiveSummarize } from "./extractive.js";
4
4
  function msg(role, text, toolName) {
5
5
  return toolName ? { role, text, toolName, input: text, output: text } : { role, text };
6
6
  }
7
+ // ---- Crash regression: undefined text (S25 hotfix) ------------------------
8
+ // pi tool/custom messages can arrive with `text: undefined` when only
9
+ // input/output is set. The adapter (adapt.ts) now coerces to "", and
10
+ // extractiveSummarize guards its entry too. This test pins the no-crash
11
+ // contract directly against the engine entry (defense in depth).
12
+ test("extractiveSummarize does not crash on messages with undefined text", () => {
13
+ const messages = [
14
+ { role: "user", text: "please edit src/index.ts" },
15
+ { role: "assistant", text: undefined, toolName: "Edit", input: "src/index.ts" },
16
+ { role: "tool", text: undefined, toolName: "Edit", output: "ok" },
17
+ { role: "assistant", text: "done editing src/index.ts" },
18
+ ];
19
+ // Must not throw — previously crashed at text.matchAll in extractFilePaths.
20
+ const s = extractiveSummarize(messages);
21
+ assert.ok(typeof s.topicSummary === "string");
22
+ assert.ok(s.topicSummary.length >= 0);
23
+ });
7
24
  // ---- Determinism -----------------------------------------------------------
8
25
  test("extractive summary is deterministic", () => {
9
26
  const messages = [
@@ -180,7 +180,9 @@ export function backfillRaptor(sessionId, stateDir, embedder = defaultEmbedder()
180
180
  return { phase: "RAPTOR", processed: 0, batches: 0, interrupted: false, cursor: undefined };
181
181
  }
182
182
  const tree = buildRaptorTree(leaves, { embedder });
183
- saveRaptorTree(sessionId, tree, stateDir);
183
+ // S25: freshness-guard timestamp = newest checkpoint's epoch.
184
+ const builtAt = all.length > 0 ? Math.max(...all.map((c) => c.timestamp)) : Date.now();
185
+ saveRaptorTree(sessionId, tree, Number.isFinite(builtAt) ? builtAt : Date.now(), stateDir);
184
186
  const db = openStore(stateDir);
185
187
  ensureProgressTable(db);
186
188
  savePhaseCursor(db, "RAPTOR", leaves[leaves.length - 1].id, leaves.length);
@@ -372,6 +372,7 @@ function initSchema(db) {
372
372
  embedding_blob BLOB, -- float32 centroid
373
373
  quality_marker TEXT DEFAULT 'low',
374
374
  token_estimate INTEGER,
375
+ built_at INTEGER, -- S25: epoch ms when the tree was built (freshness guard)
375
376
  PRIMARY KEY (session_id, id)
376
377
  );
377
378
  CREATE INDEX IF NOT EXISTS idx_raptor_session ON raptor_nodes(session_id);
@@ -469,6 +470,9 @@ function initSchema(db) {
469
470
  ensureColumn(db, "memories", "target", "TEXT");
470
471
  ensureColumn(db, "memories", "last_referenced", "INTEGER");
471
472
  ensureColumn(db, "memories", "source_turn", "INTEGER");
473
+ // S25: RAPTOR freshness-guard timestamp. Additive; old DBs have NULL → 0 →
474
+ // treated as stale → flat fallback (safe).
475
+ ensureColumn(db, "raptor_nodes", "built_at", "INTEGER");
472
476
  const v = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get();
473
477
  if (!v) {
474
478
  db.prepare("INSERT INTO meta(key, value) VALUES(?, ?)").run("schema_version", String(SCHEMA_VERSION));
@@ -879,6 +883,15 @@ export function listCheckpoints(sessionId, stateDir = getStateDir()) {
879
883
  .all(sid);
880
884
  return rows.map(rowToCheckpoint);
881
885
  }
886
+ /** S25: the newest checkpoint timestamp for a session, or 0 when none. Used by
887
+ * the RAPTOR freshness guard to reject a tree older than the live checkpoints. */
888
+ export function maxCheckpointTimestamp(sessionId, stateDir = getStateDir()) {
889
+ const db = openStore(stateDir);
890
+ const row = db
891
+ .prepare("SELECT MAX(timestamp) AS mx FROM context_chunks WHERE session_id = ?")
892
+ .get(normalizeSessionId(sessionId));
893
+ return Number(row?.mx ?? 0);
894
+ }
882
895
  /** Next sequential checkpoint id (chkpt_001 …) for a session. */
883
896
  export function nextCheckpointId(sessionId, stateDir = getStateDir()) {
884
897
  const db = openStore(stateDir);
@@ -1050,15 +1063,16 @@ export function closeStore(stateDir) {
1050
1063
  /** Persist a single RAPTOR node (upsert by (session_id, id)). */
1051
1064
  export function upsertRaptorNode(node, stateDir = getStateDir()) {
1052
1065
  const db = openStore(stateDir);
1053
- db.prepare(`INSERT INTO raptor_nodes(id, session_id, level, parent_id, children, summary, embedding_blob, quality_marker, token_estimate)
1054
- VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
1066
+ db.prepare(`INSERT INTO raptor_nodes(id, session_id, level, parent_id, children, summary, embedding_blob, quality_marker, token_estimate, built_at)
1067
+ VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1055
1068
  ON CONFLICT(session_id, id) DO UPDATE SET
1056
1069
  level=excluded.level, parent_id=excluded.parent_id, children=excluded.children,
1057
1070
  summary=excluded.summary, embedding_blob=excluded.embedding_blob,
1058
- quality_marker=excluded.quality_marker, token_estimate=excluded.token_estimate`).run(node.id, node.sessionId, node.level, node.parentId, jsonText(node.children), node.summary, encodeEmbedding(node.embedding), node.qualityMarker, node.tokenEstimate);
1071
+ quality_marker=excluded.quality_marker, token_estimate=excluded.token_estimate,
1072
+ built_at=excluded.built_at`).run(node.id, node.sessionId, node.level, node.parentId, jsonText(node.children), node.summary, encodeEmbedding(node.embedding), node.qualityMarker, node.tokenEstimate, node.builtAt);
1059
1073
  }
1060
1074
  /** Persist an entire built RAPTOR tree for a session (shadow or live). */
1061
- export function saveRaptorTree(sessionId, tree, stateDir = getStateDir()) {
1075
+ export function saveRaptorTree(sessionId, tree, builtAt, stateDir = getStateDir()) {
1062
1076
  for (const node of tree.nodes.values()) {
1063
1077
  upsertRaptorNode({
1064
1078
  id: node.id,
@@ -1070,6 +1084,7 @@ export function saveRaptorTree(sessionId, tree, stateDir = getStateDir()) {
1070
1084
  embedding: node.embedding,
1071
1085
  qualityMarker: node.qualityMarker,
1072
1086
  tokenEstimate: node.tokenEstimate,
1087
+ builtAt,
1073
1088
  }, stateDir);
1074
1089
  }
1075
1090
  }
@@ -1089,6 +1104,7 @@ export function listRaptorNodes(sessionId, stateDir = getStateDir()) {
1089
1104
  embedding: decodeEmbedding(row.embedding_blob),
1090
1105
  qualityMarker: row.quality_marker ?? "low",
1091
1106
  tokenEstimate: row.token_estimate ?? 0,
1107
+ builtAt: Number(row.built_at ?? 0),
1092
1108
  }));
1093
1109
  }
1094
1110
  /** Delete all RAPTOR nodes for a session (rollback/cleanup). */
@@ -9,13 +9,16 @@
9
9
  import { extractFileCandidates } from "./compact.js";
10
10
  /** Classify a message's relationship to a file path. */
11
11
  function fileOps(msg) {
12
- // msg.text may be undefined for pure tool-call/result messages; the guard
13
- // lives in extractFileCandidates, but the early return short-circuits the
14
- // write-detection regex too so we never classify an empty message.
15
- const paths = extractFileCandidates(msg.text);
12
+ // PREVENT crash: msg.text may be undefined for pure tool-call/result
13
+ // messages. extractFileCandidates guards the split, but if it returned a
14
+ // hit we'd still call .toLowerCase() on the raw (possibly-undefined) text.
15
+ // Coerce once so both extractFileCandidates and the write-detection regex
16
+ // are safe, and the early return still skips empty messages.
17
+ const text = msg.text ?? "";
18
+ const paths = extractFileCandidates(text);
16
19
  if (paths.length === 0)
17
20
  return [];
18
- const low = msg.text.toLowerCase();
21
+ const low = text.toLowerCase();
19
22
  const isWrite = /\b(write|edit|create|save|append|overwrite|update|patch|modify)\b/.test(low);
20
23
  return paths.map((p) => ({ path: p, op: isWrite ? "write" : "read" }));
21
24
  }
@@ -19,9 +19,9 @@ import { isNearDuplicate } from "./dedup/l1-verify.js";
19
19
  import { mmrRerank } from "./dedup/mmr.js";
20
20
  import { topK } from "./dedup/topk.js";
21
21
  import { openBloom, saveBloom } from "./store/bloom.js";
22
- import { listCheckpoints, nextCheckpointId, upsertCheckpoint, getCheckpoint, loadSessionState, saveSessionState, upsertMinhashSignature, insertLshBuckets, lshCandidateChunks, setDedupStatus, addTokensSaved, getDedupStats, bumpDedupStats, repoStats as repoStatsFromStore, dataInvariantStats, } from "./store/sqlite.js";
22
+ import { listCheckpoints, nextCheckpointId, upsertCheckpoint, getCheckpoint, loadSessionState, saveSessionState, upsertMinhashSignature, insertLshBuckets, lshCandidateChunks, setDedupStatus, addTokensSaved, getDedupStats, bumpDedupStats, repoStats as repoStatsFromStore, dataInvariantStats, maxCheckpointTimestamp, } from "./store/sqlite.js";
23
23
  import { initVectorIndex, searchAsync as vectorIndexSearch, } from "./store/vectorIndex.js";
24
- import { rehydrateRaptorTree } from "./dedup/raptor/index.js";
24
+ import { rehydrateRaptorTree, isShadowMode } from "./dedup/raptor/index.js";
25
25
  import { stagedExpansion } from "./dedup/raptor/retrieval.js";
26
26
  import { migrateJsonToSqlite } from "./store/migrate.js";
27
27
  /** Default L2 semantic-dedup enable flag (trigram embedder is local, zero-network). */
@@ -440,10 +440,24 @@ export class VectorStore {
440
440
  * exists (small sessions — flat search remains the path). Best-effort/non-fatal.
441
441
  */
442
442
  raptorSearchHits(sid, query, k) {
443
+ const t0 = Date.now();
443
444
  try {
445
+ // S25 gate (a): honor the shadow contract at SERVE time. The tree is still
446
+ // built + persisted (logging-only) but NOT merged into recall while
447
+ // RAPTOR_SHADOW_MODE is anything other than "false".
448
+ if (isShadowMode())
449
+ return [];
444
450
  const tree = rehydrateRaptorTree(sid, this.stateDir);
445
451
  if (!tree || !tree.rootId)
446
452
  return [];
453
+ // S25 gate (b): freshness + fallback guards. Skip a tree built before the
454
+ // newest checkpoint (stale → may reference trimmed/deduped leaves) or one
455
+ // whose root is a budget-exhausted extractive fallback (level 99).
456
+ if (tree.timedOut)
457
+ return [];
458
+ const maxTs = maxCheckpointTimestamp(sid, this.stateDir);
459
+ if (tree.builtAt && tree.builtAt < maxTs)
460
+ return [];
447
461
  const leafIds = stagedExpansion(query, tree, {
448
462
  embedder: this.embedder,
449
463
  k,
@@ -460,6 +474,9 @@ export class VectorStore {
460
474
  if (cp)
461
475
  hits.push({ checkpoint: cp, score: cosineSimilarity(qv, cp.embedding) });
462
476
  }
477
+ // S25 monitoring: emit a raptor_serve decision so canary.ts can track
478
+ // p95 latency + the tier's live traffic (non-fatal, best-effort).
479
+ this.record("RAPTOR", hits.length > 0 ? "new" : "mark_only", `leaves=${leafIds.length}`, Date.now() - t0);
463
480
  return hits;
464
481
  }
465
482
  catch {
@@ -70,6 +70,17 @@ interface IndexRepo {
70
70
  inputRate: number | null;
71
71
  outputRate: number | null;
72
72
  lastSeen: number;
73
+ // Per-repo token + model detail (S25 dashboard enrichment), read directly
74
+ // from each repo's node:sqlite store at stateDir. tokensKept = Σ stored
75
+ // summary tokens ("out"); tokensDropped = Σ original region tokens ("in");
76
+ // sessions = distinct sessions with a checkpoint; contextWindow/maxTokens/
77
+ // reasoning come from the latest model_snapshots row for that repo.
78
+ tokensKept: number;
79
+ tokensDropped: number;
80
+ sessions: number;
81
+ contextWindow: number | null;
82
+ maxTokens: number | null;
83
+ reasoning: boolean | null;
73
84
  }
74
85
 
75
86
  interface IndexSummary {
@@ -107,7 +118,54 @@ function readIndex(): IndexIndex | null {
107
118
  inputRate: (r.input_rate as number | null) ?? null,
108
119
  outputRate: (r.output_rate as number | null) ?? null,
109
120
  lastSeen: Number(r.last_seen ?? 0),
121
+ // Defaults — enriched below from each repo's own store.
122
+ tokensKept: 0,
123
+ tokensDropped: 0,
124
+ sessions: 0,
125
+ contextWindow: null,
126
+ maxTokens: null,
127
+ reasoning: null,
110
128
  }));
129
+ // Enrich each repo with per-store token + model detail read directly via
130
+ // node:sqlite (same zero-dependency invariant as readIndex; no store graph
131
+ // import). Best-effort: a missing/corrupt store degrades to the defaults
132
+ // above so the dashboard never fails to render.
133
+ for (const repo of mapped) {
134
+ try {
135
+ const storePath = join(repo.stateDir, "sqlite.db");
136
+ if (existsSync(storePath)) {
137
+ const sdb = new DatabaseSync(storePath, { readOnly: true });
138
+ try {
139
+ const tok = sdb
140
+ .prepare(
141
+ `SELECT COALESCE(SUM(token_estimate),0) AS kept,
142
+ COALESCE(SUM(original_token_estimate),0) AS dropped,
143
+ COUNT(DISTINCT session_id) AS sess
144
+ FROM context_chunks WHERE dedup_status != 'removed'`,
145
+ )
146
+ .get() as { kept: number; dropped: number; sess: number };
147
+ repo.tokensKept = Number(tok.kept ?? 0);
148
+ repo.tokensDropped = Number(tok.dropped ?? 0);
149
+ repo.sessions = Number(tok.sess ?? 0);
150
+ const mrow = sdb
151
+ .prepare(
152
+ `SELECT context_window, max_tokens, reasoning
153
+ FROM model_snapshots ORDER BY captured_at DESC LIMIT 1`,
154
+ )
155
+ .get() as { context_window: number; max_tokens: number; reasoning: number } | undefined;
156
+ if (mrow) {
157
+ repo.contextWindow = Number(mrow.context_window ?? 0) || null;
158
+ repo.maxTokens = Number(mrow.max_tokens ?? 0) || null;
159
+ repo.reasoning = Number(mrow.reasoning ?? 0) === 1;
160
+ }
161
+ } finally {
162
+ sdb.close();
163
+ }
164
+ }
165
+ } catch {
166
+ /* best-effort — keep the defaults */
167
+ }
168
+ }
111
169
  // Defensive display hygiene (belt-and-suspenders — the real fix is that
112
170
  // tests now isolate via MEGACOMPACT_INDEX_DIR): drop transient test/temp
113
171
  // paths that should never have been real repos, and collapse duplicate
@@ -541,15 +599,23 @@ function dashboardHtml(tierName: string): string {
541
599
  <thead>
542
600
  <tr>
543
601
  <th>Model</th><th>Provider</th>
544
- <th style="text-align:right">Repos</th>
602
+ <th style="text-align:right" title="Tokens dropped from context by compaction (the input reclaimed)">Tokens In</th>
603
+ <th style="text-align:right" title="Tokens kept as compacted summaries still in context (the output retained)">Tokens Out</th>
604
+ <th style="text-align:right">Freed</th>
605
+ <th style="text-align:right" title="Model context window (max input tokens the model accepts)">Ctx Window</th>
606
+ <th style="text-align:right" title="Model max output tokens per turn">Max Out</th>
607
+ <th style="text-align:right" title="Reasoning-capable model">Reas.</th>
608
+ <th style="text-align:right" title="Distinct sessions with at least one checkpoint">Sessions</th>
545
609
  <th style="text-align:right">Checkpoints</th>
546
- <th style="text-align:right">Tokens Saved</th>
610
+ <th style="text-align:right" title="USD per input token">In $/tok</th>
611
+ <th style="text-align:right" title="USD per output token">Out $/tok</th>
547
612
  <th style="text-align:right">$ Saved</th>
548
613
  <th style="text-align:right">Last Used</th>
549
614
  </tr>
550
615
  </thead>
551
- <tbody id="bm-rows"><tr><td colspan="7" class="repo-none">loading…</td></tr></tbody>
616
+ <tbody id="bm-rows"><tr><td colspan="14" class="repo-none">loading…</td></tr></tbody>
552
617
  </table>
618
+ <p class="legend-note" style="margin-top:8px">Tokens In = Σ original region tokens dropped by compaction. Tokens Out = Σ compacted summary tokens still retained in context. Freed = Tokens In − Tokens Out (net context reclaimed). Ctx Window / Max Out / Reas. come from the latest captured model snapshot for each repo.</p>
553
619
 
554
620
  <div class="updated" id="sm-updated"></div>
555
621
  </div>
@@ -771,33 +837,67 @@ function dashboardHtml(tierName: string): string {
771
837
  var rows = document.getElementById('bm-rows');
772
838
  if (!rows) return;
773
839
  if (!repos || !repos.length) {
774
- rows.innerHTML = '<tr><td colspan="7" class="repo-none">No repositories registered yet.</td></tr>';
840
+ rows.innerHTML = '<tr><td colspan="14" class="repo-none">No repositories registered yet.</td></tr>';
775
841
  return;
776
842
  }
777
843
  var groups = {};
778
844
  for (var i = 0; i < repos.length; i++) {
779
845
  var r = repos[i];
780
846
  var key = (r.modelName && String(r.modelName).trim()) || '(unknown)';
781
- if (!groups[key]) groups[key] = { model: key, provider: r.providerName || r.provider || '—', repos: 0, checkpoints: 0, tokensSaved: 0, usd: 0, lastAt: 0, rates: [] };
847
+ if (!groups[key]) groups[key] = {
848
+ model: key, provider: r.providerName || r.provider || '—', repos: 0, checkpoints: 0,
849
+ tokensSaved: 0, tokensIn: 0, tokensOut: 0, sessions: 0, usd: 0, lastAt: 0,
850
+ inRates: [], outRates: [], ctxWindows: [], maxTokens: [], reasoning: null,
851
+ };
782
852
  var g = groups[key];
783
853
  g.repos++;
784
854
  g.checkpoints += (r.checkpointCount || 0);
785
855
  g.tokensSaved += (r.tokensSaved || 0);
786
- if (r.inputRate) { g.usd += (r.tokensSaved || 0) * r.inputRate; g.rates.push(r.inputRate); }
856
+ g.tokensIn += (r.tokensDropped || 0);
857
+ g.tokensOut += (r.tokensKept || 0);
858
+ g.sessions += (r.sessions || 0);
859
+ if (r.inputRate) { g.usd += (r.tokensSaved || 0) * r.inputRate; g.inRates.push(r.inputRate); }
860
+ if (r.outputRate) g.outRates.push(r.outputRate);
861
+ if (r.contextWindow) g.ctxWindows.push(r.contextWindow);
862
+ if (r.maxTokens) g.maxTokens.push(r.maxTokens);
863
+ if (r.reasoning != null) g.reasoning = r.reasoning;
787
864
  if (r.lastCompactedAt && r.lastCompactedAt > g.lastAt) g.lastAt = r.lastCompactedAt;
788
865
  }
789
866
  var arr = [];
790
867
  for (var k in groups) { if (Object.prototype.hasOwnProperty.call(groups, k)) arr.push(groups[k]); }
791
868
  arr.sort(function(a, b) { return b.tokensSaved - a.tokensSaved; });
869
+ // Helper: a set of numeric samples collapses to a single value when all
870
+ // repos in the group agree, otherwise shows the range (min–max) so the
871
+ // user can see mixed-config model groups at a glance.
872
+ function collapseNum(samples) {
873
+ if (!samples || !samples.length) return '—';
874
+ var lo = Math.min.apply(null, samples), hi = Math.max.apply(null, samples);
875
+ return lo === hi ? lo.toLocaleString() : lo.toLocaleString() + '–' + hi.toLocaleString();
876
+ }
877
+ function collapseRate(samples) {
878
+ if (!samples || !samples.length) return '—';
879
+ var lo = Math.min.apply(null, samples), hi = Math.max.apply(null, samples);
880
+ var fmt = function(v) { return '$' + v.toFixed(6); };
881
+ return lo === hi ? fmt(lo) : fmt(lo) + '–' + fmt(hi);
882
+ }
792
883
  rows.innerHTML = arr.map(function(g) {
884
+ var freed = (g.tokensIn || 0) - (g.tokensOut || 0);
793
885
  var usd = g.usd > 0 ? '$' + g.usd.toFixed(4) : '—';
794
886
  var when = g.lastAt ? new Date(g.lastAt).toLocaleString() : '—';
887
+ var reas = g.reasoning == null ? '—' : (g.reasoning ? 'yes' : 'no');
795
888
  return '<tr>' +
796
889
  '<td><span class="repo-model">' + sanitize(g.model) + '</span></td>' +
797
890
  '<td>' + sanitize(g.provider) + '</td>' +
798
- '<td class="num">' + g.repos.toLocaleString() + '</td>' +
891
+ '<td class="num">' + (g.tokensIn || 0).toLocaleString() + '</td>' +
892
+ '<td class="num">' + (g.tokensOut || 0).toLocaleString() + '</td>' +
893
+ '<td class="num">' + freed.toLocaleString() + '</td>' +
894
+ '<td class="num">' + collapseNum(g.ctxWindows) + '</td>' +
895
+ '<td class="num">' + collapseNum(g.maxTokens) + '</td>' +
896
+ '<td class="num">' + reas + '</td>' +
897
+ '<td class="num">' + g.sessions.toLocaleString() + '</td>' +
799
898
  '<td class="num">' + g.checkpoints.toLocaleString() + '</td>' +
800
- '<td class="num">' + g.tokensSaved.toLocaleString() + '</td>' +
899
+ '<td class="num">' + collapseRate(g.inRates) + '</td>' +
900
+ '<td class="num">' + collapseRate(g.outRates) + '</td>' +
801
901
  '<td class="num">' + sanitize(usd) + '</td>' +
802
902
  '<td class="num">' + sanitize(when) + '</td>' +
803
903
  '</tr>';
@@ -240,6 +240,10 @@ function doCompact(
240
240
  embedding: cp.embedding,
241
241
  }));
242
242
  if (leaves.length >= 2) {
243
+ // S25: stamp the tree with the newest checkpoint epoch so the
244
+ // freshness guard in raptorSearchHits can reject stale trees after a
245
+ // later compaction adds newer checkpoints.
246
+ const builtAt = all.length > 0 ? Math.max(...all.map((c) => c.timestamp)) : Date.now();
243
247
  runRaptor(
244
248
  leaves,
245
249
  {
@@ -249,6 +253,7 @@ function doCompact(
249
253
  clustersPerLevel: dd.RAPTOR_CLUSTERS_PER_LEVEL,
250
254
  consistencyThreshold: dd.RAPTOR_CONSISTENCY,
251
255
  logger: runtime.logger,
256
+ builtAt: Number.isFinite(builtAt) ? builtAt : Date.now(),
252
257
  },
253
258
  );
254
259
  }
@@ -361,9 +361,11 @@ export class MegaRuntime {
361
361
  const repoKept = repo.totalTokenEstimate;
362
362
  const repoFreed = repo.tokensSaved;
363
363
  const repoPct = repoIn > 0 ? repoFreed / repoIn : 0;
364
- // Retro gradient bar — 12 cells, each cell shaded by fill so it reads as a
365
- // smooth green→amber→red ramp instead of a flat block. Higher fill = more
366
- // reclaimed, so the bar trends green at the right end.
364
+ // Retro gradient bar — `w` cells, each shaded by fill position so it
365
+ // reads as a smooth green→amber→red ramp. Used for CONTEXT fill where
366
+ // low=green (room to spare) and high=red (near the limit) — the only
367
+ // live-moving metric worth a bar. Savings ratios saturate near 100% and
368
+ // are shown as explanatory numbers instead (see L2).
367
369
  const ramp = (pct: number, w = 12): string => {
368
370
  const cells = ["▏","▎","▍","▌","▋","▊","▉","█"];
369
371
  const scaled = Math.max(0, Math.min(w, pct * w));
@@ -380,10 +382,16 @@ export class MegaRuntime {
380
382
  const sTxt = (sessPct * 100).toFixed(sessPct * 100 >= 10 ? 0 : 1);
381
383
  const rTxt = (repoPct * 100).toFixed(repoPct * 100 >= 10 ? 0 : 1);
382
384
  const lines = [
383
- // L1 — header: tier + ctx fill bar + tokens + checkpoints + agents
384
- ` ${C.amber}⚡ ${tierLabel}${C.reset} v${C.bold}${ownVersion()}${C.reset} ${ramp(ctxPct)} ${C.bold}${pctStr}${C.reset} ${tokStr}/${maxStr} ${st.checkpointCount} chk${agentStr}${turnStr}`,
385
- // L2 status + dedup + session + all-time savings bars
386
- ` ${triggerLabel} ${C.magenta}dup ${dedupStr}${C.reset} ${C.gray}sess${C.reset} ${ramp(sessPct)} ${C.green}${sTxt}%${C.reset} ${C.gray}all-time${C.reset} ${ramp(repoPct)} ${C.blue}${rTxt}%${C.reset}`,
385
+ // L1 — header: tier + ctx-fill bar (20-cell, green=room→red=full) +
386
+ // tokens + status glyph + checkpoints + agents/turn. Widened to use the
387
+ // terminal width; the context bar is the only live-moving bar.
388
+ ` ${C.amber} ${tierLabel}${C.reset} v${C.bold}${ownVersion()}${C.reset} ${ramp(ctxPct, 20)} ${C.bold}${pctStr}${C.reset} ${tokStr}/${maxStr} ${triggerLabel} ${st.checkpointCount} chk${agentStr}${turnStr}`,
389
+ // L2 — savings EXPLAINED, not bar'd. The freed/(freed+kept) ratio
390
+ // saturates near 100% once cumulative freed dwarfs live kept (4.8mil
391
+ // freed vs 612 kept), so a bar is visually useless. Instead show the
392
+ // compaction story: "in→kept (X% freed)" reads as "compacted N tokens
393
+ // down to M, freeing X%". Plus repo-wide chk/session counts.
394
+ ` ${C.magenta}dup ${dedupStr}${C.reset} │ ${C.gray}sess${C.reset} ${fmt(sessIn)}→${fmt(sessKept)} kept ${C.green}(${sTxt}% freed)${C.reset} · ${C.gray}all-time${C.reset} ${fmt(repoIn)}→${fmt(repoKept)} kept ${C.blue}(${rTxt}% freed)${C.reset} │ ${repo.checkpointCount} chk/${repo.sessionCount} sess`,
387
395
  ];
388
396
  // Live "now processing" line + why + recent deduped/compacted events,
389
397
  // collapsed to ONE rotating line (fresh only). The ticker ring buffer
@@ -404,11 +412,8 @@ export class MegaRuntime {
404
412
  } else if (this.pulsing) {
405
413
  lines.push(` ${pulse}${C.teal}compacting…${C.reset}`);
406
414
  }
407
- // L4 accounting: session + all-time in/out/freed, one compact line.
408
- // in = dropped into compaction, out = kept summaries, freed = saved.
409
- if (lines.length < 10) {
410
- lines.push(` ${C.dim}session ↑${fmt(sessIn)} in ↓${fmt(sessKept)} out ↓${fmt(sessFreed)} freed · all-time ↑${fmt(repoIn)} in ↓${fmt(repoKept)} out ↓${fmt(repoFreed)} freed${C.reset}`);
411
- }
415
+ // (Accounting folded into L2's "in→kept (X% freed)" framing freed =
416
+ // in kept is implied, and the saturated-ratio bars are gone.)
412
417
  ctx.ui.setWidget(WIDGET_KEY, lines, { placement: "aboveEditor" });
413
418
  }
414
419
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.6.6",
3
+ "version": "0.6.9",
4
4
  "description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
5
5
  "type": "module",
6
6
  "license": "BSD-2-Clause",
package/src/adapt.ts CHANGED
@@ -44,26 +44,41 @@ export function messageToolName(m: AgentMessage): string | undefined {
44
44
  /** Pull the text out of a string-or-blocks content field. */
45
45
  function contentText(content: string | Array<{ type: string; text?: string }>): string {
46
46
  if (typeof content === "string") return content;
47
+ // PREVENT crash: pi blocks can arrive with `text: undefined` or a missing
48
+ // `content` field (tool/custom messages). Coerce to a string at the single
49
+ // choke point so every downstream `.matchAll`/`.split`/`.toLowerCase` is safe
50
+ // (extractive.ts, compact.ts, supersede.ts, summarizer.ts, boundary.ts).
51
+ if (!Array.isArray(content)) return "";
47
52
  return content
48
- .filter((c) => c.type === "text" && typeof c.text === "string")
53
+ .filter((c) => c?.type === "text" && typeof c.text === "string")
49
54
  .map((c) => c.text as string)
50
55
  .join("\n");
51
56
  }
52
57
 
53
58
  /** Project any AgentMessage into a single text blob the engine can reason on. */
54
59
  function messageText(m: AgentMessage): string {
60
+ let out: string;
55
61
  switch (m.role) {
56
62
  case "toolResult":
57
63
  case "user":
58
64
  case "assistant":
59
65
  case "custom":
60
- return contentText((m as { content: string | Array<{ type: string; text?: string }> }).content);
66
+ out = contentText((m as { content: string | Array<{ type: string; text?: string }> }).content);
67
+ break;
61
68
  case "bashExecution":
62
- return `${(m as { command: string }).command}\n${(m as { output: string }).output}`;
69
+ out = `${(m as { command: string }).command ?? ""}\n${(m as { output: string }).output ?? ""}`;
70
+ break;
63
71
  case "branchSummary":
64
72
  case "compactionSummary":
65
- return (m as { summary: string }).summary;
73
+ out = (m as { summary: string }).summary ?? "";
74
+ break;
75
+ default:
76
+ out = "";
77
+ break;
66
78
  }
79
+ // PREVENT crash: final safety net — never let `undefined`/`null` escape the
80
+ // adapter into the engine, which assumes `text: string` everywhere.
81
+ return out ?? "";
67
82
  }
68
83
 
69
84
  /**
package/src/compact.ts CHANGED
@@ -23,7 +23,11 @@ function truncate(s: string, max: number): string {
23
23
  }
24
24
 
25
25
  function firstText(m: EngineMessage): string | undefined {
26
- const t = m.text.trim();
26
+ // PREVENT crash: pi can hand us a message with text: undefined (pure
27
+ // tool-call/tool-result). Guard the trim so the legacy summarizeMessages
28
+ // path can't throw the same undefined-text crash the extractive path did.
29
+ const raw = m.text ?? "";
30
+ const t = raw.trim();
27
31
  return t.length > 0 ? t : undefined;
28
32
  }
29
33
 
@@ -31,6 +31,8 @@ export interface RaptorOrchestratorOptions {
31
31
  consistencyThreshold?: number;
32
32
  /** Best-effort logger for shadow events. */
33
33
  logger?: Logger;
34
+ /** S25: epoch ms to stamp on every node (freshness guard). Defaults to now. */
35
+ builtAt?: number;
34
36
  }
35
37
 
36
38
  /**
@@ -54,7 +56,8 @@ export function runRaptor(
54
56
  clustersPerLevel: opts.clustersPerLevel,
55
57
  consistencyThreshold: opts.consistencyThreshold,
56
58
  });
57
- saveRaptorTree(opts.sessionId, tree, opts.stateDir);
59
+ const builtAt = opts.builtAt ?? Date.now();
60
+ saveRaptorTree(opts.sessionId, tree, builtAt, opts.stateDir);
58
61
  logger?.info("raptor_build", {
59
62
  sessionId: opts.sessionId,
60
63
  nodes: tree.nodes.size,
@@ -105,6 +108,15 @@ export function rehydrateRaptorTree(
105
108
  ): RaptorTree | null {
106
109
  const nodes = listRaptorNodes(sessionId, stateDir);
107
110
  if (nodes.length === 0) return null;
111
+ // S25: derive freshness + fallback metadata from the persisted nodes.
112
+ // builtAt = max node built_at (0 when unknown → caller treats as stale).
113
+ // timedOut = the tree's root is the extractive-fallback marker (level 99).
114
+ const builtAt = nodes.reduce((max, n) => Math.max(max, n.builtAt), 0);
115
+ const root = nodes.reduce<typeof nodes[number] | null>(
116
+ (best, n) => (!best || n.level > (best?.level ?? -1) ? n : best),
117
+ null,
118
+ );
119
+ const timedOut = root != null && root.level >= 99;
108
120
  const tree: RaptorTree = {
109
121
  nodes: new Map(
110
122
  nodes.map((n) => [
@@ -121,13 +133,10 @@ export function rehydrateRaptorTree(
121
133
  },
122
134
  ]),
123
135
  ),
124
- rootId:
125
- nodes.reduce<typeof nodes[number] | null>(
126
- (best, n) => (!best || n.level > (best?.level ?? -1) ? n : best),
127
- null,
128
- )?.id ?? null,
136
+ rootId: root?.id ?? null,
129
137
  levels: Math.max(1, ...nodes.map((n) => n.level + 1)),
130
- timedOut: false,
138
+ timedOut,
139
+ builtAt,
131
140
  };
132
141
  return tree;
133
142
  }
@@ -40,6 +40,8 @@ export interface RaptorTree {
40
40
  levels: number;
41
41
  /** True when the budget forced an extractive fallback root. */
42
42
  timedOut: boolean;
43
+ /** S25: epoch ms when the tree was built (freshness guard). 0 when unknown. */
44
+ builtAt?: number;
43
45
  }
44
46
 
45
47
  export interface Leaf {
@@ -7,6 +7,24 @@ function msg(role: EngineMessage["role"], text: string, toolName?: string): Engi
7
7
  return toolName ? { role, text, toolName, input: text, output: text } : { role, text };
8
8
  }
9
9
 
10
+ // ---- Crash regression: undefined text (S25 hotfix) ------------------------
11
+ // pi tool/custom messages can arrive with `text: undefined` when only
12
+ // input/output is set. The adapter (adapt.ts) now coerces to "", and
13
+ // extractiveSummarize guards its entry too. This test pins the no-crash
14
+ // contract directly against the engine entry (defense in depth).
15
+ test("extractiveSummarize does not crash on messages with undefined text", () => {
16
+ const messages = [
17
+ { role: "user", text: "please edit src/index.ts" },
18
+ { role: "assistant", text: undefined as unknown as string, toolName: "Edit", input: "src/index.ts" },
19
+ { role: "tool", text: undefined as unknown as string, toolName: "Edit", output: "ok" },
20
+ { role: "assistant", text: "done editing src/index.ts" },
21
+ ] as EngineMessage[];
22
+ // Must not throw — previously crashed at text.matchAll in extractFilePaths.
23
+ const s = extractiveSummarize(messages);
24
+ assert.ok(typeof s.topicSummary === "string");
25
+ assert.ok(s.topicSummary.length >= 0);
26
+ });
27
+
10
28
  // ---- Determinism -----------------------------------------------------------
11
29
 
12
30
  test("extractive summary is deterministic", () => {
package/src/extractive.ts CHANGED
@@ -226,18 +226,24 @@ export function extractiveSummarize(messages: EngineMessage[]): ExtractiveSummar
226
226
  return { topicSummary: "(empty)", keyDecisions: [], nextSteps: [], filesModified: [], tokenEstimate: 0 };
227
227
  }
228
228
 
229
- const toolMsgs = messages.filter((m) => m.role === "tool");
229
+ // PREVENT crash: tool/custom messages can arrive with `text: undefined` when
230
+ // only `input`/`output` is set (the type says string, but pi's runtime does
231
+ // not always fill it). Coerce to "" once at the entry so every downstream
232
+ // `.text` / `.matchAll` / `.split` access is safe.
233
+ const safe = messages.map((m) => ({ ...m, text: m.text ?? "" }));
234
+
235
+ const toolMsgs = safe.filter((m) => m.role === "tool");
230
236
  const tools = [...new Set(messages.flatMap((m) => (m.toolName ? [m.toolName] : [])))].sort();
231
237
 
232
- const recentUser = collectRecentUserRequests(messages, MAX_RECENT_USER);
233
- const currentWork = inferCurrentWork(messages);
234
- const keyFiles = collectKeyFiles(messages);
235
- const pending = inferPendingWork(messages);
236
- const keyDecisions = extractDecisions(messages);
238
+ const recentUser = collectRecentUserRequests(safe, MAX_RECENT_USER);
239
+ const currentWork = inferCurrentWork(safe);
240
+ const keyFiles = collectKeyFiles(safe);
241
+ const pending = inferPendingWork(safe);
242
+ const keyDecisions = extractDecisions(safe);
237
243
  const filesModified = extractFilesModified(toolMsgs);
238
244
 
239
245
  const topicSummary = buildTopicSummary(
240
- messages, tools, recentUser, currentWork, keyFiles, pending,
246
+ safe, tools, recentUser, currentWork, keyFiles, pending,
241
247
  );
242
248
 
243
249
  const tokenEstimate = estimateBlockTokens(topicSummary);
@@ -254,7 +254,9 @@ export function backfillRaptor(
254
254
  return { phase: "RAPTOR", processed: 0, batches: 0, interrupted: false, cursor: undefined };
255
255
  }
256
256
  const tree = buildRaptorTree(leaves, { embedder });
257
- saveRaptorTree(sessionId, tree, stateDir);
257
+ // S25: freshness-guard timestamp = newest checkpoint's epoch.
258
+ const builtAt = all.length > 0 ? Math.max(...all.map((c) => c.timestamp)) : Date.now();
259
+ saveRaptorTree(sessionId, tree, Number.isFinite(builtAt) ? builtAt : Date.now(), stateDir);
258
260
  const db = openStore(stateDir);
259
261
  ensureProgressTable(db);
260
262
  savePhaseCursor(db, "RAPTOR", leaves[leaves.length - 1].id, leaves.length);
@@ -458,6 +458,7 @@ function initSchema(db: DatabaseSync): void {
458
458
  embedding_blob BLOB, -- float32 centroid
459
459
  quality_marker TEXT DEFAULT 'low',
460
460
  token_estimate INTEGER,
461
+ built_at INTEGER, -- S25: epoch ms when the tree was built (freshness guard)
461
462
  PRIMARY KEY (session_id, id)
462
463
  );
463
464
  CREATE INDEX IF NOT EXISTS idx_raptor_session ON raptor_nodes(session_id);
@@ -555,6 +556,9 @@ function initSchema(db: DatabaseSync): void {
555
556
  ensureColumn(db, "memories", "target", "TEXT");
556
557
  ensureColumn(db, "memories", "last_referenced", "INTEGER");
557
558
  ensureColumn(db, "memories", "source_turn", "INTEGER");
559
+ // S25: RAPTOR freshness-guard timestamp. Additive; old DBs have NULL → 0 →
560
+ // treated as stale → flat fallback (safe).
561
+ ensureColumn(db, "raptor_nodes", "built_at", "INTEGER");
558
562
  const v = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get() as
559
563
  | { value: string }
560
564
  | undefined;
@@ -1103,6 +1107,16 @@ export function listCheckpoints(sessionId: string, stateDir: string = getStateDi
1103
1107
  return rows.map(rowToCheckpoint);
1104
1108
  }
1105
1109
 
1110
+ /** S25: the newest checkpoint timestamp for a session, or 0 when none. Used by
1111
+ * the RAPTOR freshness guard to reject a tree older than the live checkpoints. */
1112
+ export function maxCheckpointTimestamp(sessionId: string, stateDir: string = getStateDir()): number {
1113
+ const db = openStore(stateDir);
1114
+ const row = db
1115
+ .prepare("SELECT MAX(timestamp) AS mx FROM context_chunks WHERE session_id = ?")
1116
+ .get(normalizeSessionId(sessionId)) as { mx: number | null } | undefined;
1117
+ return Number(row?.mx ?? 0);
1118
+ }
1119
+
1106
1120
  /** Next sequential checkpoint id (chkpt_001 …) for a session. */
1107
1121
  export function nextCheckpointId(sessionId: string, stateDir: string = getStateDir()): string {
1108
1122
  const db = openStore(stateDir);
@@ -1404,18 +1418,21 @@ export interface StoredRaptorNode {
1404
1418
  embedding: number[];
1405
1419
  qualityMarker: string;
1406
1420
  tokenEstimate: number;
1421
+ /** S25: epoch ms when the tree containing this node was built. */
1422
+ builtAt: number;
1407
1423
  }
1408
1424
 
1409
1425
  /** Persist a single RAPTOR node (upsert by (session_id, id)). */
1410
1426
  export function upsertRaptorNode(node: StoredRaptorNode, stateDir: string = getStateDir()): void {
1411
1427
  const db = openStore(stateDir);
1412
1428
  db.prepare(
1413
- `INSERT INTO raptor_nodes(id, session_id, level, parent_id, children, summary, embedding_blob, quality_marker, token_estimate)
1414
- VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
1429
+ `INSERT INTO raptor_nodes(id, session_id, level, parent_id, children, summary, embedding_blob, quality_marker, token_estimate, built_at)
1430
+ VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1415
1431
  ON CONFLICT(session_id, id) DO UPDATE SET
1416
1432
  level=excluded.level, parent_id=excluded.parent_id, children=excluded.children,
1417
1433
  summary=excluded.summary, embedding_blob=excluded.embedding_blob,
1418
- quality_marker=excluded.quality_marker, token_estimate=excluded.token_estimate`,
1434
+ quality_marker=excluded.quality_marker, token_estimate=excluded.token_estimate,
1435
+ built_at=excluded.built_at`,
1419
1436
  ).run(
1420
1437
  node.id,
1421
1438
  node.sessionId,
@@ -1426,13 +1443,26 @@ export function upsertRaptorNode(node: StoredRaptorNode, stateDir: string = getS
1426
1443
  encodeEmbedding(node.embedding),
1427
1444
  node.qualityMarker,
1428
1445
  node.tokenEstimate,
1446
+ node.builtAt,
1429
1447
  );
1430
1448
  }
1431
1449
 
1432
1450
  /** Persist an entire built RAPTOR tree for a session (shadow or live). */
1433
1451
  export function saveRaptorTree(
1434
1452
  sessionId: string,
1435
- tree: { nodes: Map<string, { id: string; level: number; parentId: string | null; children: string[]; summary: string; embedding: number[]; qualityMarker: string; tokenEstimate: number }> },
1453
+ tree: {
1454
+ nodes: Map<string, {
1455
+ id: string;
1456
+ level: number;
1457
+ parentId: string | null;
1458
+ children: string[];
1459
+ summary: string;
1460
+ embedding: number[];
1461
+ qualityMarker: string;
1462
+ tokenEstimate: number;
1463
+ }>
1464
+ },
1465
+ builtAt: number,
1436
1466
  stateDir: string = getStateDir(),
1437
1467
  ): void {
1438
1468
  for (const node of tree.nodes.values()) {
@@ -1447,6 +1477,7 @@ export function saveRaptorTree(
1447
1477
  embedding: node.embedding,
1448
1478
  qualityMarker: node.qualityMarker,
1449
1479
  tokenEstimate: node.tokenEstimate,
1480
+ builtAt,
1450
1481
  },
1451
1482
  stateDir,
1452
1483
  );
@@ -1469,6 +1500,7 @@ export function listRaptorNodes(sessionId: string, stateDir: string = getStateDi
1469
1500
  embedding: decodeEmbedding(row.embedding_blob),
1470
1501
  qualityMarker: row.quality_marker ?? "low",
1471
1502
  tokenEstimate: row.token_estimate ?? 0,
1503
+ builtAt: Number(row.built_at ?? 0),
1472
1504
  }));
1473
1505
  }
1474
1506
 
package/src/supersede.ts CHANGED
@@ -12,12 +12,15 @@ import { extractFileCandidates } from "./compact.js";
12
12
 
13
13
  /** Classify a message's relationship to a file path. */
14
14
  function fileOps(msg: EngineMessage): { path: string; op: "read" | "write" }[] {
15
- // msg.text may be undefined for pure tool-call/result messages; the guard
16
- // lives in extractFileCandidates, but the early return short-circuits the
17
- // write-detection regex too so we never classify an empty message.
18
- const paths = extractFileCandidates(msg.text);
15
+ // PREVENT crash: msg.text may be undefined for pure tool-call/result
16
+ // messages. extractFileCandidates guards the split, but if it returned a
17
+ // hit we'd still call .toLowerCase() on the raw (possibly-undefined) text.
18
+ // Coerce once so both extractFileCandidates and the write-detection regex
19
+ // are safe, and the early return still skips empty messages.
20
+ const text = msg.text ?? "";
21
+ const paths = extractFileCandidates(text);
19
22
  if (paths.length === 0) return [];
20
- const low = msg.text.toLowerCase();
23
+ const low = text.toLowerCase();
21
24
  const isWrite = /\b(write|edit|create|save|append|overwrite|update|patch|modify)\b/.test(low);
22
25
  return paths.map((p) => ({ path: p, op: isWrite ? "write" : "read" }));
23
26
  }
@@ -38,13 +38,14 @@ import {
38
38
  bumpDedupStats,
39
39
  repoStats as repoStatsFromStore,
40
40
  dataInvariantStats,
41
+ maxCheckpointTimestamp,
41
42
  } from "./store/sqlite.js";
42
43
  import {
43
44
  initVectorIndex,
44
45
  searchAsync as vectorIndexSearch,
45
46
  type VectorIndexHit,
46
47
  } from "./store/vectorIndex.js";
47
- import { rehydrateRaptorTree } from "./dedup/raptor/index.js";
48
+ import { rehydrateRaptorTree, isShadowMode } from "./dedup/raptor/index.js";
48
49
  import { stagedExpansion } from "./dedup/raptor/retrieval.js";
49
50
  import { migrateJsonToSqlite } from "./store/migrate.js";
50
51
 
@@ -557,9 +558,20 @@ export class VectorStore {
557
558
  * exists (small sessions — flat search remains the path). Best-effort/non-fatal.
558
559
  */
559
560
  private raptorSearchHits(sid: string, query: string, k: number): SearchHit[] {
561
+ const t0 = Date.now();
560
562
  try {
563
+ // S25 gate (a): honor the shadow contract at SERVE time. The tree is still
564
+ // built + persisted (logging-only) but NOT merged into recall while
565
+ // RAPTOR_SHADOW_MODE is anything other than "false".
566
+ if (isShadowMode()) return [];
561
567
  const tree = rehydrateRaptorTree(sid, this.stateDir);
562
568
  if (!tree || !tree.rootId) return [];
569
+ // S25 gate (b): freshness + fallback guards. Skip a tree built before the
570
+ // newest checkpoint (stale → may reference trimmed/deduped leaves) or one
571
+ // whose root is a budget-exhausted extractive fallback (level 99).
572
+ if (tree.timedOut) return [];
573
+ const maxTs = maxCheckpointTimestamp(sid, this.stateDir);
574
+ if (tree.builtAt && tree.builtAt < maxTs) return [];
563
575
  const leafIds = stagedExpansion(query, tree, {
564
576
  embedder: this.embedder,
565
577
  k,
@@ -576,6 +588,9 @@ export class VectorStore {
576
588
  const cp = all.find((c) => c.checkpointId === id);
577
589
  if (cp) hits.push({ checkpoint: cp, score: cosineSimilarity(qv, cp.embedding) });
578
590
  }
591
+ // S25 monitoring: emit a raptor_serve decision so canary.ts can track
592
+ // p95 latency + the tier's live traffic (non-fatal, best-effort).
593
+ this.record("RAPTOR", hits.length > 0 ? "new" : "mark_only", `leaves=${leafIds.length}`, Date.now() - t0);
579
594
  return hits;
580
595
  } catch {
581
596
  return [];