pi-mega-compact 0.6.5 → 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.
- package/dist/extensions/mega-pipeline.js +5 -0
- package/dist/extensions/mega-runtime.js +36 -20
- package/dist/src/adapt.js +20 -4
- package/dist/src/dedup/raptor/index.js +11 -3
- package/dist/src/extractive.js +12 -7
- package/dist/src/extractive.test.js +17 -0
- package/dist/src/store/backfill.js +3 -1
- package/dist/src/store/sqlite.js +20 -4
- package/dist/src/vectorStore.js +19 -2
- package/extensions/mega-pipeline.ts +5 -0
- package/extensions/mega-runtime.ts +34 -20
- package/package.json +1 -1
- package/src/adapt.ts +19 -4
- package/src/dedup/raptor/index.ts +16 -7
- package/src/dedup/raptor/tree.ts +2 -0
- package/src/extractive.test.ts +18 -0
- package/src/extractive.ts +13 -7
- package/src/store/backfill.ts +3 -1
- package/src/store/sqlite.ts +36 -4
- package/src/vectorStore.ts +16 -1
|
@@ -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
|
}
|
|
@@ -53,6 +53,7 @@ export const C = {
|
|
|
53
53
|
magenta: "\x1b[38;5;201m", // dedup rate
|
|
54
54
|
blue: "\x1b[38;5;75m", // repo totals
|
|
55
55
|
gray: "\x1b[38;5;245m", // labels
|
|
56
|
+
red: "\x1b[38;5;203m", // pressure / overflow
|
|
56
57
|
};
|
|
57
58
|
const PULSE = ["◐", "◓", "◑", "◒"];
|
|
58
59
|
export class MegaRuntime {
|
|
@@ -315,7 +316,7 @@ export class MegaRuntime {
|
|
|
315
316
|
// 1e6, k at/above 1e3, raw below — so 5,472,700 → "5.5M", 24,100 → "24.1k",
|
|
316
317
|
// 142 → "142". Dropped (in) = Freed + Kept; Freed = rt.tokensSaved (session)
|
|
317
318
|
// / repo.tokensSaved meta (repo); Kept = totalTokenEstimate (stored).
|
|
318
|
-
const fmt = (x) => x >= 1_000_000 ? `${(x / 1_000_000).toFixed(1)}
|
|
319
|
+
const fmt = (x) => x >= 1_000_000 ? `${(x / 1_000_000).toFixed(1)}mil`
|
|
319
320
|
: x >= 1000 ? `${(x / 1000).toFixed(1)}k`
|
|
320
321
|
: `${Math.round(x)}`;
|
|
321
322
|
const agentStr = this.activeAgents > 0 ? ` │ 🤖 ${this.activeAgents} agent${this.activeAgents === 1 ? "" : "s"}` : "";
|
|
@@ -331,21 +332,40 @@ export class MegaRuntime {
|
|
|
331
332
|
const repoKept = repo.totalTokenEstimate;
|
|
332
333
|
const repoFreed = repo.tokensSaved;
|
|
333
334
|
const repoPct = repoIn > 0 ? repoFreed / repoIn : 0;
|
|
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).
|
|
340
|
+
const ramp = (pct, w = 12) => {
|
|
341
|
+
const cells = ["▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"];
|
|
342
|
+
const scaled = Math.max(0, Math.min(w, pct * w));
|
|
343
|
+
const full = Math.floor(scaled);
|
|
344
|
+
const frac = scaled - full;
|
|
345
|
+
const fracCell = frac > 0 ? cells[Math.round(frac * (cells.length - 1))] : "";
|
|
346
|
+
let out = "";
|
|
347
|
+
for (let i = 0; i < full; i++)
|
|
348
|
+
out += (i / w < 0.6 ? C.green : i / w < 0.85 ? C.amber : C.red) + "█";
|
|
349
|
+
if (fracCell)
|
|
350
|
+
out += (full / w < 0.6 ? C.green : full / w < 0.85 ? C.amber : C.red) + fracCell;
|
|
351
|
+
out += C.dim + "░".repeat(Math.max(0, w - full - (fracCell ? 1 : 0))) + C.reset;
|
|
352
|
+
return out;
|
|
353
|
+
};
|
|
354
|
+
const ctxPct = this.lastCtxPercent != null ? this.lastCtxPercent / 100 : 0;
|
|
355
|
+
const sTxt = (sessPct * 100).toFixed(sessPct * 100 >= 10 ? 0 : 1);
|
|
356
|
+
const rTxt = (repoPct * 100).toFixed(repoPct * 100 >= 10 ? 0 : 1);
|
|
334
357
|
const lines = [
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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`,
|
|
338
368
|
];
|
|
339
|
-
// Compression meter — the single headline "% tokens saved" (Freed / In),
|
|
340
|
-
// same formula as the dashboard. Higher = better, so it reads green.
|
|
341
|
-
{
|
|
342
|
-
const w = 10;
|
|
343
|
-
const filled = Math.max(0, Math.min(w, Math.round(sessPct * w)));
|
|
344
|
-
const cbar = C.green + "▓".repeat(filled) + C.dim + "░".repeat(w - filled) + C.reset;
|
|
345
|
-
const sTxt = (sessPct * 100).toFixed(sessPct * 100 >= 10 ? 0 : 1);
|
|
346
|
-
const rTxt = (repoPct * 100).toFixed(repoPct * 100 >= 10 ? 0 : 1);
|
|
347
|
-
lines.push(` ${cbar} ${sTxt}% tokens saved (sess) · ${rTxt}% repo${C.reset}`);
|
|
348
|
-
}
|
|
349
369
|
// Live "now processing" line + why + recent deduped/compacted events,
|
|
350
370
|
// collapsed to ONE rotating line (fresh only). The ticker ring buffer
|
|
351
371
|
// (≤5 most-recent events) is cycled one-per-repaint so the line scrolls
|
|
@@ -367,12 +387,8 @@ export class MegaRuntime {
|
|
|
367
387
|
else if (this.pulsing) {
|
|
368
388
|
lines.push(` ${pulse}${C.teal}compacting…${C.reset}`);
|
|
369
389
|
}
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
// (saved) tokens for both this session and all-time across the repo.
|
|
373
|
-
if (lines.length < 10) {
|
|
374
|
-
lines.push(` ${C.dim}session ↑${fmt(sessIn)} in ↓${fmt(sessKept)} out · saved ${fmt(sessFreed)} session / ${fmt(repoFreed)} all-time${C.reset}`);
|
|
375
|
-
}
|
|
390
|
+
// (Accounting folded into L2's "in→kept (X% freed)" framing — freed =
|
|
391
|
+
// in − kept is implied, and the saturated-ratio bars are gone.)
|
|
376
392
|
ctx.ui.setWidget(WIDGET_KEY, lines, { placement: "aboveEditor" });
|
|
377
393
|
}
|
|
378
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
|
|
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
|
-
|
|
58
|
+
out = contentText(m.content);
|
|
59
|
+
break;
|
|
52
60
|
case "bashExecution":
|
|
53
|
-
|
|
61
|
+
out = `${m.command ?? ""}\n${m.output ?? ""}`;
|
|
62
|
+
break;
|
|
54
63
|
case "branchSummary":
|
|
55
64
|
case "compactionSummary":
|
|
56
|
-
|
|
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
|
-
|
|
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:
|
|
104
|
+
rootId: root?.id ?? null,
|
|
98
105
|
levels: Math.max(1, ...nodes.map((n) => n.level + 1)),
|
|
99
|
-
timedOut
|
|
106
|
+
timedOut,
|
|
107
|
+
builtAt,
|
|
100
108
|
};
|
|
101
109
|
return tree;
|
|
102
110
|
}
|
package/dist/src/extractive.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
185
|
-
const currentWork = inferCurrentWork(
|
|
186
|
-
const keyFiles = collectKeyFiles(
|
|
187
|
-
const pending = inferPendingWork(
|
|
188
|
-
const keyDecisions = extractDecisions(
|
|
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(
|
|
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
|
-
|
|
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);
|
package/dist/src/store/sqlite.js
CHANGED
|
@@ -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
|
|
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). */
|
package/dist/src/vectorStore.js
CHANGED
|
@@ -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
|
}
|
|
@@ -69,6 +69,7 @@ export const C = {
|
|
|
69
69
|
magenta: "\x1b[38;5;201m", // dedup rate
|
|
70
70
|
blue: "\x1b[38;5;75m", // repo totals
|
|
71
71
|
gray: "\x1b[38;5;245m", // labels
|
|
72
|
+
red: "\x1b[38;5;203m", // pressure / overflow
|
|
72
73
|
};
|
|
73
74
|
|
|
74
75
|
const PULSE = ["◐", "◓", "◑", "◒"];
|
|
@@ -344,7 +345,7 @@ export class MegaRuntime {
|
|
|
344
345
|
// 142 → "142". Dropped (in) = Freed + Kept; Freed = rt.tokensSaved (session)
|
|
345
346
|
// / repo.tokensSaved meta (repo); Kept = totalTokenEstimate (stored).
|
|
346
347
|
const fmt = (x: number) =>
|
|
347
|
-
x >= 1_000_000 ? `${(x / 1_000_000).toFixed(1)}
|
|
348
|
+
x >= 1_000_000 ? `${(x / 1_000_000).toFixed(1)}mil`
|
|
348
349
|
: x >= 1000 ? `${(x / 1000).toFixed(1)}k`
|
|
349
350
|
: `${Math.round(x)}`;
|
|
350
351
|
const agentStr = this.activeAgents > 0 ? ` │ 🤖 ${this.activeAgents} agent${this.activeAgents === 1 ? "" : "s"}` : "";
|
|
@@ -360,21 +361,38 @@ export class MegaRuntime {
|
|
|
360
361
|
const repoKept = repo.totalTokenEstimate;
|
|
361
362
|
const repoFreed = repo.tokensSaved;
|
|
362
363
|
const repoPct = repoIn > 0 ? repoFreed / repoIn : 0;
|
|
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).
|
|
369
|
+
const ramp = (pct: number, w = 12): string => {
|
|
370
|
+
const cells = ["▏","▎","▍","▌","▋","▊","▉","█"];
|
|
371
|
+
const scaled = Math.max(0, Math.min(w, pct * w));
|
|
372
|
+
const full = Math.floor(scaled);
|
|
373
|
+
const frac = scaled - full;
|
|
374
|
+
const fracCell = frac > 0 ? cells[Math.round(frac * (cells.length - 1))] : "";
|
|
375
|
+
let out = "";
|
|
376
|
+
for (let i = 0; i < full; i++) out += (i / w < 0.6 ? C.green : i / w < 0.85 ? C.amber : C.red) + "█";
|
|
377
|
+
if (fracCell) out += (full / w < 0.6 ? C.green : full / w < 0.85 ? C.amber : C.red) + fracCell;
|
|
378
|
+
out += C.dim + "░".repeat(Math.max(0, w - full - (fracCell ? 1 : 0))) + C.reset;
|
|
379
|
+
return out;
|
|
380
|
+
};
|
|
381
|
+
const ctxPct = this.lastCtxPercent != null ? this.lastCtxPercent / 100 : 0;
|
|
382
|
+
const sTxt = (sessPct * 100).toFixed(sessPct * 100 >= 10 ? 0 : 1);
|
|
383
|
+
const rTxt = (repoPct * 100).toFixed(repoPct * 100 >= 10 ? 0 : 1);
|
|
363
384
|
const lines = [
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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`,
|
|
367
395
|
];
|
|
368
|
-
// Compression meter — the single headline "% tokens saved" (Freed / In),
|
|
369
|
-
// same formula as the dashboard. Higher = better, so it reads green.
|
|
370
|
-
{
|
|
371
|
-
const w = 10;
|
|
372
|
-
const filled = Math.max(0, Math.min(w, Math.round(sessPct * w)));
|
|
373
|
-
const cbar = C.green + "▓".repeat(filled) + C.dim + "░".repeat(w - filled) + C.reset;
|
|
374
|
-
const sTxt = (sessPct * 100).toFixed(sessPct * 100 >= 10 ? 0 : 1);
|
|
375
|
-
const rTxt = (repoPct * 100).toFixed(repoPct * 100 >= 10 ? 0 : 1);
|
|
376
|
-
lines.push(` ${cbar} ${sTxt}% tokens saved (sess) · ${rTxt}% repo${C.reset}`);
|
|
377
|
-
}
|
|
378
396
|
// Live "now processing" line + why + recent deduped/compacted events,
|
|
379
397
|
// collapsed to ONE rotating line (fresh only). The ticker ring buffer
|
|
380
398
|
// (≤5 most-recent events) is cycled one-per-repaint so the line scrolls
|
|
@@ -394,12 +412,8 @@ export class MegaRuntime {
|
|
|
394
412
|
} else if (this.pulsing) {
|
|
395
413
|
lines.push(` ${pulse}${C.teal}compacting…${C.reset}`);
|
|
396
414
|
}
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
// (saved) tokens for both this session and all-time across the repo.
|
|
400
|
-
if (lines.length < 10) {
|
|
401
|
-
lines.push(` ${C.dim}session ↑${fmt(sessIn)} in ↓${fmt(sessKept)} out · saved ${fmt(sessFreed)} session / ${fmt(repoFreed)} all-time${C.reset}`);
|
|
402
|
-
}
|
|
415
|
+
// (Accounting folded into L2's "in→kept (X% freed)" framing — freed =
|
|
416
|
+
// in − kept is implied, and the saturated-ratio bars are gone.)
|
|
403
417
|
ctx.ui.setWidget(WIDGET_KEY, lines, { placement: "aboveEditor" });
|
|
404
418
|
}
|
|
405
419
|
}
|
package/package.json
CHANGED
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
|
|
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
|
-
|
|
66
|
+
out = contentText((m as { content: string | Array<{ type: string; text?: string }> }).content);
|
|
67
|
+
break;
|
|
61
68
|
case "bashExecution":
|
|
62
|
-
|
|
69
|
+
out = `${(m as { command: string }).command ?? ""}\n${(m as { output: string }).output ?? ""}`;
|
|
70
|
+
break;
|
|
63
71
|
case "branchSummary":
|
|
64
72
|
case "compactionSummary":
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
138
|
+
timedOut,
|
|
139
|
+
builtAt,
|
|
131
140
|
};
|
|
132
141
|
return tree;
|
|
133
142
|
}
|
package/src/dedup/raptor/tree.ts
CHANGED
|
@@ -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 {
|
package/src/extractive.test.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
233
|
-
const currentWork = inferCurrentWork(
|
|
234
|
-
const keyFiles = collectKeyFiles(
|
|
235
|
-
const pending = inferPendingWork(
|
|
236
|
-
const keyDecisions = extractDecisions(
|
|
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
|
-
|
|
246
|
+
safe, tools, recentUser, currentWork, keyFiles, pending,
|
|
241
247
|
);
|
|
242
248
|
|
|
243
249
|
const tokenEstimate = estimateBlockTokens(topicSummary);
|
package/src/store/backfill.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/src/store/sqlite.ts
CHANGED
|
@@ -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: {
|
|
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/vectorStore.ts
CHANGED
|
@@ -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 [];
|