pi-mega-compact 0.4.20 → 0.4.23
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/conflict-scan.js +201 -0
- package/dist/extensions/dashboard-server.js +3 -3
- package/dist/extensions/mega-compact-driver.js +79 -0
- package/dist/extensions/mega-compact.js +2 -0
- package/dist/extensions/mega-compact.test.js +54 -18
- package/dist/extensions/mega-config.js +10 -0
- package/dist/extensions/mega-conflict-cmds.js +121 -0
- package/dist/extensions/mega-events.js +45 -23
- package/dist/extensions/mega-pipeline.js +80 -8
- package/dist/extensions/mega-runtime.js +14 -20
- package/dist/src/config/dedup.js +4 -1
- package/dist/src/config.js +21 -0
- package/dist/src/dedup/raptor/index.js +28 -6
- package/dist/src/dedup/raptor/promote.test.js +69 -0
- package/dist/src/engine.js +1 -0
- package/dist/src/recall.js +30 -4
- package/dist/src/recall.test.js +28 -0
- package/dist/src/store/backfill.js +5 -6
- package/dist/src/store/compression.js +47 -7
- package/dist/src/store/compression.test.js +48 -0
- package/dist/src/store/sqlite.js +123 -41
- package/dist/src/store.test.js +19 -0
- package/dist/src/vectorStore.js +56 -1
- package/extensions/DASHBOARD.md +3 -3
- package/extensions/conflict-scan.ts +209 -0
- package/extensions/dashboard-server.ts +4 -4
- package/extensions/mega-compact-driver.ts +105 -0
- package/extensions/mega-compact.test.ts +65 -18
- package/extensions/mega-compact.ts +2 -0
- package/extensions/mega-config.ts +25 -0
- package/extensions/mega-conflict-cmds.ts +129 -0
- package/extensions/mega-events.ts +43 -24
- package/extensions/mega-pipeline.ts +86 -9
- package/extensions/mega-runtime.ts +14 -18
- package/package.json +6 -7
- package/src/config/dedup.ts +4 -1
- package/src/config.ts +26 -0
- package/src/dedup/raptor/index.ts +42 -7
- package/src/dedup/raptor/promote.test.ts +82 -0
- package/src/engine.ts +5 -0
- package/src/recall.test.ts +44 -0
- package/src/recall.ts +43 -4
- package/src/store/backfill.ts +10 -11
- package/src/store/compression.test.ts +58 -0
- package/src/store/compression.ts +48 -7
- package/src/store/sqlite.ts +156 -49
- package/src/store.test.ts +22 -0
- package/src/vectorStore.ts +63 -1
- package/dist/extensions/openclaw-mega-compact.js +0 -291
- package/dist/src/minilm.js +0 -92
- package/dist/src/wordpiece.js +0 -129
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* 1. `compressSmart` / `decompressSmart` — SYNCHRONOUS, zlib-based. Used by the
|
|
7
7
|
* VectorStore write path (which must stay synchronous — see Sprint 8 plan:
|
|
8
|
-
*
|
|
8
|
+
* node:sqlite replaced PGlite precisely to avoid an async cascade).
|
|
9
9
|
*
|
|
10
10
|
* 2. `compressZstd` / `decompressZstd` — ASYNCHRONOUS, via @mongodb-js/zstd.
|
|
11
11
|
* Optional, used for DR-export / large-blob paths where an await is fine.
|
|
@@ -25,7 +25,13 @@
|
|
|
25
25
|
* decompressSmart detects the magic first, so all three eras roundtrip together.
|
|
26
26
|
*/
|
|
27
27
|
import { gzipSync, gunzipSync, brotliCompressSync, brotliDecompressSync, constants as zlibConstants, } from "node:zlib";
|
|
28
|
-
|
|
28
|
+
// zstd is loaded lazily (see compressZstdWithLevel / decompressZstd). It is an
|
|
29
|
+
// OPTIONAL async DR-export dependency: its native addon (`zstd.node`) is not in
|
|
30
|
+
// the npm tarball and may be absent on a clean/allowScripts-blocked install, so
|
|
31
|
+
// a static import here would crash the whole extension at load time. Lazy
|
|
32
|
+
// import keeps the extension loadable even when the binary is missing; the DR
|
|
33
|
+
// path throws a clear error only if it is actually used. (Fix A.)
|
|
34
|
+
// import zstd from "@mongodb-js/zstd";
|
|
29
35
|
// --- Versioned format markers ----------------------------------------------
|
|
30
36
|
const MAGIC_HI = 0xec;
|
|
31
37
|
const MAGIC_LO = 0x01; // format version 1
|
|
@@ -45,6 +51,12 @@ const SIZE_MEDIUM = 32768;
|
|
|
45
51
|
function header(ver, tag) {
|
|
46
52
|
return Buffer.from([MAGIC_HI, MAGIC_LO, ver, tag]);
|
|
47
53
|
}
|
|
54
|
+
/** Clamp a value to the [0, 1] range (pressure bands). */
|
|
55
|
+
function clamp01(n) {
|
|
56
|
+
if (Number.isNaN(n))
|
|
57
|
+
return 0;
|
|
58
|
+
return n < 0 ? 0 : n > 1 ? 1 : n;
|
|
59
|
+
}
|
|
48
60
|
/**
|
|
49
61
|
* Compress synchronously using the best zlib tier for the payload size.
|
|
50
62
|
*
|
|
@@ -54,21 +66,36 @@ function header(ver, tag) {
|
|
|
54
66
|
* 4KB–32KB → gzip level 6 (tag 0x02)
|
|
55
67
|
* > 32 KB → brotli 4 (tag 0x05)
|
|
56
68
|
*
|
|
57
|
-
*
|
|
69
|
+
* `pressure` (0–1, optional) escalates the brotli quality for the large tier
|
|
70
|
+
* when the session is near its context limit — the "variable compression as we
|
|
71
|
+
* approach the limit" design (Fix E). Low/undefined pressure keeps brotli-4;
|
|
72
|
+
* high pressure pushes toward brotli-11. Stays fully synchronous (brotli-11 is
|
|
73
|
+
* sync via brotliCompressSync) so the sync `add()` contract is preserved; zstd
|
|
74
|
+
* is reserved for the async DR-export path only. Same versioned header/tags for
|
|
75
|
+
* every pressure, so decompressSmart is unaffected.
|
|
58
76
|
*/
|
|
59
|
-
export function compressSmart(data) {
|
|
77
|
+
export function compressSmart(data, pressure = 0) {
|
|
78
|
+
const p = clamp01(pressure);
|
|
60
79
|
const len = data.length;
|
|
61
80
|
if (len < SIZE_TINY) {
|
|
62
81
|
return Buffer.concat([header(1, TAG_RAW), data]);
|
|
63
82
|
}
|
|
64
83
|
if (len < SIZE_SMALL) {
|
|
65
|
-
|
|
84
|
+
// Small tier: escalate gzip level 1 → 9 with context pressure (Fix E) so
|
|
85
|
+
// the "variable compression as we approach the limit" dial bites for
|
|
86
|
+
// short sessions too, not just the >32KB brotli tier.
|
|
87
|
+
const level = Math.max(1, Math.min(9, Math.round(1 + 8 * p)));
|
|
88
|
+
return Buffer.concat([header(1, TAG_GZIP_1), gzipSync(data, { level })]);
|
|
66
89
|
}
|
|
67
90
|
if (len < SIZE_MEDIUM) {
|
|
68
|
-
|
|
91
|
+
// Medium tier: escalate gzip level 6 → 9 with context pressure (Fix E).
|
|
92
|
+
const level = Math.max(6, Math.min(9, Math.round(6 + 3 * p)));
|
|
93
|
+
return Buffer.concat([header(1, TAG_GZIP_6), gzipSync(data, { level })]);
|
|
69
94
|
}
|
|
95
|
+
// Large tier: escalate brotli quality 4 → 11 with context pressure (Fix E).
|
|
96
|
+
const quality = Math.max(4, Math.min(11, Math.round(4 + 7 * p)));
|
|
70
97
|
const compressed = brotliCompressSync(data, {
|
|
71
|
-
params: { [zlibConstants.BROTLI_PARAM_QUALITY]:
|
|
98
|
+
params: { [zlibConstants.BROTLI_PARAM_QUALITY]: quality },
|
|
72
99
|
});
|
|
73
100
|
return Buffer.concat([header(1, TAG_BROTLI_4), compressed]);
|
|
74
101
|
}
|
|
@@ -141,6 +168,17 @@ export function decompressSmart(buf) {
|
|
|
141
168
|
const ZSTD_MAGIC_HI = 0x5a; // 'Z'
|
|
142
169
|
const ZSTD_MAGIC_LO = 0x53; // 'S'
|
|
143
170
|
async function compressZstdWithLevel(data, level) {
|
|
171
|
+
// Lazy import: the native addon may be absent (clean/allowScripts install).
|
|
172
|
+
// Throws a clear, actionable error instead of a load-time crash.
|
|
173
|
+
let zstd;
|
|
174
|
+
try {
|
|
175
|
+
zstd = await import("@mongodb-js/zstd");
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
throw new Error("zstd is not available — the @mongodb-js/zstd native addon (zstd.node) " +
|
|
179
|
+
"was not built. Run the extension's native install step (or allow npm " +
|
|
180
|
+
"install scripts) to enable DR-export compression.");
|
|
181
|
+
}
|
|
144
182
|
const compressed = await zstd.compress(data, level);
|
|
145
183
|
return Buffer.concat([Buffer.from([ZSTD_MAGIC_HI, ZSTD_MAGIC_LO]), compressed]);
|
|
146
184
|
}
|
|
@@ -163,6 +201,8 @@ export async function decompressZstd(buf) {
|
|
|
163
201
|
if (!isZstd(buf)) {
|
|
164
202
|
throw new Error("decompressZstd: buffer is not a zstd blob (missing ZS marker)");
|
|
165
203
|
}
|
|
204
|
+
// Lazy import (see compressZstdWithLevel for rationale).
|
|
205
|
+
const zstd = await import("@mongodb-js/zstd");
|
|
166
206
|
return zstd.decompress(buf.subarray(2));
|
|
167
207
|
}
|
|
168
208
|
/**
|
|
@@ -65,3 +65,51 @@ test("zstd helper roundtrips (async) and is not sync-decoded", async () => {
|
|
|
65
65
|
assert.equal(auto.isZstd, true, "flagged as zstd");
|
|
66
66
|
assert.deepEqual(await decompressZstd(c), data, "zstd roundtrip");
|
|
67
67
|
});
|
|
68
|
+
test("module loads without a top-level zstd import (Fix A: no load crash)", async () => {
|
|
69
|
+
// The extension must load even when the @mongodb-js/zstd native addon is
|
|
70
|
+
// absent (clean/allowScripts-blocked install). The dynamic import() lives
|
|
71
|
+
// inside the helpers, so importing this module must never throw.
|
|
72
|
+
const mod = await import("./compression.js");
|
|
73
|
+
assert.equal(typeof mod.compressSmart, "function", "compressSmart exported");
|
|
74
|
+
assert.equal(typeof mod.compressZstd, "function", "compressZstd exported");
|
|
75
|
+
// The real invariant: no STATIC `import ... from "@mongodb-js/zstd"` at the
|
|
76
|
+
// top level (that's what crashed the whole extension). zstd must be loaded
|
|
77
|
+
// lazily inside the helpers only. Check the source text.
|
|
78
|
+
const { readFileSync } = await import("node:fs");
|
|
79
|
+
const { join } = await import("node:path");
|
|
80
|
+
// Tests run with cwd at repo root (`node --test`), so resolve the source.
|
|
81
|
+
const src = readFileSync(join(process.cwd(), "src/store/compression.ts"), "utf-8");
|
|
82
|
+
const staticImport = /^import\s+.+\s+from\s+["']@mongodb-js\/zstd["'];?$/m;
|
|
83
|
+
assert.equal(staticImport.test(src), false, "no static top-level import of @mongodb-js/zstd (would crash load if binary absent)");
|
|
84
|
+
assert.ok(src.includes('await import("@mongodb-js/zstd")'), "zstd is loaded lazily via dynamic import() inside the helpers");
|
|
85
|
+
});
|
|
86
|
+
test("compressSmart escalates brotli quality with pressure (Fix E)", () => {
|
|
87
|
+
// Large (>32KB) payloads hit the brotli tier; higher pressure → brotli-11
|
|
88
|
+
// → smaller output than the default brotli-4, and still decodes.
|
|
89
|
+
const words = Array.from({ length: 6000 }, (_, i) => "word" + ((i * 2654435761) % 9973));
|
|
90
|
+
const big = Buffer.from(words.join(" "));
|
|
91
|
+
const low = compressSmart(big, 0);
|
|
92
|
+
const high = compressSmart(big, 1);
|
|
93
|
+
assert.equal(isVersioned(low), true, "versioned header preserved at p=0");
|
|
94
|
+
assert.equal(isVersioned(high), true, "versioned header preserved at p=1");
|
|
95
|
+
assert.ok(high.length < low.length, "high pressure compresses smaller");
|
|
96
|
+
assert.deepEqual(decompressSmart(low), big, "p=0 roundtrip");
|
|
97
|
+
assert.deepEqual(decompressSmart(high), big, "p=1 roundtrip");
|
|
98
|
+
// Small payloads ignore pressure (gzip tier) but still roundtrip.
|
|
99
|
+
const small = buf("hello world ", 300);
|
|
100
|
+
assert.deepEqual(decompressSmart(compressSmart(small, 1)), small, "small ignores pressure");
|
|
101
|
+
// pressure out of range is clamped (no throw, still versioned + decodable).
|
|
102
|
+
assert.deepEqual(decompressSmart(compressSmart(big, 5)), big, "over-pressure clamped");
|
|
103
|
+
assert.deepEqual(decompressSmart(compressSmart(big, -1)), big, "under-pressure clamped");
|
|
104
|
+
});
|
|
105
|
+
test("pressureFromPct + preserveRecentForPressure scale with context (Fix E)", async () => {
|
|
106
|
+
const { pressureFromPct, preserveRecentForPressure } = await import("../config.js");
|
|
107
|
+
assert.equal(pressureFromPct(50), 0.5, "pct→pressure");
|
|
108
|
+
assert.equal(pressureFromPct(null), 0, "null pct → 0");
|
|
109
|
+
assert.equal(pressureFromPct(150), 1, "pct clamped");
|
|
110
|
+
// low pressure keeps preserveRecent; high pressure compacts deeper (min floor).
|
|
111
|
+
assert.equal(preserveRecentForPressure(0, 4, 2), 4, "p=0 → preserveRecent");
|
|
112
|
+
assert.equal(preserveRecentForPressure(1, 4, 2), 2, "p=1 → preserveRecentMin");
|
|
113
|
+
assert.equal(preserveRecentForPressure(0.5, 4, 2), 3, "p=0.5 → interpolates");
|
|
114
|
+
assert.ok(preserveRecentForPressure(1, 4, 2) >= 2, "never below floor");
|
|
115
|
+
});
|
package/dist/src/store/sqlite.js
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
* sqlite.ts — Sprint 8 storage backbone (the "one store").
|
|
3
3
|
*
|
|
4
4
|
* Replaces the per-session gzipped-JSON checkpoint files with a single local
|
|
5
|
-
* SQLite database (
|
|
6
|
-
* honors PREVENT-PI-004).
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* SQLite database (node:sqlite — the Node built-in, in-process, FS-backed,
|
|
6
|
+
* ZERO network calls — honors PREVENT-PI-004). No native build and no install
|
|
7
|
+
* scripts, so it survives pi's npm blocked-install-scripts gate (better-sqlite3's
|
|
8
|
+
* native binary could not be built under pi, which crashed every `pi update
|
|
9
|
+
* --extensions`). node:sqlite is synchronous, so every VectorStore signature
|
|
10
|
+
* stays sync. PGlite + pgvector (async) is layered on in vectorIndex.ts for
|
|
11
|
+
* real HNSW indexing (Slice 2 of the dual-backend plan).
|
|
10
12
|
*
|
|
11
13
|
* FTS5 `trigram` tokenizer is created for the Sprint 9+ dedup tiers (MinHash/LSH
|
|
12
14
|
* / pg_trgm-equivalent verification). The default cosine path stays a linear
|
|
@@ -14,7 +16,7 @@
|
|
|
14
16
|
*
|
|
15
17
|
* All queries are parameterized (PREVENT-002) — never string-concatenated.
|
|
16
18
|
*/
|
|
17
|
-
import
|
|
19
|
+
import { DatabaseSync } from "node:sqlite";
|
|
18
20
|
import { existsSync, mkdirSync } from "node:fs";
|
|
19
21
|
import { homedir, tmpdir } from "node:os";
|
|
20
22
|
import { join } from "node:path";
|
|
@@ -28,14 +30,17 @@ function encodeEmbedding(v) {
|
|
|
28
30
|
buf.writeFloatLE(v[i] ?? 0, i * 4);
|
|
29
31
|
return buf;
|
|
30
32
|
}
|
|
31
|
-
/** Decode a Float32 BLOB back to a number[].
|
|
33
|
+
/** Decode a Float32 BLOB back to a number[]. node:sqlite returns BLOBs as
|
|
34
|
+
* Uint8Array, so decode via DataView (Buffer is a Uint8Array subclass — both
|
|
35
|
+
* work). */
|
|
32
36
|
function decodeEmbedding(buf) {
|
|
33
37
|
if (!buf || buf.length === 0)
|
|
34
38
|
return [];
|
|
39
|
+
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
35
40
|
const n = buf.length / 4;
|
|
36
41
|
const out = new Array(n);
|
|
37
42
|
for (let i = 0; i < n; i++)
|
|
38
|
-
out[i] =
|
|
43
|
+
out[i] = dv.getFloat32(i * 4, true);
|
|
39
44
|
return out;
|
|
40
45
|
}
|
|
41
46
|
function jsonText(v) {
|
|
@@ -52,9 +57,9 @@ export function openStore(stateDir = getStateDir()) {
|
|
|
52
57
|
return existing;
|
|
53
58
|
if (!existsSync(stateDir))
|
|
54
59
|
mkdirSync(stateDir, { recursive: true });
|
|
55
|
-
const db = new
|
|
56
|
-
db.
|
|
57
|
-
db.
|
|
60
|
+
const db = new DatabaseSync(join(stateDir, "sqlite.db"));
|
|
61
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
62
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
58
63
|
initSchema(db);
|
|
59
64
|
cache.set(stateDir, db);
|
|
60
65
|
return db;
|
|
@@ -91,10 +96,10 @@ export function openIndexStore(indexDir = getIndexDir()) {
|
|
|
91
96
|
return indexCache;
|
|
92
97
|
if (!existsSync(indexDir))
|
|
93
98
|
mkdirSync(indexDir, { recursive: true });
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
99
|
+
const iddb = new DatabaseSync(join(indexDir, "index.sqlite"));
|
|
100
|
+
iddb.exec("PRAGMA journal_mode = WAL");
|
|
101
|
+
iddb.exec("PRAGMA busy_timeout = 3000"); // tolerate brief cross-process write contention
|
|
102
|
+
iddb.exec(`
|
|
98
103
|
CREATE TABLE IF NOT EXISTS repo_registry (
|
|
99
104
|
repo_root TEXT PRIMARY KEY,
|
|
100
105
|
display_name TEXT,
|
|
@@ -114,9 +119,9 @@ export function openIndexStore(indexDir = getIndexDir()) {
|
|
|
114
119
|
);
|
|
115
120
|
CREATE INDEX IF NOT EXISTS idx_registry_last_seen ON repo_registry(last_seen DESC);
|
|
116
121
|
`);
|
|
117
|
-
indexCache =
|
|
122
|
+
indexCache = iddb;
|
|
118
123
|
indexCacheDir = indexDir;
|
|
119
|
-
return
|
|
124
|
+
return iddb;
|
|
120
125
|
}
|
|
121
126
|
/**
|
|
122
127
|
* Upsert a repo's aggregate stats into the global index. Called on repo-switch
|
|
@@ -359,6 +364,20 @@ function initSchema(db) {
|
|
|
359
364
|
ts INTEGER
|
|
360
365
|
);
|
|
361
366
|
|
|
367
|
+
-- Durable "save to memory" store (taken over from memory extensions).
|
|
368
|
+
-- One row per saved memory; scoped by repo so memory travels with the
|
|
369
|
+
-- clone. All params are parameterized (PREVENT-002).
|
|
370
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
371
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
372
|
+
repo TEXT,
|
|
373
|
+
kind TEXT DEFAULT 'note', -- note | fact | decision | preference
|
|
374
|
+
content TEXT NOT NULL,
|
|
375
|
+
tags TEXT, -- JSON array of strings
|
|
376
|
+
created_at INTEGER,
|
|
377
|
+
last_recalled_at INTEGER
|
|
378
|
+
);
|
|
379
|
+
CREATE INDEX IF NOT EXISTS idx_memories_repo ON memories(repo);
|
|
380
|
+
|
|
362
381
|
-- FTS5 trigram virtual table (Sprint 9+ pg_trgm-equivalent verification).
|
|
363
382
|
CREATE VIRTUAL TABLE IF NOT EXISTS context_chunks_trgm USING fts5(
|
|
364
383
|
id UNINDEXED,
|
|
@@ -476,6 +495,69 @@ export function addLesson(sessionId, repo, lesson, stateDir = getStateDir()) {
|
|
|
476
495
|
const now = Math.floor(Date.now() / 1000);
|
|
477
496
|
db.prepare(`INSERT INTO lessons(session_id, repo, lesson, ts) VALUES(?, ?, ?, ?)`).run(normalizeSessionId(sessionId), repo ?? null, lesson, now);
|
|
478
497
|
}
|
|
498
|
+
/** Save a memory to the current repo's store. Returns the new row id. */
|
|
499
|
+
export function addMemory(memory, repo, stateDir = getStateDir()) {
|
|
500
|
+
const db = openStore(stateDir);
|
|
501
|
+
const now = Math.floor(Date.now() / 1000);
|
|
502
|
+
const res = db
|
|
503
|
+
.prepare(`INSERT INTO memories(repo, kind, content, tags, created_at, last_recalled_at)
|
|
504
|
+
VALUES(?, ?, ?, ?, ?, NULL)`)
|
|
505
|
+
.run(repo ?? null, memory.kind ?? "note", memory.content, JSON.stringify(memory.tags ?? []), now);
|
|
506
|
+
return Number(res.lastInsertRowid);
|
|
507
|
+
}
|
|
508
|
+
/** List recent memories for a repo (or all repos when repo is null). */
|
|
509
|
+
export function listMemories(repo, limit = 50, stateDir = getStateDir()) {
|
|
510
|
+
const db = openStore(stateDir);
|
|
511
|
+
const rows = repo
|
|
512
|
+
? db.prepare("SELECT * FROM memories WHERE repo = ? ORDER BY created_at DESC LIMIT ?").all(repo, limit)
|
|
513
|
+
: db.prepare("SELECT * FROM memories ORDER BY created_at DESC LIMIT ?").all(limit);
|
|
514
|
+
return rows.map(mapMemoryRow);
|
|
515
|
+
}
|
|
516
|
+
/** Substring search across content + tags. */
|
|
517
|
+
export function searchMemories(query, repo = null, limit = 50, stateDir = getStateDir()) {
|
|
518
|
+
const db = openStore(stateDir);
|
|
519
|
+
const like = `%${query}%`;
|
|
520
|
+
const rows = repo
|
|
521
|
+
? db.prepare("SELECT * FROM memories WHERE repo = ? AND (content LIKE ? OR tags LIKE ?) ORDER BY created_at DESC LIMIT ?").all(repo, like, like, limit)
|
|
522
|
+
: db.prepare("SELECT * FROM memories WHERE content LIKE ? OR tags LIKE ? ORDER BY created_at DESC LIMIT ?").all(like, like, limit);
|
|
523
|
+
return rows.map(mapMemoryRow);
|
|
524
|
+
}
|
|
525
|
+
/** Mark a memory as recalled (updates last_recalled_at). Returns true if found. */
|
|
526
|
+
export function recallMemory(id, stateDir = getStateDir()) {
|
|
527
|
+
const db = openStore(stateDir);
|
|
528
|
+
const now = Math.floor(Date.now() / 1000);
|
|
529
|
+
const res = db.prepare("UPDATE memories SET last_recalled_at = ? WHERE id = ?").run(now, id);
|
|
530
|
+
return res.changes > 0;
|
|
531
|
+
}
|
|
532
|
+
function mapMemoryRow(row) {
|
|
533
|
+
return {
|
|
534
|
+
id: row.id,
|
|
535
|
+
repo: row.repo ?? null,
|
|
536
|
+
kind: row.kind ?? "note",
|
|
537
|
+
content: row.content ?? "",
|
|
538
|
+
tags: row.tags ? JSON.parse(row.tags) : [],
|
|
539
|
+
createdAt: row.created_at ?? 0,
|
|
540
|
+
lastRecalledAt: row.last_recalled_at ?? null,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Run `fn` atomically. Uses SAVEPOINT so it nests safely under an outer
|
|
545
|
+
* transaction (unlike `BEGIN`, which SQLite rejects when one is already open).
|
|
546
|
+
* Mirrors better-sqlite3's `db.transaction(fn)` semantics — callers that wrap a
|
|
547
|
+
* batch in withTx (e.g. backfill) can still call helpers that also use withTx.
|
|
548
|
+
*/
|
|
549
|
+
export function withTx(db, fn) {
|
|
550
|
+
db.exec("SAVEPOINT mc_tx");
|
|
551
|
+
try {
|
|
552
|
+
fn();
|
|
553
|
+
db.exec("RELEASE mc_tx");
|
|
554
|
+
}
|
|
555
|
+
catch (e) {
|
|
556
|
+
db.exec("ROLLBACK TO mc_tx");
|
|
557
|
+
db.exec("RELEASE mc_tx");
|
|
558
|
+
throw e;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
479
561
|
/** Map a DB row to the public StoredCheckpoint shape. */
|
|
480
562
|
function rowToCheckpoint(row) {
|
|
481
563
|
return {
|
|
@@ -494,7 +576,9 @@ function rowToCheckpoint(row) {
|
|
|
494
576
|
contentHash2: row.content_hash2 ?? undefined,
|
|
495
577
|
contentHashVersion: row.content_hash_version ?? undefined,
|
|
496
578
|
normalizedText: row.normalized_text ?? undefined,
|
|
497
|
-
|
|
579
|
+
// node:sqlite returns BLOBs as Uint8Array; normalize to Buffer so callers
|
|
580
|
+
// (e.g. decompressSmart → Buffer.toString) behave as under better-sqlite3.
|
|
581
|
+
compressedOriginal: row.compressed_original ? Buffer.from(row.compressed_original) : undefined,
|
|
498
582
|
embedding: decodeEmbedding(row.embedding_blob),
|
|
499
583
|
timestamp: Number(row.timestamp ?? 0),
|
|
500
584
|
dedupStatus: row.dedup_status ?? undefined,
|
|
@@ -504,7 +588,7 @@ function rowToCheckpoint(row) {
|
|
|
504
588
|
export function upsertCheckpoint(cp, stateDir = getStateDir()) {
|
|
505
589
|
const db = openStore(stateDir);
|
|
506
590
|
const sid = normalizeSessionId(cp.sessionId);
|
|
507
|
-
|
|
591
|
+
withTx(db, () => {
|
|
508
592
|
db.prepare(`INSERT INTO context_chunks
|
|
509
593
|
(id, session_id, region_hash, content_hash, content_hash2, content_hash_version,
|
|
510
594
|
normalized_text, summary, topic_summary, summary_hash,
|
|
@@ -527,25 +611,25 @@ export function upsertCheckpoint(cp, stateDir = getStateDir()) {
|
|
|
527
611
|
timestamp=excluded.timestamp,
|
|
528
612
|
dedup_status=excluded.dedup_status,
|
|
529
613
|
compressed_original=excluded.compressed_original`).run({
|
|
530
|
-
id: cp.checkpointId,
|
|
531
|
-
sid,
|
|
532
|
-
region_hash: cp.regionHash ?? null,
|
|
533
|
-
content_hash: cp.contentHash ?? null,
|
|
534
|
-
content_hash2: cp.contentHash2 ?? null,
|
|
535
|
-
content_hash_version: cp.contentHashVersion ?? null,
|
|
536
|
-
normalized_text: cp.normalizedText ?? null,
|
|
537
|
-
summary: cp.summary ?? "",
|
|
538
|
-
topic_summary: cp.topicSummary ?? null,
|
|
539
|
-
summary_hash: cp.summaryHash ?? null,
|
|
540
|
-
key_decisions: jsonText(cp.keyDecisions),
|
|
541
|
-
next_steps: jsonText(cp.nextSteps),
|
|
542
|
-
files_modified: jsonText(cp.filesModified),
|
|
543
|
-
embedding_blob: encodeEmbedding(cp.embedding ?? []),
|
|
544
|
-
token_estimate: cp.tokenEstimate ?? 0,
|
|
545
|
-
original_token_estimate: cp.originalTokenEstimate ?? null,
|
|
546
|
-
timestamp: cp.timestamp ?? 0,
|
|
547
|
-
dedup_status: "active",
|
|
548
|
-
compressed_original: cp.compressedOriginal ?? null,
|
|
614
|
+
"@id": cp.checkpointId,
|
|
615
|
+
"@sid": sid,
|
|
616
|
+
"@region_hash": cp.regionHash ?? null,
|
|
617
|
+
"@content_hash": cp.contentHash ?? null,
|
|
618
|
+
"@content_hash2": cp.contentHash2 ?? null,
|
|
619
|
+
"@content_hash_version": cp.contentHashVersion ?? null,
|
|
620
|
+
"@normalized_text": cp.normalizedText ?? null,
|
|
621
|
+
"@summary": cp.summary ?? "",
|
|
622
|
+
"@topic_summary": cp.topicSummary ?? null,
|
|
623
|
+
"@summary_hash": cp.summaryHash ?? null,
|
|
624
|
+
"@key_decisions": jsonText(cp.keyDecisions),
|
|
625
|
+
"@next_steps": jsonText(cp.nextSteps),
|
|
626
|
+
"@files_modified": jsonText(cp.filesModified),
|
|
627
|
+
"@embedding_blob": encodeEmbedding(cp.embedding ?? []),
|
|
628
|
+
"@token_estimate": cp.tokenEstimate ?? 0,
|
|
629
|
+
"@original_token_estimate": cp.originalTokenEstimate ?? null,
|
|
630
|
+
"@timestamp": cp.timestamp ?? 0,
|
|
631
|
+
"@dedup_status": "active",
|
|
632
|
+
"@compressed_original": cp.compressedOriginal ?? null,
|
|
549
633
|
});
|
|
550
634
|
// FTS5 virtual tables don't support UPSERT — delete any prior row, reinsert.
|
|
551
635
|
// Store normalized_text (the L1 verify key); fall back to summary for rows
|
|
@@ -553,7 +637,6 @@ export function upsertCheckpoint(cp, stateDir = getStateDir()) {
|
|
|
553
637
|
db.prepare("DELETE FROM context_chunks_trgm WHERE id = ?").run(cp.checkpointId);
|
|
554
638
|
db.prepare("INSERT INTO context_chunks_trgm(id, normalized_text) VALUES(?, ?)").run(cp.checkpointId, cp.normalizedText ?? cp.summary ?? "");
|
|
555
639
|
});
|
|
556
|
-
tx();
|
|
557
640
|
}
|
|
558
641
|
// --- Sprint 11: MinHash signatures + LSH buckets --------------------------
|
|
559
642
|
/** Persist a checkpoint's MinHash signature (idempotent by chunk_id + version). */
|
|
@@ -571,12 +654,11 @@ export function insertLshBuckets(chunkId, sessionId, signatureVersion, bucketKey
|
|
|
571
654
|
const sid = normalizeSessionId(sessionId);
|
|
572
655
|
const del = db.prepare("DELETE FROM dedup_lsh_buckets WHERE chunk_id = ?");
|
|
573
656
|
const ins = db.prepare("INSERT OR IGNORE INTO dedup_lsh_buckets(bucket_key, chunk_id, session_id, signature_version) VALUES(?, ?, ?, ?)");
|
|
574
|
-
|
|
657
|
+
withTx(db, () => {
|
|
575
658
|
del.run(chunkId);
|
|
576
659
|
for (const key of bucketKeys)
|
|
577
660
|
ins.run(key, chunkId, sid, signatureVersion);
|
|
578
661
|
});
|
|
579
|
-
tx();
|
|
580
662
|
}
|
|
581
663
|
/**
|
|
582
664
|
* Candidate chunk_ids sharing any LSH bucket with `bucketKeys`, scoped to the
|
package/dist/src/store.test.js
CHANGED
|
@@ -109,6 +109,25 @@ test("compression tier: GZIP-1 and GZIP-6 tiers produce valid, smaller output",
|
|
|
109
109
|
assert.ok(big.length < Buffer.from("compress me ".repeat(1800)).length, "GZIP-6 output smaller than input");
|
|
110
110
|
assert.deepEqual(decompressSmart(big), Buffer.from("compress me ".repeat(1800)));
|
|
111
111
|
});
|
|
112
|
+
test("Fix E: pressure escalates gzip tier strength (sync, no zstd)", () => {
|
|
113
|
+
const small = Buffer.from("compress me ".repeat(200)); // 512B–4KB band
|
|
114
|
+
const medium = Buffer.from("compress me ".repeat(1800)); // 4KB–32KB band
|
|
115
|
+
// Low pressure → cheap levels (gzip-1 / gzip-6).
|
|
116
|
+
const lowSmall = compressSmart(small, 0);
|
|
117
|
+
const lowMedium = compressSmart(medium, 0);
|
|
118
|
+
assert.equal(lowSmall[3], 0x01, "small tier tag");
|
|
119
|
+
assert.equal(lowMedium[3], 0x02, "medium tier tag");
|
|
120
|
+
assert.ok(decompressSmart(lowSmall).equals(small), "low-pressure small roundtrips");
|
|
121
|
+
assert.ok(decompressSmart(lowMedium).equals(medium), "low-pressure medium roundtrips");
|
|
122
|
+
// High pressure → stronger levels (gzip-9 / gzip-9); tag unchanged, output
|
|
123
|
+
// must still decode to the exact original (versioned header preserved).
|
|
124
|
+
const highSmall = compressSmart(small, 1);
|
|
125
|
+
const highMedium = compressSmart(medium, 1);
|
|
126
|
+
assert.equal(highSmall[3], 0x01, "tag unchanged under pressure");
|
|
127
|
+
assert.equal(highMedium[3], 0x02, "tag unchanged under pressure");
|
|
128
|
+
assert.ok(decompressSmart(highSmall).equals(small), "high-pressure small roundtrips");
|
|
129
|
+
assert.ok(decompressSmart(highMedium).equals(medium), "high-pressure medium roundtrips");
|
|
130
|
+
});
|
|
112
131
|
// ---------------------------------------------------------------------------
|
|
113
132
|
// normalizeSessionId
|
|
114
133
|
// ---------------------------------------------------------------------------
|
package/dist/src/vectorStore.js
CHANGED
|
@@ -20,6 +20,8 @@ import { mmrRerank } from "./dedup/mmr.js";
|
|
|
20
20
|
import { topK } from "./dedup/topk.js";
|
|
21
21
|
import { openBloom, saveBloom } from "./store/bloom.js";
|
|
22
22
|
import { listCheckpoints, nextCheckpointId, upsertCheckpoint, loadSessionState, saveSessionState, upsertMinhashSignature, insertLshBuckets, lshCandidateChunks, setDedupStatus, addTokensSaved, getDedupStats, bumpDedupStats, repoStats as repoStatsFromStore, dataInvariantStats, } from "./store/sqlite.js";
|
|
23
|
+
import { rehydrateRaptorTree } from "./dedup/raptor/index.js";
|
|
24
|
+
import { stagedExpansion } from "./dedup/raptor/retrieval.js";
|
|
23
25
|
import { migrateJsonToSqlite } from "./store/migrate.js";
|
|
24
26
|
/** Default L2 semantic-dedup enable flag (trigram embedder is local, zero-network). */
|
|
25
27
|
export const L2_ENABLED = true;
|
|
@@ -242,7 +244,7 @@ export class VectorStore {
|
|
|
242
244
|
contentHash2: digest.contentHash2,
|
|
243
245
|
contentHashVersion: digest.contentHashVersion,
|
|
244
246
|
normalizedText: digest.normalizedText,
|
|
245
|
-
compressedOriginal: compressSmart(Buffer.from(input.regionText, "utf-8")),
|
|
247
|
+
compressedOriginal: compressSmart(Buffer.from(input.regionText, "utf-8"), input.compressionPressure),
|
|
246
248
|
embedding,
|
|
247
249
|
timestamp: input.timestamp,
|
|
248
250
|
};
|
|
@@ -339,6 +341,27 @@ export class VectorStore {
|
|
|
339
341
|
// (Sprint 14 flag), returning the plain relevance-ranked window instead.
|
|
340
342
|
if (!this.cfg.L2_ENABLED)
|
|
341
343
|
return window.slice(0, k);
|
|
344
|
+
// Fix D: when RAPTOR is promoted, ALSO recall high-level tree summaries and
|
|
345
|
+
// merge them with the flat hits via MMR so RAPTOR + flat don't double-cover.
|
|
346
|
+
// RAPTOR returns fewer, broader hits (O(log n) high-level nodes) than the
|
|
347
|
+
// O(n) flat leaves, tightening the block at read time.
|
|
348
|
+
if (this.cfg.RAPTOR_ENABLED) {
|
|
349
|
+
const raptorHits = this.raptorSearchHits(sid, query, k);
|
|
350
|
+
if (raptorHits.length > 0) {
|
|
351
|
+
const merged = [...window];
|
|
352
|
+
for (const rh of raptorHits) {
|
|
353
|
+
if (!merged.some((m) => m.checkpoint.checkpointId === rh.checkpoint.checkpointId)) {
|
|
354
|
+
merged.push(rh);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const mmrItems = merged.map((h) => ({
|
|
358
|
+
item: h,
|
|
359
|
+
vector: h.checkpoint.embedding,
|
|
360
|
+
relevance: h.score,
|
|
361
|
+
}));
|
|
362
|
+
return mmrRerank(mmrItems, k, this.cfg.MMR_LAMBDA);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
342
365
|
const mmrItems = window.map((h) => ({
|
|
343
366
|
item: h,
|
|
344
367
|
vector: h.checkpoint.embedding,
|
|
@@ -347,6 +370,38 @@ export class VectorStore {
|
|
|
347
370
|
const ranked = mmrRerank(mmrItems, k, this.cfg.MMR_LAMBDA);
|
|
348
371
|
return ranked;
|
|
349
372
|
}
|
|
373
|
+
/**
|
|
374
|
+
* Serve the RAPTOR tree for a query (Fix D): rehydrate the persisted tree and
|
|
375
|
+
* return its staged-expansion leaf hits as SearchHits. Returns [] when no tree
|
|
376
|
+
* exists (small sessions — flat search remains the path). Best-effort/non-fatal.
|
|
377
|
+
*/
|
|
378
|
+
raptorSearchHits(sid, query, k) {
|
|
379
|
+
try {
|
|
380
|
+
const tree = rehydrateRaptorTree(sid, this.stateDir);
|
|
381
|
+
if (!tree || !tree.rootId)
|
|
382
|
+
return [];
|
|
383
|
+
const leafIds = stagedExpansion(query, tree, {
|
|
384
|
+
embedder: this.embedder,
|
|
385
|
+
k,
|
|
386
|
+
topM: this.cfg.RAPTOR_CLUSTERS_PER_LEVEL,
|
|
387
|
+
mmrLambda: this.cfg.MMR_LAMBDA,
|
|
388
|
+
});
|
|
389
|
+
if (leafIds.length === 0)
|
|
390
|
+
return [];
|
|
391
|
+
const all = listCheckpoints(sid, this.stateDir).filter((cp) => cp.dedupStatus !== "removed");
|
|
392
|
+
const qv = this.embedder.embed(query);
|
|
393
|
+
const hits = [];
|
|
394
|
+
for (const id of leafIds) {
|
|
395
|
+
const cp = all.find((c) => c.checkpointId === id);
|
|
396
|
+
if (cp)
|
|
397
|
+
hits.push({ checkpoint: cp, score: cosineSimilarity(qv, cp.embedding) });
|
|
398
|
+
}
|
|
399
|
+
return hits;
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
return [];
|
|
403
|
+
}
|
|
404
|
+
}
|
|
350
405
|
/**
|
|
351
406
|
* SemDeDup offline cleanup (Sprint 12, QA #17): within a session, mark the
|
|
352
407
|
* lower-quality row of any pair scoring cosine > `threshold` as
|
package/extensions/DASHBOARD.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
A lightweight local web dashboard for monitoring mega-compact's live state — compactions, context usage, checkpoints, and recall hits.
|
|
4
4
|
|
|
5
|
-
Uses Node built-in modules (`http`, `fs`, `path
|
|
6
|
-
|
|
5
|
+
Uses Node built-in modules (`http`, `fs`, `path`, `node:sqlite` — the project's
|
|
6
|
+
one-store DB backend) to read the machine-wide multi-repo index.
|
|
7
7
|
|
|
8
8
|
## Quick Start
|
|
9
9
|
|
|
@@ -74,7 +74,7 @@ The server runs as a detached child process, independent of the pi session. It:
|
|
|
74
74
|
- Auto-discovers the state directory from the `port.pid` file
|
|
75
75
|
- Cleans up stale `port.pid` files from dead processes
|
|
76
76
|
- Supports `SIGTERM`/`SIGINT` for graceful shutdown
|
|
77
|
-
- Serves static HTML; reads the multi-repo index from SQLite (`
|
|
77
|
+
- Serves static HTML; reads the multi-repo index from SQLite (`node:sqlite`)
|
|
78
78
|
|
|
79
79
|
## Browser UI
|
|
80
80
|
|