pi-mega-compact 0.6.6 → 0.6.7

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.
@@ -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
@@ -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). */
@@ -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 {
@@ -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.7",
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
  /**
@@ -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
 
@@ -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 [];