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