pi-mega-compact 0.8.23 → 0.8.25
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/README.md +26 -0
- package/dist/extensions/dashboard-server/api-contracts/endpoints.js +8 -0
- package/dist/extensions/dashboard-server/api-contracts/game-types.js +7 -0
- package/dist/extensions/mega-compact-s38.test.js +263 -14
- package/dist/extensions/mega-compact.js +15 -0
- package/dist/extensions/mega-config.js +3 -0
- package/dist/extensions/mega-events/agent-handlers.js +211 -26
- package/dist/extensions/mega-events/context-handler.js +45 -7
- package/dist/extensions/mega-events/error-classifier.js +125 -18
- package/dist/extensions/mega-pipeline/compact.js +24 -13
- package/dist/extensions/mega-pipeline/recall.js +31 -2
- package/dist/extensions/mega-runtime/append-event.js +24 -0
- package/dist/extensions/mega-runtime/bind-repo.js +65 -0
- package/dist/extensions/mega-runtime/capture-model.js +87 -0
- package/dist/extensions/mega-runtime/dashboard-snapshot.js +122 -0
- package/dist/extensions/mega-runtime/effects.js +86 -0
- package/dist/extensions/mega-runtime/engine-view.js +11 -0
- package/dist/extensions/mega-runtime/game-state.js +116 -0
- package/dist/extensions/mega-runtime/get-state-dir.js +10 -0
- package/dist/extensions/mega-runtime/perf.js +49 -0
- package/dist/extensions/mega-runtime/pressure-getters.js +64 -0
- package/dist/extensions/mega-runtime/render-widget.js +17 -0
- package/dist/extensions/mega-runtime/reset-runtime.js +50 -0
- package/dist/extensions/mega-runtime/runtime-helpers.js +73 -0
- package/dist/extensions/mega-runtime/runtime-snapshot.js +208 -0
- package/dist/extensions/mega-runtime/runtime.js +405 -0
- package/dist/extensions/mega-runtime/snapshot.js +142 -0
- package/dist/extensions/mega-runtime/state.js +5 -1151
- package/dist/extensions/mega-runtime/status.js +11 -0
- package/dist/extensions/mega-runtime/widget-ansi.js +207 -0
- package/dist/extensions/mega-runtime/widget-types.js +8 -0
- package/dist/extensions/mega-runtime/widget.js +15 -204
- package/dist/extensions/openclaw-mega-compact.js +291 -0
- package/dist/src/boundary.js +79 -43
- package/dist/src/boundary.test.js +119 -2
- package/dist/src/canary.js +10 -0
- package/dist/src/config/dedup.js +14 -0
- package/dist/src/config.js +3 -1
- package/dist/src/dedup/raptor/buildHistory.js +164 -0
- package/dist/src/dedup/raptor/buildHistory.test.js +292 -0
- package/dist/src/dedup/raptor/index.js +38 -0
- package/dist/src/dedup/raptor/multilevel-serve.test.js +229 -0
- package/dist/src/dedup/raptor/multilevel.js +17 -5
- package/dist/src/dedup/raptor/multilevel.test.js +36 -1
- package/dist/src/dedup/raptor/raptor.test.js +43 -0
- package/dist/src/dedup/raptor/retrieval.js +14 -2
- package/dist/src/dedup/raptor/retrieval.test.js +95 -0
- package/dist/src/dedup/raptor/serve-gate.test.js +298 -0
- package/dist/src/dedup/raptor/summarizer.js +1 -0
- package/dist/src/dedup/raptor/tree.js +16 -2
- package/dist/src/engine.js +18 -2
- package/dist/src/httpEmbedder.js +96 -6
- package/dist/src/httpEmbedder.test.js +277 -0
- package/dist/src/mechanical-fix.test.js +65 -0
- package/dist/src/minilm.js +92 -0
- package/dist/src/raptor-inject-summaries.test.js +155 -0
- package/dist/src/recall.js +135 -21
- package/dist/src/recall.test.js +179 -4
- package/dist/src/store/sqlite/dedup-mirror.js +32 -15
- package/dist/src/store/sqlite/maintenance.js +2 -2
- package/dist/src/store/sqlite/mechanical-fix.test.js +146 -0
- package/dist/src/store/sqlite/memories.js +5 -5
- package/dist/src/store/sqlite/meta.js +1 -1
- package/dist/src/store/sqlite/raptor.js +56 -17
- package/dist/src/store/sqlite/raptor.test.js +106 -0
- package/dist/src/store/sqlite/schema.js +90 -1
- package/dist/src/store/sqlite/session-state.js +9 -3
- package/dist/src/store/sqlite/stats.js +9 -5
- package/dist/src/store/sqlite/turns.js +181 -0
- package/dist/src/store/sqlite/turns.test.js +183 -0
- package/dist/src/store/sqlite/utils.js +15 -4
- package/dist/src/store/sqlite.js +1 -0
- package/dist/src/store.js +2 -2
- package/dist/src/vector-search-cache.test.js +157 -0
- package/dist/src/vector-search.js +107 -15
- package/dist/src/vectorStore.js +36 -8
- package/dist/src/wordpiece.js +129 -0
- package/extensions/dashboard-client/dist/assets/index-D_WtU2TV.js.map +1 -1
- package/extensions/dashboard-server/api-contracts/endpoints.ts +30 -155
- package/extensions/dashboard-server/api-contracts/game-types.ts +172 -0
- package/extensions/mega-compact-s38.test.ts +259 -14
- package/extensions/mega-compact.ts +15 -0
- package/extensions/mega-config.ts +18 -0
- package/extensions/mega-dashboard.ts +10 -1
- package/extensions/mega-events/agent-handlers.ts +211 -26
- package/extensions/mega-events/context-handler.ts +43 -7
- package/extensions/mega-events/error-classifier.ts +125 -17
- package/extensions/mega-pipeline/compact.ts +28 -16
- package/extensions/mega-pipeline/recall.ts +34 -2
- package/extensions/mega-runtime/DECOMPOSITION.md +180 -0
- package/extensions/mega-runtime/README.md +38 -0
- package/extensions/mega-runtime/append-event.ts +40 -0
- package/extensions/mega-runtime/bind-repo.ts +81 -0
- package/extensions/mega-runtime/capture-model.ts +101 -0
- package/extensions/mega-runtime/dashboard-snapshot.ts +181 -0
- package/extensions/mega-runtime/effects.ts +129 -0
- package/extensions/mega-runtime/engine-view.ts +17 -0
- package/extensions/mega-runtime/game-state.ts +149 -0
- package/extensions/mega-runtime/get-state-dir.ts +19 -0
- package/extensions/mega-runtime/helpers.ts +25 -1
- package/extensions/mega-runtime/perf.ts +60 -0
- package/extensions/mega-runtime/pressure-getters.ts +96 -0
- package/extensions/mega-runtime/render-widget.ts +41 -0
- package/extensions/mega-runtime/runtime-helpers.ts +119 -0
- package/extensions/mega-runtime/runtime-snapshot.ts +293 -0
- package/extensions/mega-runtime/runtime.ts +483 -0
- package/extensions/mega-runtime/snapshot.ts +230 -0
- package/extensions/mega-runtime/state.ts +5 -1268
- package/extensions/mega-runtime/status.ts +26 -0
- package/extensions/mega-runtime/widget-ansi.ts +217 -0
- package/extensions/mega-runtime/widget-types.ts +80 -0
- package/extensions/mega-runtime/widget.ts +34 -285
- package/package.json +1 -1
- package/src/boundary.test.ts +128 -2
- package/src/boundary.ts +75 -39
- package/src/canary.ts +10 -0
- package/src/config/dedup.ts +25 -0
- package/src/config.ts +3 -1
- package/src/dedup/raptor/buildHistory.test.ts +353 -0
- package/src/dedup/raptor/buildHistory.ts +259 -0
- package/src/dedup/raptor/index.ts +38 -0
- package/src/dedup/raptor/multilevel-serve.test.ts +273 -0
- package/src/dedup/raptor/multilevel.test.ts +47 -0
- package/src/dedup/raptor/multilevel.ts +18 -8
- package/src/dedup/raptor/raptor.test.ts +59 -0
- package/src/dedup/raptor/retrieval.test.ts +118 -0
- package/src/dedup/raptor/retrieval.ts +14 -2
- package/src/dedup/raptor/serve-gate.test.ts +348 -0
- package/src/dedup/raptor/summarizer.ts +1 -0
- package/src/dedup/raptor/tree.ts +17 -2
- package/src/engine.ts +32 -3
- package/src/httpEmbedder.test.ts +286 -0
- package/src/httpEmbedder.ts +98 -8
- package/src/mechanical-fix.test.ts +70 -0
- package/src/raptor-inject-summaries.test.ts +212 -0
- package/src/recall.test.ts +220 -4
- package/src/recall.ts +151 -22
- package/src/store/sqlite/dedup-mirror.ts +35 -18
- package/src/store/sqlite/maintenance.ts +2 -2
- package/src/store/sqlite/mechanical-fix.test.ts +162 -0
- package/src/store/sqlite/memories.ts +5 -5
- package/src/store/sqlite/meta.ts +1 -1
- package/src/store/sqlite/raptor.test.ts +139 -0
- package/src/store/sqlite/raptor.ts +135 -81
- package/src/store/sqlite/schema.ts +90 -1
- package/src/store/sqlite/session-state.ts +9 -3
- package/src/store/sqlite/stats.ts +10 -8
- package/src/store/sqlite/turns.test.ts +218 -0
- package/src/store/sqlite/turns.ts +292 -0
- package/src/store/sqlite/utils.ts +14 -4
- package/src/store/sqlite.ts +1 -0
- package/src/store.ts +9 -2
- package/src/vector-search-cache.test.ts +190 -0
- package/src/vector-search.ts +273 -156
- package/src/vectorStore.ts +443 -382
- package/dist/extensions/dashboard-client/src/hooks/useApi.js +0 -51
- package/dist/extensions/dashboard-client/src/hooks/useSSE.js +0 -63
package/src/recall.test.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { join } from "node:path";
|
|
|
6
6
|
import { VectorStore } from "./vectorStore.js";
|
|
7
7
|
import { compactSession } from "./engine.js";
|
|
8
8
|
import { recallAndInline, recallAndInlineAsync, formatRecallBlock } from "./recall.js";
|
|
9
|
-
import { vectorList } from "./vectorStore.js";
|
|
9
|
+
import { vectorList, vectorWasInjected } from "./vectorStore.js";
|
|
10
10
|
import { markInjectedGlobal, wasInjectedGlobal, closeIndexStore } from "./store/sqlite.js";
|
|
11
11
|
import {
|
|
12
12
|
closeVectorIndex,
|
|
@@ -88,10 +88,12 @@ test("Fix C: recallMaxTokens caps the injected block", () => {
|
|
|
88
88
|
compactSession({ sessionId: SESS, messages: [msg("user", "beta module config and env resolution"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 2 }, s);
|
|
89
89
|
compactSession({ sessionId: SESS, messages: [msg("user", "gamma module shutdown and cleanup hooks"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 3 }, s);
|
|
90
90
|
|
|
91
|
-
// A ceiling of
|
|
92
|
-
// second (~
|
|
91
|
+
// A ceiling of 50 tokens fits the first checkpoint (~33 body tokens) but
|
|
92
|
+
// stops before the second (~65 cumulative) — proving the cap bites mid-stream.
|
|
93
|
+
// (F3: the cap now counts body tokens only — one preamble at format time, not
|
|
94
|
+
// N — matching the async path. The old per-hit preamble counting needed ~100.)
|
|
93
95
|
const r = recallAndInline(
|
|
94
|
-
{ sessionId: SESS, query: "module wiring config shutdown", limit: 5, source: "command", recallMaxTokens:
|
|
96
|
+
{ sessionId: SESS, query: "module wiring config shutdown", limit: 5, source: "command", recallMaxTokens: 50, skipInjected: false },
|
|
95
97
|
s as any,
|
|
96
98
|
);
|
|
97
99
|
assert.ok(r.toInject.length >= 1, "at least one injected under the cap");
|
|
@@ -246,6 +248,220 @@ test("S18: a fresh foreign checkpoint is injected AND recorded globally", async
|
|
|
246
248
|
}
|
|
247
249
|
});
|
|
248
250
|
|
|
251
|
+
// ---- F3: format-once — one preamble, [1..n] numbering, cap respected ----
|
|
252
|
+
//
|
|
253
|
+
// The fix (landed async, extended to sync here) accumulates the hit list and
|
|
254
|
+
// calls formatRecallBlock ONCE at the end, so a multi-hit block carries exactly
|
|
255
|
+
// one preamble and contiguous [1..n] labels instead of one preamble per hit.
|
|
256
|
+
|
|
257
|
+
test("F3 (sync): multi-hit injection has exactly one preamble and [1..n] numbering", () => {
|
|
258
|
+
const s = store();
|
|
259
|
+
compactSession({ sessionId: SESS, messages: [msg("user", "alpha module wiring and bootstrap sequence"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 1 }, s);
|
|
260
|
+
compactSession({ sessionId: SESS, messages: [msg("user", "beta module config and env resolution"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 2 }, s);
|
|
261
|
+
compactSession({ sessionId: SESS, messages: [msg("user", "gamma module shutdown and cleanup hooks"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 3 }, s);
|
|
262
|
+
|
|
263
|
+
const r = recallAndInline(
|
|
264
|
+
{ sessionId: SESS, query: "module wiring config shutdown", limit: 5, source: "command", skipInjected: false },
|
|
265
|
+
s as any,
|
|
266
|
+
);
|
|
267
|
+
assert.ok(r.toInject.length >= 2, "at least two hits injected (got " + r.toInject.length + ")");
|
|
268
|
+
// Exactly one preamble (the old per-hit format produced one per hit).
|
|
269
|
+
const preamble = "The following compacted context was recalled";
|
|
270
|
+
assert.equal(r.block.split(preamble).length - 1, 1, "exactly one preamble for a multi-hit block");
|
|
271
|
+
// Contiguous [1..n] numbering.
|
|
272
|
+
for (let i = 1; i <= r.toInject.length; i++) {
|
|
273
|
+
assert.ok(r.block.includes(`[${i}]`), `block includes [${i}]`);
|
|
274
|
+
}
|
|
275
|
+
// No out-of-range label (e.g. [n+1]) leaks in.
|
|
276
|
+
assert.ok(!r.block.includes(`[${r.toInject.length + 1}]`), "no extra-numbered label");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("F3 (sync): recallMaxTokens caps mid-stream with exactly one preamble", () => {
|
|
280
|
+
const s = store();
|
|
281
|
+
compactSession({ sessionId: SESS, messages: [msg("user", "alpha module wiring and bootstrap sequence"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 1 }, s);
|
|
282
|
+
compactSession({ sessionId: SESS, messages: [msg("user", "beta module config and env resolution"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 2 }, s);
|
|
283
|
+
compactSession({ sessionId: SESS, messages: [msg("user", "gamma module shutdown and cleanup hooks"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 3 }, s);
|
|
284
|
+
|
|
285
|
+
// A tight ceiling that fits the first checkpoint (~33 body tokens) but stops
|
|
286
|
+
// before the second (~65 cumulative). F3: body-only counting (one preamble at
|
|
287
|
+
// format time, not per hit), matching the async path.
|
|
288
|
+
const r = recallAndInline(
|
|
289
|
+
{ sessionId: SESS, query: "module wiring config shutdown", limit: 5, source: "command", skipInjected: false, recallMaxTokens: 50 },
|
|
290
|
+
s as any,
|
|
291
|
+
);
|
|
292
|
+
assert.ok(r.toInject.length < 3, "cap stopped before all three injected");
|
|
293
|
+
assert.ok(r.toInject.length >= 1, "at least one injected under the cap");
|
|
294
|
+
assert.equal(
|
|
295
|
+
r.block.split("The following compacted context was recalled").length - 1,
|
|
296
|
+
1,
|
|
297
|
+
"still exactly one preamble under the cap",
|
|
298
|
+
);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("F3 (async): multi-hit cross-repo injection has exactly one preamble and [1..n] numbering", async () => {
|
|
302
|
+
if (process.env.MEGACOMPACT_PGLITE_DISABLED === "true") { return; } // skip when WASM index is off
|
|
303
|
+
const indexDir = mkdtempSync(join(tmpdir(), "mc-f3a-"));
|
|
304
|
+
const foreignStateDir = mkdtempSync(join(tmpdir(), "mc-f3a-foreign-"));
|
|
305
|
+
const selfStateDir = mkdtempSync(join(tmpdir(), "mc-f3a-self-"));
|
|
306
|
+
process.env.MEGACOMPACT_VECTOR_INDEX_DIR = mkdtempSync(join(tmpdir(), "mc-f3a-vidx-"));
|
|
307
|
+
try {
|
|
308
|
+
await closeVectorIndex();
|
|
309
|
+
// Seed multiple distinct foreign checkpoints so the cross-repo query yields
|
|
310
|
+
// more than one hit (needed to assert [1..n] numbering).
|
|
311
|
+
const foreign = new VectorStore({ stateDir: foreignStateDir, dedupSim: 0.9 });
|
|
312
|
+
const fsess = "sess_foreign_f3";
|
|
313
|
+
const summaries = [
|
|
314
|
+
"foreign repo authentication jwt token validation",
|
|
315
|
+
"foreign repo database connection pooling and retry",
|
|
316
|
+
"foreign repo logging telemetry and tracing spans",
|
|
317
|
+
];
|
|
318
|
+
const cids: string[] = [];
|
|
319
|
+
for (let i = 0; i < summaries.length; i++) {
|
|
320
|
+
const res = compactSession(
|
|
321
|
+
{ sessionId: fsess, messages: [msg("user", summaries[i]), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: i + 1 },
|
|
322
|
+
foreign,
|
|
323
|
+
);
|
|
324
|
+
if (res.checkpointId) cids.push(res.checkpointId);
|
|
325
|
+
}
|
|
326
|
+
await rebuildFromSqlite(
|
|
327
|
+
() => [{ repoId: foreignStateDir, stateDir: foreignStateDir }],
|
|
328
|
+
(sd) => {
|
|
329
|
+
const st = new VectorStore({ stateDir: sd, dedupSim: 0.9 });
|
|
330
|
+
return vectorList(st, fsess).map((cp) => ({
|
|
331
|
+
sessionId: fsess, checkpointId: cp.checkpointId, embedding: cp.embedding,
|
|
332
|
+
}));
|
|
333
|
+
},
|
|
334
|
+
);
|
|
335
|
+
const pg = await initVectorIndex();
|
|
336
|
+
assert.ok(pg, "PGlite index should initialize");
|
|
337
|
+
|
|
338
|
+
const selfStore = new VectorStore({ stateDir: selfStateDir, dedupSim: 0.9 });
|
|
339
|
+
const r = await recallAndInlineAsync(
|
|
340
|
+
{ sessionId: "sess_f3", query: "foreign repo", limit: 5, source: "command", crossRepo: true, skipInjected: false, globalIndexDir: indexDir },
|
|
341
|
+
selfStore,
|
|
342
|
+
);
|
|
343
|
+
assert.ok(r.toInject.length >= 2, "at least two cross-repo hits (got " + r.toInject.length + ")");
|
|
344
|
+
assert.equal(
|
|
345
|
+
r.block.split("The following compacted context was recalled").length - 1,
|
|
346
|
+
1,
|
|
347
|
+
"exactly one preamble for a multi-hit async block",
|
|
348
|
+
);
|
|
349
|
+
for (let i = 1; i <= r.toInject.length; i++) {
|
|
350
|
+
assert.ok(r.block.includes(`[${i}]`), `async block includes [${i}]`);
|
|
351
|
+
}
|
|
352
|
+
assert.ok(!r.block.includes(`[${r.toInject.length + 1}]`), "no extra-numbered label (async)");
|
|
353
|
+
} finally {
|
|
354
|
+
await closeVectorIndex();
|
|
355
|
+
closeIndexStore();
|
|
356
|
+
delete process.env.MEGACOMPACT_VECTOR_INDEX_DIR;
|
|
357
|
+
rmSync(indexDir, { recursive: true, force: true });
|
|
358
|
+
rmSync(foreignStateDir, { recursive: true, force: true });
|
|
359
|
+
rmSync(selfStateDir, { recursive: true, force: true });
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// ---- F2: cross-repo injected-set bypass ----------------------------------
|
|
364
|
+
//
|
|
365
|
+
// When the same session resumes in a DIFFERENT repo, the per-repo session_state
|
|
366
|
+
// has no injection marker for the foreign checkpoint (different stateDir), so
|
|
367
|
+
// only the machine-wide injected-set (shared globalIndexDir) can block a
|
|
368
|
+
// re-inject. The F2 fix ensures globalIndexDir is always resolved (via
|
|
369
|
+
// getIndexDir in the extension), so wasInjectedGlobal is consulted; without
|
|
370
|
+
// the fix, globalIndexDir was undefined and foreign checkpoints re-injected
|
|
371
|
+
// every resume. This test passes globalIndexDir explicitly (simulating the
|
|
372
|
+
// resolver default) and does NOT set MEGACOMPACT_INDEX_DIR.
|
|
373
|
+
|
|
374
|
+
test("F2: cross-repo hit is not re-injected when resuming the same session in a different repo (shared index dir)", async () => {
|
|
375
|
+
if (process.env.MEGACOMPACT_PGLITE_DISABLED === "true") { return; } // skip when WASM index is off
|
|
376
|
+
const indexDir = mkdtempSync(join(tmpdir(), "mc-f2-"));
|
|
377
|
+
const foreignStateDir = mkdtempSync(join(tmpdir(), "mc-f2-foreign-"));
|
|
378
|
+
const selfStateDir1 = mkdtempSync(join(tmpdir(), "mc-f2-self1-"));
|
|
379
|
+
const selfStateDir2 = mkdtempSync(join(tmpdir(), "mc-f2-self2-"));
|
|
380
|
+
process.env.MEGACOMPACT_VECTOR_INDEX_DIR = mkdtempSync(join(tmpdir(), "mc-f2-vidx-"));
|
|
381
|
+
try {
|
|
382
|
+
await closeVectorIndex();
|
|
383
|
+
const seed = await seedForeignRepo(foreignStateDir);
|
|
384
|
+
await rebuildFromSqlite(
|
|
385
|
+
() => [{ repoId: foreignStateDir, stateDir: foreignStateDir }],
|
|
386
|
+
(sd) => {
|
|
387
|
+
const st = new VectorStore({ stateDir: sd, dedupSim: 0.9 });
|
|
388
|
+
return vectorList(st, seed.sessionId).map((cp) => ({
|
|
389
|
+
sessionId: seed.sessionId, checkpointId: cp.checkpointId, embedding: cp.embedding,
|
|
390
|
+
}));
|
|
391
|
+
},
|
|
392
|
+
);
|
|
393
|
+
const pg = await initVectorIndex();
|
|
394
|
+
assert.ok(pg, "PGlite index should initialize");
|
|
395
|
+
|
|
396
|
+
const sess = "sess_resume";
|
|
397
|
+
// Repo B: first recall — fresh foreign checkpoint is injected AND recorded
|
|
398
|
+
// in the shared machine-wide index.
|
|
399
|
+
const storeB = new VectorStore({ stateDir: selfStateDir1, dedupSim: 0.9 });
|
|
400
|
+
const r1 = await recallAndInlineAsync(
|
|
401
|
+
{ sessionId: sess, query: "foreign repo authentication jwt", limit: 3, source: "command", crossRepo: true, globalIndexDir: indexDir },
|
|
402
|
+
storeB,
|
|
403
|
+
);
|
|
404
|
+
assert.equal(r1.toInject.length, 1, "first recall (repo B) injects the foreign checkpoint");
|
|
405
|
+
assert.equal(wasInjectedGlobal(seed.checkpointId, sess, indexDir), true, "recorded in the shared index");
|
|
406
|
+
|
|
407
|
+
// Repo C: SAME session resumed in a DIFFERENT repo. The per-session injected
|
|
408
|
+
// set in repo C's store is empty (different stateDir), so only the shared
|
|
409
|
+
// global injected-set can block re-injection.
|
|
410
|
+
const storeC = new VectorStore({ stateDir: selfStateDir2, dedupSim: 0.9 });
|
|
411
|
+
assert.equal(vectorWasInjected(storeC, sess, seed.checkpointId), false, "repo C per-session set is empty (different stateDir)");
|
|
412
|
+
const r2 = await recallAndInlineAsync(
|
|
413
|
+
{ sessionId: sess, query: "foreign repo authentication jwt", limit: 3, source: "command", crossRepo: true, globalIndexDir: indexDir },
|
|
414
|
+
storeC,
|
|
415
|
+
);
|
|
416
|
+
assert.equal(r2.toInject.length, 0, "second recall (repo C) does NOT re-inject (machine-wide global dedup)");
|
|
417
|
+
} finally {
|
|
418
|
+
await closeVectorIndex();
|
|
419
|
+
closeIndexStore();
|
|
420
|
+
delete process.env.MEGACOMPACT_VECTOR_INDEX_DIR;
|
|
421
|
+
rmSync(indexDir, { recursive: true, force: true });
|
|
422
|
+
rmSync(foreignStateDir, { recursive: true, force: true });
|
|
423
|
+
rmSync(selfStateDir1, { recursive: true, force: true });
|
|
424
|
+
rmSync(selfStateDir2, { recursive: true, force: true });
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("F2: without globalIndexDir, cross-repo hits are skipped (not injected undeduped)", async () => {
|
|
429
|
+
if (process.env.MEGACOMPACT_PGLITE_DISABLED === "true") { return; } // skip when WASM index is off
|
|
430
|
+
const foreignStateDir = mkdtempSync(join(tmpdir(), "mc-f2b-foreign-"));
|
|
431
|
+
const selfStateDir = mkdtempSync(join(tmpdir(), "mc-f2b-self-"));
|
|
432
|
+
process.env.MEGACOMPACT_VECTOR_INDEX_DIR = mkdtempSync(join(tmpdir(), "mc-f2b-vidx-"));
|
|
433
|
+
try {
|
|
434
|
+
await closeVectorIndex();
|
|
435
|
+
const seed = await seedForeignRepo(foreignStateDir);
|
|
436
|
+
await rebuildFromSqlite(
|
|
437
|
+
() => [{ repoId: foreignStateDir, stateDir: foreignStateDir }],
|
|
438
|
+
(sd) => {
|
|
439
|
+
const st = new VectorStore({ stateDir: sd, dedupSim: 0.9 });
|
|
440
|
+
return vectorList(st, seed.sessionId).map((cp) => ({
|
|
441
|
+
sessionId: seed.sessionId, checkpointId: cp.checkpointId, embedding: cp.embedding,
|
|
442
|
+
}));
|
|
443
|
+
},
|
|
444
|
+
);
|
|
445
|
+
const pg = await initVectorIndex();
|
|
446
|
+
assert.ok(pg, "PGlite index should initialize");
|
|
447
|
+
|
|
448
|
+
// No globalIndexDir → the F2 guard skips foreign hits rather than injecting
|
|
449
|
+
// them undeduped. Same-repo hits (none here) would still pass.
|
|
450
|
+
const selfStore = new VectorStore({ stateDir: selfStateDir, dedupSim: 0.9 });
|
|
451
|
+
const r = await recallAndInlineAsync(
|
|
452
|
+
{ sessionId: "sess_nodir", query: "foreign repo authentication jwt", limit: 3, source: "command", crossRepo: true },
|
|
453
|
+
selfStore,
|
|
454
|
+
);
|
|
455
|
+
assert.equal(r.toInject.length, 0, "foreign hit skipped when globalIndexDir is unset (no undeduped injection)");
|
|
456
|
+
} finally {
|
|
457
|
+
await closeVectorIndex();
|
|
458
|
+
closeIndexStore();
|
|
459
|
+
delete process.env.MEGACOMPACT_VECTOR_INDEX_DIR;
|
|
460
|
+
rmSync(foreignStateDir, { recursive: true, force: true });
|
|
461
|
+
rmSync(selfStateDir, { recursive: true, force: true });
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
249
465
|
test("cleanup", () => {
|
|
250
466
|
rmSync(baseTmp, { recursive: true, force: true });
|
|
251
467
|
});
|
package/src/recall.ts
CHANGED
|
@@ -18,6 +18,9 @@ import { recall as searchRecall } from "./engine.js";
|
|
|
18
18
|
import { vectorWasInjected, vectorMarkInjected, type SearchHit, type VectorStore, vectorSearchAsync } from "./vectorStore.js";
|
|
19
19
|
import { estimateBlockTokens } from "./tokens.js";
|
|
20
20
|
import { defaultEmbedder, cosineSimilarity } from "./embedder.js";
|
|
21
|
+
import { rehydrateRaptorTree, isShadowMode } from "./dedup/raptor/index.js";
|
|
22
|
+
import { maxCheckpointTimestamp } from "./store/sqlite.js";
|
|
23
|
+
import { normalizeSessionId } from "./store.js";
|
|
21
24
|
|
|
22
25
|
export type RecallSource = "resume" | "command" | "sentinel";
|
|
23
26
|
|
|
@@ -42,6 +45,11 @@ export interface RecallInjectOptions {
|
|
|
42
45
|
* recall, a foreign checkpoint already injected (in any session) is skipped
|
|
43
46
|
* and a fresh injection is recorded globally. */
|
|
44
47
|
globalIndexDir?: string;
|
|
48
|
+
/** S25 Phase-2: also inject top-level RAPTOR summary nodes (root + level-1
|
|
49
|
+
* clusters) as a hierarchical overview HEADER on the recall block. Defaults
|
|
50
|
+
* to the `RAPTOR_INJECT_SUMMARIES` config flag. The overview helps the model
|
|
51
|
+
* see the session's topical structure before the detailed checkpoint hits. */
|
|
52
|
+
raptorSummaries?: boolean;
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
export interface RecallInjectResult {
|
|
@@ -64,6 +72,15 @@ export function formatRecallBlock(hits: SearchHit[]): string {
|
|
|
64
72
|
// that repo's stateDir, so the last path segment is the repo's display
|
|
65
73
|
// name). Same-repo hits (no repoId) stay unlabeled.
|
|
66
74
|
const repoName = h.repoId ? ` (from repo ${h.repoId.split("/").filter(Boolean).pop() ?? h.repoId})` : "";
|
|
75
|
+
// S42B: a RAPTOR cluster node hit (not a stored checkpoint) is labeled as a
|
|
76
|
+
// hierarchical summary and uses raptorSummary as its body. No Key files line
|
|
77
|
+
// (cluster nodes carry no file list).
|
|
78
|
+
if (h.raptorLevel !== undefined) {
|
|
79
|
+
return (
|
|
80
|
+
`### Recalled cluster summary [${i + 1}] (level ${h.raptorLevel}, relevance ${score}%)${repoName}\n` +
|
|
81
|
+
`${(h.raptorSummary ?? h.checkpoint.summary).trim()}\n`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
67
84
|
return (
|
|
68
85
|
`### Recalled context [${i + 1}] (relevance ${score}%)${repoName}\n` +
|
|
69
86
|
`${h.checkpoint.summary.trim()}\n` +
|
|
@@ -79,6 +96,33 @@ export function formatRecallBlock(hits: SearchHit[]): string {
|
|
|
79
96
|
);
|
|
80
97
|
}
|
|
81
98
|
|
|
99
|
+
/**
|
|
100
|
+
* S25 Phase-2: format the RAPTOR tree's top-level summary nodes (root + level-1
|
|
101
|
+
* clusters) as a hierarchical overview HEADER. Surfacing the high-level topical
|
|
102
|
+
* structure before the detailed checkpoint hits gives the model a map of what
|
|
103
|
+
* the session has covered. `nodes` are the RAPTOR summary nodes to surface,
|
|
104
|
+
* highest level first (root → level-1 clusters). Returns "" when empty.
|
|
105
|
+
*/
|
|
106
|
+
export function formatRaptorBlock(
|
|
107
|
+
nodes: { summary: string; level: number; score?: number }[],
|
|
108
|
+
): string {
|
|
109
|
+
if (nodes.length === 0) return "";
|
|
110
|
+
const parts = nodes.map((n, i) => {
|
|
111
|
+
const score =
|
|
112
|
+
n.score !== undefined ? ` (relevance ${(n.score * 100).toFixed(0)}%)` : "";
|
|
113
|
+
const label =
|
|
114
|
+
n.level === 0
|
|
115
|
+
? `Session overview [${i + 1}]${score}`
|
|
116
|
+
: `Cluster summary [${i + 1}] (level ${n.level})${score}`;
|
|
117
|
+
return `### ${label}\n${n.summary.trim()}\n`;
|
|
118
|
+
});
|
|
119
|
+
return (
|
|
120
|
+
"The following hierarchical overview summarizes the structure of this " +
|
|
121
|
+
"session so far. Use it as a map of what has been covered:\n\n" +
|
|
122
|
+
parts.join("\n")
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
82
126
|
/**
|
|
83
127
|
* Run the unified recall+dudupe+prepare-inject pipeline. Does NOT touch pi;
|
|
84
128
|
* it records injections via `markInjected` so the next call dedupes. The
|
|
@@ -132,58 +176,119 @@ export function recallAndInline(
|
|
|
132
176
|
const doWindowDedupe = opts.windowDedupe ?? false;
|
|
133
177
|
const dedupSim = opts.dedupSim ?? 0.9;
|
|
134
178
|
|
|
135
|
-
|
|
136
|
-
|
|
179
|
+
// F4: thread skipInjected through to searchRecall instead of hardcoding false
|
|
180
|
+
// and re-implementing the filter here. newHits is already deduped when skip
|
|
181
|
+
// is true (default); equals hits when skip is false (openclaw command path).
|
|
182
|
+
const { newHits } = searchRecall(
|
|
183
|
+
{ sessionId: opts.sessionId, query: opts.query, limit, skipInjected: skip },
|
|
137
184
|
store,
|
|
138
185
|
);
|
|
139
186
|
|
|
187
|
+
// F1: hoist one embedder instance for inline dedupe (matches the async path).
|
|
188
|
+
// defaultEmbedder() is deterministic but creating it per hit wastes allocations.
|
|
189
|
+
const embedder = defaultEmbedder();
|
|
140
190
|
// Precompute live-window embeddings once for inline dedupe (Fix C). Trigram
|
|
141
191
|
// embedder is local + cheap; never a network call (PREVENT-PI-004).
|
|
142
192
|
let liveEmbeddings: number[][] = [];
|
|
143
193
|
if (doWindowDedupe && opts.liveWindow && opts.liveWindow.length > 0) {
|
|
144
|
-
const embedder = defaultEmbedder();
|
|
145
194
|
liveEmbeddings = opts.liveWindow.map((m) => embedder.embed(m));
|
|
146
195
|
}
|
|
147
196
|
|
|
148
|
-
//
|
|
149
|
-
//
|
|
197
|
+
// F3: build the hit list first; format ONCE at the end so the block carries
|
|
198
|
+
// exactly one preamble and [1..n] numbering, and the token cap counts body
|
|
199
|
+
// tokens (one preamble at format time, not N). We accumulate summaries and
|
|
200
|
+
// break mid-stream when the cap would be exceeded.
|
|
150
201
|
const toInject: SearchHit[] = [];
|
|
151
|
-
const parts: string[] = [];
|
|
152
202
|
let blockTokens = 0;
|
|
153
203
|
|
|
154
|
-
for (const h of
|
|
155
|
-
if (skip && vectorWasInjected(store, opts.sessionId, h.checkpoint.checkpointId)) continue;
|
|
156
|
-
|
|
204
|
+
for (const h of newHits) {
|
|
157
205
|
// Inline dedupe: skip a hit already resident in the live window (Fix C).
|
|
158
206
|
if (doWindowDedupe && liveEmbeddings.length > 0) {
|
|
159
|
-
const hitVec =
|
|
207
|
+
const hitVec = embedder.embed(h.checkpoint.summary);
|
|
160
208
|
if (liveEmbeddings.some((v) => cosineSimilarity(v, hitVec) >= dedupSim)) continue;
|
|
161
209
|
}
|
|
162
210
|
|
|
163
|
-
const
|
|
164
|
-
const partTokens = estimateBlockTokens(part);
|
|
211
|
+
const partTokens = estimateBlockTokens(h.checkpoint.summary);
|
|
165
212
|
// Token cap: never push a chunk that would overrun the ceiling.
|
|
166
213
|
if (maxTokens > 0 && blockTokens + partTokens > maxTokens) break;
|
|
167
214
|
|
|
168
|
-
parts.push(part);
|
|
169
215
|
toInject.push(h);
|
|
170
216
|
blockTokens += partTokens;
|
|
171
217
|
vectorMarkInjected(store, opts.sessionId, h.checkpoint.checkpointId);
|
|
172
218
|
}
|
|
173
219
|
|
|
174
|
-
|
|
220
|
+
// F3: format once — one preamble, correct [1..n] numbering.
|
|
221
|
+
const recallBlock = toInject.length > 0 ? formatRecallBlock(toInject) : "";
|
|
175
222
|
const report = toInject.map(
|
|
176
223
|
(h) => ` • ${h.checkpoint.checkpointId} (${h.checkpoint.summary.slice(0, 60).replace(/\n/g, " ")}…)`,
|
|
177
224
|
);
|
|
178
225
|
|
|
226
|
+
// S25 Phase-2 (RAPTOR_INJECT_SUMMARIES): prepend a hierarchical overview
|
|
227
|
+
// header built from the tree's top-level summary nodes (root + the
|
|
228
|
+
// highest-scoring level-1 cluster summaries). This gives the model a topical
|
|
229
|
+
// map of the session before the detailed checkpoint hits. Default ON via
|
|
230
|
+
// the store's config; `opts.raptorSummaries` (when explicitly set) overrides.
|
|
231
|
+
// Skipped when no tree exists, the tree is stale/timedOut, or shadow mode is on.
|
|
232
|
+
let overview = "";
|
|
233
|
+
const injectSummaries = opts.raptorSummaries ?? store.cfg.RAPTOR_INJECT_SUMMARIES;
|
|
234
|
+
if (injectSummaries && recallBlock) {
|
|
235
|
+
overview = raptorOverviewBlock(store, opts.sessionId, opts.query);
|
|
236
|
+
}
|
|
237
|
+
const block = overview && recallBlock
|
|
238
|
+
? overview + "\n" + recallBlock
|
|
239
|
+
: overview || recallBlock;
|
|
240
|
+
|
|
179
241
|
return {
|
|
180
242
|
toInject,
|
|
181
243
|
report,
|
|
182
244
|
block,
|
|
183
|
-
empty:
|
|
245
|
+
empty: block.length === 0,
|
|
184
246
|
};
|
|
185
247
|
}
|
|
186
248
|
|
|
249
|
+
/**
|
|
250
|
+
* S25 Phase-2: build the hierarchical overview header for a session. Rehydrates
|
|
251
|
+
* the persisted RAPTOR tree, picks the root + top level-1 cluster nodes by
|
|
252
|
+
* cosine similarity to the query, and formats them via formatRaptorBlock.
|
|
253
|
+
* Returns "" when no tree, stale tree, timedOut tree, or shadow mode. Non-fatal
|
|
254
|
+
* (wrapped in try/catch) — the overview is a bonus; a failure must never block
|
|
255
|
+
* the detailed recall block.
|
|
256
|
+
*/
|
|
257
|
+
function raptorOverviewBlock(
|
|
258
|
+
store: VectorStore,
|
|
259
|
+
sessionId: string,
|
|
260
|
+
query: string,
|
|
261
|
+
): string {
|
|
262
|
+
try {
|
|
263
|
+
const sid = normalizeSessionId(sessionId);
|
|
264
|
+
// S25 gate: the overview header is part of the RAPTOR serve surface, so it
|
|
265
|
+
// must honor the same contract as raptorSearchHits — shadow mode is
|
|
266
|
+
// logging-only building, not injection.
|
|
267
|
+
if (isShadowMode()) return "";
|
|
268
|
+
const tree = rehydrateRaptorTree(sid, store.stateDir);
|
|
269
|
+
if (!tree || !tree.rootId || tree.timedOut) return "";
|
|
270
|
+
// Freshness: a tree built before the session's latest checkpoint is stale.
|
|
271
|
+
const maxTs = maxCheckpointTimestamp(sid, store.stateDir);
|
|
272
|
+
if (tree.builtAt && tree.builtAt < maxTs) return "";
|
|
273
|
+
const root = tree.nodes.get(tree.rootId);
|
|
274
|
+
if (!root) return "";
|
|
275
|
+
const qv = store.embedder.embed(query);
|
|
276
|
+
// Root (level 0) first, then the top level-1 clusters by cosine to the query.
|
|
277
|
+
const nodes: { summary: string; level: number; score: number }[] = [
|
|
278
|
+
{ summary: root.summary, level: root.level, score: cosineSimilarity(qv, root.embedding) },
|
|
279
|
+
];
|
|
280
|
+
const level1 = [...tree.nodes.values()]
|
|
281
|
+
.filter((n) => n.level === 1 && n.summary)
|
|
282
|
+
.map((n) => ({ summary: n.summary, level: n.level, score: cosineSimilarity(qv, n.embedding) }))
|
|
283
|
+
.sort((a, b) => b.score - a.score)
|
|
284
|
+
.slice(0, 3);
|
|
285
|
+
nodes.push(...level1);
|
|
286
|
+
return formatRaptorBlock(nodes);
|
|
287
|
+
} catch {
|
|
288
|
+
return ""; // non-fatal: overview is a bonus
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
187
292
|
// --- S21: memory recall ----------------------------------------------------
|
|
188
293
|
// Durables (decisions, rules, user-saved facts) live in the `memories` table.
|
|
189
294
|
// We mirror the checkpoint recall path: rank by cosine, format a block, respect
|
|
@@ -303,17 +408,37 @@ export async function recallAndInlineAsync(
|
|
|
303
408
|
hits = [];
|
|
304
409
|
}
|
|
305
410
|
|
|
411
|
+
// F1: hoist one embedder instance for inline dedupe. defaultEmbedder() is
|
|
412
|
+
// deterministic but creating it per call wastes allocations on large hit sets.
|
|
413
|
+
// (recallAndInline already hoisted this; applying the same fix here.)
|
|
414
|
+
const embedder = defaultEmbedder();
|
|
306
415
|
let liveEmbeddings: number[][] = [];
|
|
307
416
|
if (doWindowDedupe && opts.liveWindow && opts.liveWindow.length > 0) {
|
|
308
|
-
const embedder = defaultEmbedder();
|
|
309
417
|
liveEmbeddings = opts.liveWindow.map((m) => embedder.embed(m));
|
|
310
418
|
}
|
|
311
419
|
|
|
312
420
|
const toInject: SearchHit[] = [];
|
|
313
|
-
const parts: string[] = [];
|
|
314
421
|
let blockTokens = 0;
|
|
315
422
|
|
|
423
|
+
// F2: when cross-repo is on but no global index dir could be resolved, skip
|
|
424
|
+
// foreign hits rather than injecting them undeduped — otherwise a foreign
|
|
425
|
+
// checkpoint with no machine-wide injected-set to consult would re-inject in
|
|
426
|
+
// every new session. Same-repo hits (no repoId) are unaffected. Warn once so
|
|
427
|
+
// the silent degradation is observable. (The extension resolver normally
|
|
428
|
+
// supplies a default globalIndexDir, so this is belt-and-braces.)
|
|
429
|
+
const skipCrossRepoHits = !!opts.crossRepo && !opts.globalIndexDir;
|
|
430
|
+
if (skipCrossRepoHits) {
|
|
431
|
+
try {
|
|
432
|
+
console.warn(
|
|
433
|
+
"[mega-compact:recall] cross-repo recall enabled but globalIndexDir is unset — "
|
|
434
|
+
+ "skipping cross-repo injection to avoid re-injecting undeduped foreign checkpoints",
|
|
435
|
+
);
|
|
436
|
+
} catch { /* ignore */ }
|
|
437
|
+
}
|
|
438
|
+
|
|
316
439
|
for (const h of hits) {
|
|
440
|
+
// F2: skip foreign hits when we can't dedup them machine-wide.
|
|
441
|
+
if (skipCrossRepoHits && h.repoId) continue;
|
|
317
442
|
if (skip && vectorWasInjected(store, opts.sessionId, h.checkpoint.checkpointId)) continue;
|
|
318
443
|
// S18: machine-wide injected-set — a foreign checkpoint already injected
|
|
319
444
|
// (in any session) is never re-injected. Only applies to cross-repo hits
|
|
@@ -326,14 +451,15 @@ export async function recallAndInlineAsync(
|
|
|
326
451
|
/* non-fatal: degrade to per-session injected-set only */
|
|
327
452
|
}
|
|
328
453
|
}
|
|
454
|
+
// Inline dedupe: skip a hit already resident in the live window (F1: hoisted embedder).
|
|
329
455
|
if (doWindowDedupe && liveEmbeddings.length > 0) {
|
|
330
|
-
const hitVec =
|
|
456
|
+
const hitVec = embedder.embed(h.checkpoint.summary);
|
|
331
457
|
if (liveEmbeddings.some((v) => cosineSimilarity(v, hitVec) >= dedupSim)) continue;
|
|
332
458
|
}
|
|
333
|
-
|
|
334
|
-
|
|
459
|
+
// F3: build the hit list first; format ONCE at the end so the block carries
|
|
460
|
+
// exactly one preamble and numbering [1..n] rather than one per hit.
|
|
461
|
+
const partTokens = estimateBlockTokens(h.checkpoint.summary);
|
|
335
462
|
if (maxTokens > 0 && blockTokens + partTokens > maxTokens) break;
|
|
336
|
-
parts.push(part);
|
|
337
463
|
toInject.push(h);
|
|
338
464
|
blockTokens += partTokens;
|
|
339
465
|
vectorMarkInjected(store, opts.sessionId, h.checkpoint.checkpointId);
|
|
@@ -349,7 +475,10 @@ export async function recallAndInlineAsync(
|
|
|
349
475
|
}
|
|
350
476
|
}
|
|
351
477
|
|
|
352
|
-
|
|
478
|
+
// F3: format once — one preamble, correct [1..n] numbering, token cap counted
|
|
479
|
+
// against one preamble (not N). Pass the full toInject array so formatRecallBlock
|
|
480
|
+
// has repoId + score for proper labeling.
|
|
481
|
+
const block = toInject.length > 0 ? formatRecallBlock(toInject) : "";
|
|
353
482
|
const report = toInject.map(
|
|
354
483
|
(h) => ` • ${h.checkpoint.checkpointId} (${h.checkpoint.summary.slice(0, 60).replace(/\n/g, " ")}…)`,
|
|
355
484
|
);
|
|
@@ -21,6 +21,9 @@ export interface DedupMirrorRowDB {
|
|
|
21
21
|
/**
|
|
22
22
|
* Upsert a row into dedup_mirror. If the hash already exists, increment ref_count.
|
|
23
23
|
* Returns true if this was a NEW unique content (first insert), false if it was a duplicate.
|
|
24
|
+
*
|
|
25
|
+
* F3 fix: uses INSERT ... ON CONFLICT DO UPDATE (single atomic statement) instead of
|
|
26
|
+
* a check-then-act race-prone SELECT + UPDATE/INSERT sequence.
|
|
24
27
|
*/
|
|
25
28
|
export function upsertDedupMirror(
|
|
26
29
|
db: DatabaseSync,
|
|
@@ -29,29 +32,35 @@ export function upsertDedupMirror(
|
|
|
29
32
|
seq: number,
|
|
30
33
|
): boolean {
|
|
31
34
|
const now = Date.now();
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
db.prepare(`UPDATE dedup_mirror SET ref_count = ref_count + 1 WHERE content_hash = @hash`).run({
|
|
37
|
-
"@hash": contentHash,
|
|
38
|
-
});
|
|
39
|
-
return false;
|
|
40
|
-
}
|
|
41
|
-
db.prepare(
|
|
35
|
+
// Atomic upsert: on conflict, increment ref_count in-place (no check-then-act
|
|
36
|
+
// race). RETURNING ref_count distinguishes the two paths in one statement:
|
|
37
|
+
// inserted rows report ref_count=1, conflict-updated rows report ref_count>1.
|
|
38
|
+
const row = db.prepare(
|
|
42
39
|
`INSERT INTO dedup_mirror (content_hash, content_bytes, ref_count, first_seen_seq, created_at)
|
|
43
|
-
VALUES (@hash, @bytes, 1, @seq, @now)
|
|
44
|
-
|
|
40
|
+
VALUES (@hash, @bytes, 1, @seq, @now)
|
|
41
|
+
ON CONFLICT(content_hash) DO UPDATE SET
|
|
42
|
+
ref_count = ref_count + 1,
|
|
43
|
+
content_bytes = excluded.content_bytes
|
|
44
|
+
RETURNING ref_count`,
|
|
45
|
+
).get({
|
|
45
46
|
"@hash": contentHash,
|
|
46
47
|
"@bytes": contentBytes,
|
|
47
48
|
"@seq": seq,
|
|
48
49
|
"@now": now,
|
|
49
|
-
});
|
|
50
|
-
return
|
|
50
|
+
}) as { ref_count: number } | undefined;
|
|
51
|
+
return (row?.ref_count ?? 1) === 1;
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
/**
|
|
54
55
|
* Get dedup ratio for a session: total bytes vs unique bytes.
|
|
56
|
+
*
|
|
57
|
+
* F2 fix: both total and unique bytes are now scoped to the session, via a JOIN
|
|
58
|
+
* of raw_transcript.content_ref → dedup_mirror. The ratio is meaningful: how much
|
|
59
|
+
* smaller the session's storage footprint is compared to naive inline storage.
|
|
60
|
+
*
|
|
61
|
+
* NOTE: for sessions with NO dedup pipeline runs yet (all content_ref NULL),
|
|
62
|
+
* uniqueBytes falls back to the raw_transcript bytes (ratio=1), which is correct
|
|
63
|
+
* since nothing has been deduplicated yet.
|
|
55
64
|
*/
|
|
56
65
|
export function getDedupRatio(
|
|
57
66
|
db: DatabaseSync,
|
|
@@ -64,12 +73,20 @@ export function getDedupRatio(
|
|
|
64
73
|
WHERE session_id = @session_id`,
|
|
65
74
|
)
|
|
66
75
|
.get({ "@session_id": sessionId }) as { total: number };
|
|
76
|
+
// F2 fix: session-scoped unique bytes via JOIN on content_ref.
|
|
77
|
+
// A row contributes its dedup_mirror bytes exactly once even when content_ref
|
|
78
|
+
// is NULL (fallback: use the raw_transcript bytes for that row, which is
|
|
79
|
+
// accurate when dedup hasn't run yet for the session).
|
|
67
80
|
const uniqueRow = db
|
|
68
81
|
.prepare(
|
|
69
|
-
`SELECT COALESCE(SUM(LENGTH(
|
|
70
|
-
|
|
82
|
+
`SELECT COALESCE(SUM(LENGTH(
|
|
83
|
+
COALESCE(dm.content_bytes, rt.content_bytes)
|
|
84
|
+
)), 0) AS unique_bytes
|
|
85
|
+
FROM raw_transcript rt
|
|
86
|
+
LEFT JOIN dedup_mirror dm ON rt.content_ref = dm.content_hash
|
|
87
|
+
WHERE rt.session_id = @session_id`,
|
|
71
88
|
)
|
|
72
|
-
.get() as { unique_bytes: number };
|
|
89
|
+
.get({ "@session_id": sessionId }) as { unique_bytes: number };
|
|
73
90
|
const totalBytes = totalRow.total;
|
|
74
91
|
const uniqueBytes = uniqueRow.unique_bytes;
|
|
75
92
|
const ratio = uniqueBytes > 0 ? totalBytes / uniqueBytes : 1;
|
|
@@ -111,4 +128,4 @@ export function updateRawTranscriptRef(
|
|
|
111
128
|
"@sid": sessionId,
|
|
112
129
|
"@seq": seq,
|
|
113
130
|
});
|
|
114
|
-
}
|
|
131
|
+
}
|
|
@@ -36,7 +36,7 @@ const DB_TABLE_NAMES = [
|
|
|
36
36
|
"checkpoint_epochs",
|
|
37
37
|
"dedup_mirror",
|
|
38
38
|
"memories",
|
|
39
|
-
"
|
|
39
|
+
"meta",
|
|
40
40
|
"daily_log",
|
|
41
41
|
] as const;
|
|
42
42
|
|
|
@@ -136,7 +136,7 @@ export function pruneOldRows(stateDir: string = getStateDir(), daysOld = 30): Ma
|
|
|
136
136
|
// dedup_mirror: cascade-delete orphan rows whose ref_count has dropped to 0
|
|
137
137
|
// after the raw_transcript deletes. Safe even if FK is off (raw_transcript has
|
|
138
138
|
// no FK to dedup_mirror; ref_count is maintained by the dedup pipeline).
|
|
139
|
-
const delDedup = db.prepare(`DELETE FROM dedup_mirror WHERE
|
|
139
|
+
const delDedup = db.prepare(`DELETE FROM dedup_mirror WHERE content_hash NOT IN (SELECT DISTINCT content_ref FROM raw_transcript WHERE content_ref IS NOT NULL)`).run() as {
|
|
140
140
|
changes?: number;
|
|
141
141
|
} | undefined;
|
|
142
142
|
const dedupDeleted = delDedup?.changes ?? 0;
|