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.
- package/dist/extensions/dashboard-server.js +95 -8
- package/dist/extensions/mega-pipeline.js +5 -0
- package/dist/extensions/mega-runtime.js +17 -12
- package/dist/src/adapt.js +20 -4
- package/dist/src/compact.js +5 -1
- 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/supersede.js +8 -5
- package/dist/src/vectorStore.js +19 -2
- package/extensions/dashboard-server.ts +108 -8
- package/extensions/mega-pipeline.ts +5 -0
- package/extensions/mega-runtime.ts +17 -12
- package/package.json +1 -1
- package/src/adapt.ts +19 -4
- package/src/compact.ts +5 -1
- 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/supersede.ts +8 -5
- package/src/vectorStore.ts +16 -1
|
@@ -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">
|
|
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">
|
|
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="
|
|
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="
|
|
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] = {
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 —
|
|
336
|
-
// smooth green→amber→red ramp
|
|
337
|
-
//
|
|
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
|
|
357
|
-
|
|
358
|
-
//
|
|
359
|
-
`
|
|
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
|
-
//
|
|
383
|
-
//
|
|
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
|
|
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
|
package/dist/src/compact.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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/supersede.js
CHANGED
|
@@ -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
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
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 =
|
|
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
|
}
|
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 {
|
|
@@ -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">
|
|
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">
|
|
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="
|
|
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="
|
|
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] = {
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 —
|
|
365
|
-
// smooth green→amber→red ramp
|
|
366
|
-
//
|
|
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
|
|
384
|
-
|
|
385
|
-
//
|
|
386
|
-
`
|
|
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
|
-
//
|
|
408
|
-
//
|
|
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
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
|
/**
|
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
|
-
|
|
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
|
-
|
|
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/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
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
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 =
|
|
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
|
}
|
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 [];
|