pi-mega-compact 0.4.4 → 0.4.6

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.
Files changed (69) hide show
  1. package/dist/extensions/dashboard-server.js +450 -0
  2. package/dist/extensions/dashboard-server.test.js +111 -0
  3. package/dist/extensions/error-patterns.js +115 -0
  4. package/dist/extensions/mega-compact.js +782 -0
  5. package/dist/extensions/mega-compact.test.js +328 -0
  6. package/dist/extensions/openclaw-mega-compact.js +291 -0
  7. package/dist/src/adapt.js +106 -0
  8. package/dist/src/boundary.js +88 -0
  9. package/dist/src/boundary.test.js +53 -0
  10. package/dist/src/canary.js +118 -0
  11. package/dist/src/compact.js +250 -0
  12. package/dist/src/compact.test.js +78 -0
  13. package/dist/src/config/dedup.js +81 -0
  14. package/dist/src/config.js +12 -0
  15. package/dist/src/dedup/dedup.test.js +41 -0
  16. package/dist/src/dedup/digest.js +30 -0
  17. package/dist/src/dedup/l1-lsh.js +52 -0
  18. package/dist/src/dedup/l1-minhash.js +91 -0
  19. package/dist/src/dedup/l1-verify.js +54 -0
  20. package/dist/src/dedup/l1.test.js +50 -0
  21. package/dist/src/dedup/mmr.js +45 -0
  22. package/dist/src/dedup/normalize.js +39 -0
  23. package/dist/src/dedup/raptor/guardrails.js +83 -0
  24. package/dist/src/dedup/raptor/index.js +94 -0
  25. package/dist/src/dedup/raptor/kmeans.js +152 -0
  26. package/dist/src/dedup/raptor/raptor.test.js +205 -0
  27. package/dist/src/dedup/raptor/retrieval.js +81 -0
  28. package/dist/src/dedup/raptor/summarizer.js +85 -0
  29. package/dist/src/dedup/raptor/tree.js +177 -0
  30. package/dist/src/dedup/sprint12.test.js +219 -0
  31. package/dist/src/dedup/topk.js +60 -0
  32. package/dist/src/dedup-engine.test.js +447 -0
  33. package/dist/src/e2e.test.js +698 -0
  34. package/dist/src/embedder.js +102 -0
  35. package/dist/src/engine.js +137 -0
  36. package/dist/src/engine.test.js +111 -0
  37. package/dist/src/extractive.js +209 -0
  38. package/dist/src/extractive.test.js +130 -0
  39. package/dist/src/httpEmbedder.js +143 -0
  40. package/dist/src/log.js +47 -0
  41. package/dist/src/log.test.js +42 -0
  42. package/dist/src/minilm.js +92 -0
  43. package/dist/src/monitoring.js +131 -0
  44. package/dist/src/ratio.bench.test.js +897 -0
  45. package/dist/src/recall.integration.test.js +77 -0
  46. package/dist/src/recall.js +60 -0
  47. package/dist/src/recall.test.js +50 -0
  48. package/dist/src/sprint14.test.js +219 -0
  49. package/dist/src/store/backfill.js +189 -0
  50. package/dist/src/store/bloom.js +114 -0
  51. package/dist/src/store/compression.js +177 -0
  52. package/dist/src/store/compression.test.js +67 -0
  53. package/dist/src/store/integrity.js +44 -0
  54. package/dist/src/store/migrate.js +79 -0
  55. package/dist/src/store/migrate.test.js +139 -0
  56. package/dist/src/store/sprint10.test.js +186 -0
  57. package/dist/src/store/sqlite.js +574 -0
  58. package/dist/src/store.js +115 -0
  59. package/dist/src/store.test.js +142 -0
  60. package/dist/src/supersede.js +68 -0
  61. package/dist/src/supersede.test.js +36 -0
  62. package/dist/src/tokens.js +31 -0
  63. package/dist/src/types.js +8 -0
  64. package/dist/src/types.test.js +9 -0
  65. package/dist/src/vectorStore.js +465 -0
  66. package/dist/src/vectorStore.test.js +479 -0
  67. package/dist/src/wordpiece.js +129 -0
  68. package/extensions/mega-compact.ts +9 -3
  69. package/package.json +4 -2
@@ -0,0 +1,114 @@
1
+ /**
2
+ * bloom.ts — local bloom-filter accelerator for the L0 content-hash dedup tier
3
+ * (Sprint 10).
4
+ *
5
+ * ACCELERATOR ONLY (QA #2 spirit, re-mapped locally): a bloom filter has zero
6
+ * false negatives — a MISS truly means "this content_hash is not present", so we
7
+ * can skip the full SQLite scan on the happy path. A HIT is only a candidate and
8
+ * MUST be confirmed by a SELECT against SQLite, which remains the source of truth
9
+ * (PREVENT-PI-004: in-process, no network; SQLite owns durability).
10
+ *
11
+ * The filter is an in-memory `bloom-filters` Map persisted to
12
+ * `STATE_DIR/bloom.json.gz` so a fresh VectorStore over the same dir reuses the
13
+ * warm filter instead of rebuilding from a scan.
14
+ */
15
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { getStateDir } from "../store.js";
18
+ import { compressSmart, decompressSmart } from "../store.js";
19
+ // Fixed bit-array size + hash count sized for a 1K-checkpoint fixture at <1% FP
20
+ // (m ≈ -n*ln(p)/ln(2)^2). 8 KiB bits → ~8192 bits, k=7 → well under 1% at 1K.
21
+ const BITS = 8192;
22
+ const HASHES = 7;
23
+ const STORAGE_MARK = 0x42; // 'B' — marks a persisted bloom blob (not versioned)
24
+ function fnv1a(data, seed) {
25
+ let h = 0x811c9dc5 ^ seed;
26
+ for (let i = 0; i < data.length; i++) {
27
+ h ^= data[i];
28
+ h = Math.imul(h, 0x01000193);
29
+ }
30
+ return h >>> 0;
31
+ }
32
+ export class BloomFilter {
33
+ bits;
34
+ constructor(bits) {
35
+ this.bits = bits ?? new Uint8Array(BITS);
36
+ }
37
+ indices(key) {
38
+ const data = Buffer.from(key, "utf-8");
39
+ const idx = [];
40
+ for (let i = 0; i < HASHES; i++) {
41
+ // Double-hashing (Kirsch–Mitzenmacher) to derive k independent positions.
42
+ const h1 = fnv1a(data, 0x9e3779b1 * i);
43
+ const h2 = fnv1a(data, 0x85ebca77 * (i + 1));
44
+ idx.push((h1 + i * h2) % BITS);
45
+ }
46
+ return idx;
47
+ }
48
+ add(key) {
49
+ for (const i of this.indices(key))
50
+ this.bits[i >> 3] |= 1 << (i & 7);
51
+ }
52
+ /** A miss is definitive (zero false negatives): false ⇒ definitely absent. */
53
+ maybeHas(key) {
54
+ for (const i of this.indices(key)) {
55
+ if ((this.bits[i >> 3] & (1 << (i & 7))) === 0)
56
+ return false;
57
+ }
58
+ return true;
59
+ }
60
+ toBuffer() {
61
+ return Buffer.concat([Buffer.from([STORAGE_MARK]), Buffer.from(this.bits)]);
62
+ }
63
+ /** Raw bit array (for persistence). */
64
+ bytes() {
65
+ return this.bits;
66
+ }
67
+ static fromBuffer(buf) {
68
+ if (buf.length >= 1 && buf[0] === STORAGE_MARK) {
69
+ return new BloomFilter(Uint8Array.from(buf.subarray(1)));
70
+ }
71
+ // Legacy/compressed form: best-effort decompress.
72
+ try {
73
+ const raw = decompressSmart(buf);
74
+ return new BloomFilter(Uint8Array.from(raw));
75
+ }
76
+ catch {
77
+ return new BloomFilter();
78
+ }
79
+ }
80
+ }
81
+ const cache = new Map();
82
+ /** Load (or lazily create + cache) the bloom filter for a state dir. */
83
+ export function openBloom(stateDir = getStateDir()) {
84
+ const existing = cache.get(stateDir);
85
+ if (existing)
86
+ return existing;
87
+ const path = join(stateDir, "bloom.json.gz");
88
+ let filter = new BloomFilter();
89
+ if (existsSync(path)) {
90
+ try {
91
+ filter = BloomFilter.fromBuffer(readFileSync(path));
92
+ }
93
+ catch {
94
+ filter = new BloomFilter();
95
+ }
96
+ }
97
+ cache.set(stateDir, filter);
98
+ return filter;
99
+ }
100
+ /** Persist the bloom filter to disk (additive — does not clear the cache). */
101
+ export function saveBloom(stateDir = getStateDir()) {
102
+ const filter = cache.get(stateDir);
103
+ if (!filter)
104
+ return;
105
+ if (!existsSync(stateDir))
106
+ mkdirSync(stateDir, { recursive: true });
107
+ // Compress the raw bit array for a smaller, versioned-on-disk footprint.
108
+ const blob = compressSmart(Buffer.from(filter.bytes()));
109
+ writeFileSync(join(stateDir, "bloom.json.gz"), blob);
110
+ }
111
+ /** Evict the cached filter (test teardown only). */
112
+ export function closeBloom(stateDir) {
113
+ cache.delete(stateDir);
114
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * compression.ts — versioned, size-adaptive compression for checkpoint blobs.
3
+ *
4
+ * Extracted from store.ts (Sprint 8). Two coordinated compressors:
5
+ *
6
+ * 1. `compressSmart` / `decompressSmart` — SYNCHRONOUS, zlib-based. Used by the
7
+ * VectorStore write path (which must stay synchronous — see Sprint 8 plan:
8
+ * better-sqlite3 replaced PGlite precisely to avoid an async cascade).
9
+ *
10
+ * 2. `compressZstd` / `decompressZstd` — ASYNCHRONOUS, via @mongodb-js/zstd.
11
+ * Optional, used for DR-export / large-blob paths where an await is fine.
12
+ *
13
+ * FORMAT-VERSION PROBLEM (root cause of Sprint 8):
14
+ * store.ts shipped `0x03` = brotli (legacy single-tag format). PLAN.md reassigns
15
+ * `0x03` → zstd, which would corrupt every existing checkpoint file. We fix this
16
+ * with a 2-byte magic header on the NEW format so the tag byte is namespaced and
17
+ * can never collide with legacy payloads:
18
+ *
19
+ * NEW (versioned): 0xEC 0x01 [TIER_TAG] [payload]
20
+ * LEGACY single-tag: [TIER_TAG] [payload] (tags 0x00..0x03)
21
+ * LEGACY untagged: 0x1f ... (plain gzip magic)
22
+ *
23
+ * `0xEC` is chosen because it collides with no zlib output: gzip magic is 0x1f,
24
+ * brotli streams start 0xCE/0xCF, zlib/deflate streams start 0x78/0x05/0x03.
25
+ * decompressSmart detects the magic first, so all three eras roundtrip together.
26
+ */
27
+ import { gzipSync, gunzipSync, brotliCompressSync, brotliDecompressSync, constants as zlibConstants, } from "node:zlib";
28
+ import zstd from "@mongodb-js/zstd";
29
+ // --- Versioned format markers ----------------------------------------------
30
+ const MAGIC_HI = 0xec;
31
+ const MAGIC_LO = 0x01; // format version 1
32
+ // Tier tags (only meaningful inside the 0xEC 0x01 versioned frame).
33
+ const TAG_RAW = 0x00; // no compression (< 512 bytes)
34
+ const TAG_GZIP_1 = 0x01; // gzip level 1 (fast, 512B–4KB)
35
+ const TAG_GZIP_6 = 0x02; // gzip level 6 (default, 4KB–32KB)
36
+ const TAG_BROTLI_4 = 0x05; // brotli level 4 (> 32KB, best text ratio, sync)
37
+ // Reserved for the async zstd helper (see compressZstd). Not used by the sync path.
38
+ const TAG_ZSTD_3 = 0x03;
39
+ const TAG_ZSTD_9 = 0x04;
40
+ /** Gzip magic byte — used to detect legacy untagged files. */
41
+ const GZIP_MAGIC = 0x1f;
42
+ const SIZE_TINY = 512;
43
+ const SIZE_SMALL = 4096;
44
+ const SIZE_MEDIUM = 32768;
45
+ function header(ver, tag) {
46
+ return Buffer.from([MAGIC_HI, MAGIC_LO, ver, tag]);
47
+ }
48
+ /**
49
+ * Compress synchronously using the best zlib tier for the payload size.
50
+ *
51
+ * Tiers (all synchronous — no network, no async, PREVENT-PI-004):
52
+ * < 512 B → raw (tag 0x00)
53
+ * 512B–4KB → gzip level 1 (tag 0x01)
54
+ * 4KB–32KB → gzip level 6 (tag 0x02)
55
+ * > 32 KB → brotli 4 (tag 0x05)
56
+ *
57
+ * Writes the versioned header so readers disambiguate from legacy blobs.
58
+ */
59
+ export function compressSmart(data) {
60
+ const len = data.length;
61
+ if (len < SIZE_TINY) {
62
+ return Buffer.concat([header(1, TAG_RAW), data]);
63
+ }
64
+ if (len < SIZE_SMALL) {
65
+ return Buffer.concat([header(1, TAG_GZIP_1), gzipSync(data, { level: 1 })]);
66
+ }
67
+ if (len < SIZE_MEDIUM) {
68
+ return Buffer.concat([header(1, TAG_GZIP_6), gzipSync(data, { level: 6 })]);
69
+ }
70
+ const compressed = brotliCompressSync(data, {
71
+ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 },
72
+ });
73
+ return Buffer.concat([header(1, TAG_BROTLI_4), compressed]);
74
+ }
75
+ /** True when `buf` is a versioned-format blob (0xEC 0x01 …). */
76
+ export function isVersioned(buf) {
77
+ return buf.length >= 2 && buf[0] === MAGIC_HI && buf[1] === MAGIC_LO;
78
+ }
79
+ export function detectFormat(buf) {
80
+ if (isVersioned(buf))
81
+ return "versioned";
82
+ if (buf[0] === GZIP_MAGIC)
83
+ return "legacy-gzip";
84
+ // Legacy single-tag: first byte is a known legacy tag.
85
+ if (buf[0] === 0x00 || buf[0] === 0x01 || buf[0] === 0x02 || buf[0] === 0x03) {
86
+ return "legacy-tag";
87
+ }
88
+ return "unknown";
89
+ }
90
+ /**
91
+ * Decompress a buffer written by `compressSmart` (versioned) OR any legacy
92
+ * format still on disk (legacy single-tag, legacy untagged gzip). SYNCHRONOUS.
93
+ *
94
+ * Throws on zstd blobs — those must go through the async `decompressZstd`,
95
+ * because zstd decompression cannot be awaited inside this sync path.
96
+ */
97
+ export function decompressSmart(buf) {
98
+ if (buf.length === 0)
99
+ return buf;
100
+ // New versioned format — dispatch on the namespaced tier tag.
101
+ if (isVersioned(buf)) {
102
+ const tag = buf[3];
103
+ const payload = buf.subarray(4);
104
+ switch (tag) {
105
+ case TAG_RAW:
106
+ return payload;
107
+ case TAG_GZIP_1:
108
+ case TAG_GZIP_6:
109
+ return gunzipSync(payload);
110
+ case TAG_BROTLI_4:
111
+ return brotliDecompressSync(payload);
112
+ case TAG_ZSTD_3:
113
+ case TAG_ZSTD_9:
114
+ throw new Error("decompressSmart cannot read zstd blobs (async only) — use decompressZstd");
115
+ default:
116
+ throw new Error(`decompressSmart: unknown versioned tier tag 0x${tag.toString(16)}`);
117
+ }
118
+ }
119
+ // Legacy untagged gzip file (old writeGzJson with no tag byte).
120
+ if (buf[0] === GZIP_MAGIC) {
121
+ return gunzipSync(buf);
122
+ }
123
+ // Legacy single-tag format (store.ts v0.1.0): tags 0x00..0x03.
124
+ const tag = buf[0];
125
+ const payload = buf.subarray(1);
126
+ switch (tag) {
127
+ case 0x00: // TAG_RAW (legacy)
128
+ return payload;
129
+ case 0x01: // TAG_GZIP_1 (legacy)
130
+ case 0x02: // TAG_GZIP_6 (legacy)
131
+ return gunzipSync(payload);
132
+ case 0x03: // TAG_BROTLI (legacy) — the very collision this format fixes
133
+ return brotliDecompressSync(payload);
134
+ default:
135
+ // Unknown legacy tag — last-ditch try plain gzip.
136
+ return gunzipSync(buf);
137
+ }
138
+ }
139
+ // --- Optional async zstd path (DR export / large blobs) --------------------
140
+ // Self-describing: own 2-byte marker so it never routes through decompressSmart.
141
+ const ZSTD_MAGIC_HI = 0x5a; // 'Z'
142
+ const ZSTD_MAGIC_LO = 0x53; // 'S'
143
+ async function compressZstdWithLevel(data, level) {
144
+ const compressed = await zstd.compress(data, level);
145
+ return Buffer.concat([Buffer.from([ZSTD_MAGIC_HI, ZSTD_MAGIC_LO]), compressed]);
146
+ }
147
+ /** Compress with zstd level 3 (fast, balanced). Async. */
148
+ export function compressZstd(data) {
149
+ return compressZstdWithLevel(data, 3);
150
+ }
151
+ /** Compress with zstd level 9 (max ratio). Async. */
152
+ export function compressZstdMax(data) {
153
+ return compressZstdWithLevel(data, 9);
154
+ }
155
+ /** True when a buffer is a zstd-compressed blob from this helper. */
156
+ export function isZstd(buf) {
157
+ return buf.length >= 2 && buf[0] === ZSTD_MAGIC_HI && buf[1] === ZSTD_MAGIC_LO;
158
+ }
159
+ /** Decompress a zstd blob produced by compressZstd/compressZstdMax. Async. */
160
+ export async function decompressZstd(buf) {
161
+ if (buf.length === 0)
162
+ return buf;
163
+ if (!isZstd(buf)) {
164
+ throw new Error("decompressZstd: buffer is not a zstd blob (missing ZS marker)");
165
+ }
166
+ return zstd.decompress(buf.subarray(2));
167
+ }
168
+ /**
169
+ * Decompress anything we can WITHOUT awaiting: versioned + legacy zlib formats.
170
+ * zstd blobs are detected and reported (not thrown blindly) so callers can
171
+ * decide whether to await decompressZstd.
172
+ */
173
+ export function decompressSyncAuto(buf) {
174
+ if (isZstd(buf))
175
+ return { data: buf, isZstd: true };
176
+ return { data: decompressSmart(buf), isZstd: false };
177
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * compression.test.ts — versioned compression tiers + backward compatibility.
3
+ *
4
+ * Proves Sprint 8's root-cause fix: the 0x03 tag collision is impossible because
5
+ * new blobs carry a 2-byte version magic, and legacy blobs (untagged gzip, legacy
6
+ * single-tag incl. the old 0x03=brotli) still decompress.
7
+ */
8
+ import { test } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { gzipSync, brotliCompressSync } from "node:zlib";
11
+ import { compressSmart, decompressSmart, compressZstd, decompressZstd, isVersioned, detectFormat, decompressSyncAuto, } from "./compression.js";
12
+ const buf = (s, n) => Buffer.from(s.repeat(n));
13
+ test("versioned format: all size tiers roundtrip and are versioned", () => {
14
+ const cases = [
15
+ { label: "tiny", data: buf("x", 100), expectTag: 0x00 },
16
+ { label: "small", data: buf("a", 600), expectTag: 0x01 },
17
+ { label: "medium", data: buf("b", 8000), expectTag: 0x02 },
18
+ { label: "large", data: buf("c", 40000), expectTag: 0x05 },
19
+ ];
20
+ for (const { label, data, expectTag } of cases) {
21
+ const c = compressSmart(data);
22
+ // New 2-byte magic header present.
23
+ assert.ok(isVersioned(c), `${label}: should be versioned (0xEC 0x01)`);
24
+ assert.equal(c[0], 0xec, `${label}: magic hi`);
25
+ assert.equal(c[1], 0x01, `${label}: magic lo (version)`);
26
+ assert.equal(c[2], 0x01, `${label}: format version 1`);
27
+ assert.equal(c[3], expectTag, `${label}: tier tag ${expectTag.toString(16)}`);
28
+ // Roundtrips exactly.
29
+ assert.deepEqual(decompressSmart(c), data, `${label}: roundtrip`);
30
+ }
31
+ });
32
+ test("large tier actually compresses better than raw (brotli 0x05)", () => {
33
+ const data = buf("this is a long summary of a coding session. ", 900);
34
+ const c = compressSmart(data);
35
+ assert.ok(c.length < data.length, "compressed smaller than raw");
36
+ assert.equal(c[3], 0x05, "tag is brotli-4");
37
+ assert.deepEqual(decompressSmart(c), data);
38
+ });
39
+ test("legacy untagged gzip (0x1f magic) still decompresses", () => {
40
+ const data = Buffer.from(JSON.stringify({ legacy: true }));
41
+ const legacyGzip = gzipSync(data); // no tag byte, starts with 0x1f
42
+ assert.equal(legacyGzip[0], 0x1f, "gzip magic present");
43
+ assert.equal(detectFormat(legacyGzip), "legacy-gzip");
44
+ assert.deepEqual(JSON.parse(decompressSmart(legacyGzip).toString()), { legacy: true });
45
+ });
46
+ test("legacy single-tag 0x03=brotli (the collision case) still decompresses", () => {
47
+ const data = buf("legacy brotli payload ", 200);
48
+ // Reconstruct the EXACT v0.1.0 legacy brotli frame: tag 0x03 + brotli payload.
49
+ const legacy = Buffer.concat([Buffer.from([0x03]), brotliCompressSync(data)]);
50
+ assert.equal(detectFormat(legacy), "legacy-tag");
51
+ assert.deepEqual(decompressSmart(legacy), data, "legacy 0x03 brotli roundtrips");
52
+ });
53
+ test("detectFormat classifies all eras", () => {
54
+ assert.equal(detectFormat(compressSmart(buf("q", 700))), "versioned");
55
+ assert.equal(detectFormat(gzipSync(buf("q", 10))), "legacy-gzip");
56
+ assert.equal(detectFormat(Buffer.from([0x00, 1, 2, 3])), "legacy-tag");
57
+ assert.equal(detectFormat(Buffer.from([0x99, 0x88])), "unknown");
58
+ });
59
+ test("zstd helper roundtrips (async) and is not sync-decoded", async () => {
60
+ const data = buf("zstd dr export payload ", 1500);
61
+ const c = await compressZstd(data);
62
+ assert.ok(c.length < data.length, "zstd compresses");
63
+ // decompressSyncAuto reports zstd without throwing (caller awaits decompressZstd).
64
+ const auto = decompressSyncAuto(c);
65
+ assert.equal(auto.isZstd, true, "flagged as zstd");
66
+ assert.deepEqual(await decompressZstd(c), data, "zstd roundtrip");
67
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * integrity.ts — post-backfill / audit integrity checks (Sprint 10).
3
+ *
4
+ * Two checks (QA #1 / QA #14 spirit, re-mapped locally):
5
+ * 1. Sentinel vs recomputed: the `session_state.stored_region_hashes` set must
6
+ * equal the set of `region_hash` values recomputed from `context_chunks`.
7
+ * A mismatch flags a tampered / stale sentinel (so the dedup sentinel can't
8
+ * miss a real duplicate).
9
+ * 2. Orphan id detection: any `injected_checkpoint_ids` entry that does not
10
+ * correspond to a real `context_chunks.id` is orphaned and flagged.
11
+ *
12
+ * Pure read-only verification — never mutates; returns a structured report.
13
+ * SQLite is the source of truth; no network (PREVENT-PI-004).
14
+ */
15
+ import { openStore, listCheckpoints, loadSessionState } from "./sqlite.js";
16
+ import { getStateDir, normalizeSessionId } from "../store.js";
17
+ /** Verify one session's sentinel set + injected-id integrity. */
18
+ export function checkSessionIntegrity(sessionId, stateDir = getStateDir()) {
19
+ openStore(stateDir); // ensure schema is initialized for this state dir
20
+ const sid = normalizeSessionId(sessionId);
21
+ const state = loadSessionState(sessionId, stateDir);
22
+ const checkpoints = listCheckpoints(sessionId, stateDir);
23
+ // Recompute the region-hash set from the checkpoint rows (source of truth).
24
+ const recomputed = new Set(checkpoints.map((c) => c.regionHash).filter(Boolean));
25
+ const stored = new Set(state.storedRegionHashes);
26
+ const regionHashMismatch = recomputed.size !== stored.size || [...recomputed].some((h) => !stored.has(h));
27
+ // Orphan injected ids: referenced but no matching checkpoint.
28
+ const validIds = new Set(checkpoints.map((c) => c.checkpointId));
29
+ const orphanInjectedIds = state.injectedCheckpointIds.filter((id) => !validIds.has(id));
30
+ return {
31
+ sessionId: sid,
32
+ ok: !regionHashMismatch && orphanInjectedIds.length === 0,
33
+ storedRegionHashes: stored.size,
34
+ recomputedRegionHashes: recomputed.size,
35
+ regionHashMismatch,
36
+ orphanInjectedIds,
37
+ };
38
+ }
39
+ /** Check every session present in the store. */
40
+ export function checkAllIntegrity(stateDir = getStateDir()) {
41
+ const db = openStore(stateDir);
42
+ const rows = db.prepare("SELECT DISTINCT session_id FROM context_chunks").all();
43
+ return rows.map((r) => checkSessionIntegrity(r.session_id, stateDir));
44
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * migrate.ts — Sprint 8: bring v0.1.0 JSON checkpoint files into SQLite.
3
+ *
4
+ * Reads every `<sessionId>.checkpoints.json.gz` in the state dir, computes the
5
+ * dedup-tier columns (content_hash / content_hash2 / normalized_text) that
6
+ * Sprints 9-12 match on, and upserts into context_chunks idempotently
7
+ * (ON CONFLICT id DO NOTHING — re-running is a no-op). The JSON files are kept
8
+ * as disaster-recovery snapshots; they are never deleted.
9
+ *
10
+ * Runs on first VectorStore construction (auto-migrate) and is also exposed for
11
+ * the integration test to call explicitly.
12
+ */
13
+ import { createHash } from "node:crypto";
14
+ import { existsSync, readdirSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { getStateDir, readGzJson, normalizeSessionId } from "../store.js";
17
+ import { openStore, upsertCheckpoint, listCheckpoints } from "./sqlite.js";
18
+ /** Scan a state dir for v0.1.0 checkpoint JSON files. */
19
+ function legacyCheckpointFiles(stateDir) {
20
+ if (!existsSync(stateDir))
21
+ return [];
22
+ return readdirSync(stateDir).filter((f) => f.endsWith(".checkpoints.json.gz"));
23
+ }
24
+ /** Derive the normalized text + two content hashes for the dedup tiers. */
25
+ export function deriveContentHashes(cp) {
26
+ // L0/L1 basis: the human summary (whitespace-normalized).
27
+ const normalized = (cp.summary ?? "").replace(/\s+/g, " ").trim();
28
+ const contentHash = createHash("sha256").update(normalized).digest("hex");
29
+ // L2 basis: summary + extractive topic summary (catches paraphrased topics).
30
+ const basis2 = `${normalized}\n${cp.topicSummary ?? ""}`.trim();
31
+ const contentHash2 = createHash("sha256").update(basis2).digest("hex");
32
+ return { normalizedText: normalized, contentHash, contentHash2 };
33
+ }
34
+ /** Read a single legacy checkpoint file (lossless: returns every stored field). */
35
+ export function readLegacyCheckpointFile(sessionId, stateDir = getStateDir()) {
36
+ const file = join(stateDir, `${normalizeSessionId(sessionId)}.checkpoints.json.gz`);
37
+ return readGzJson(file, []);
38
+ }
39
+ /**
40
+ * Migrate all legacy JSON checkpoint files in `stateDir` into SQLite.
41
+ * Idempotent — safe to call repeatedly. Does not delete JSON files.
42
+ */
43
+ export function migrateJsonToSqlite(stateDir = getStateDir()) {
44
+ openStore(stateDir); // ensures schema exists
45
+ const files = legacyCheckpointFiles(stateDir);
46
+ let sessionsScanned = 0;
47
+ let migrated = 0;
48
+ let alreadyPresent = 0;
49
+ for (const file of files) {
50
+ // File name shape: <sessionId>.checkpoints.json.gz
51
+ const sessionId = file.replace(/\.checkpoints\.json\.gz$/, "");
52
+ const cps = readLegacyCheckpointFile(sessionId, stateDir);
53
+ if (cps.length === 0)
54
+ continue;
55
+ sessionsScanned++;
56
+ const existing = new Set(listCheckpoints(sessionId, stateDir).map((c) => c.checkpointId));
57
+ for (const cp of cps) {
58
+ if (existing.has(cp.checkpointId)) {
59
+ alreadyPresent++;
60
+ continue;
61
+ }
62
+ const { normalizedText, contentHash, contentHash2 } = deriveContentHashes(cp);
63
+ upsertCheckpoint({ ...cp, summary: cp.summary ?? "", regionHash: cp.regionHash ?? "" }, stateDir);
64
+ // Persist the extra dedup columns (upsertCheckpoint sets them null).
65
+ setContentHashes(cp.checkpointId, contentHash, contentHash2, normalizedText, stateDir);
66
+ migrated++;
67
+ }
68
+ }
69
+ return { sessionsScanned, checkpointsMigrated: migrated, alreadyPresent };
70
+ }
71
+ // Direct column update for the computed hashes (kept out of upsertCheckpoint's
72
+ // hot path so the common write doesn't pay for hashing).
73
+ function setContentHashes(checkpointId, contentHash, contentHash2, normalizedText, stateDir) {
74
+ const Database = openStore(stateDir);
75
+ Database.prepare(`UPDATE context_chunks
76
+ SET content_hash = @ch, content_hash2 = @ch2,
77
+ content_hash_version = 1, normalized_text = @nt
78
+ WHERE id = @id`).run({ id: checkpointId, ch: contentHash, ch2: contentHash2, nt: normalizedText });
79
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * migrate + recall integration test — Sprint 8 acceptance proofs.
3
+ *
4
+ * 1. Migration is lossless: a v0.1.0 `<sess>.checkpoints.json.gz` roundtrips
5
+ * into SQLite with checkpoint count + regionHash set identical, and the JSON
6
+ * file is retained as a DR snapshot.
7
+ * 2. Cross-process recall: compact in one VectorStore, then recall via a FRESH
8
+ * VectorStore over the SAME stateDir (re-opens the same sqlite.db file) — the
9
+ * checkpoint must reappear. Mirrors Sprint 6.1's durability requirement.
10
+ *
11
+ * Uses MEGACOMPACT_STATE_DIR overrides; never the real user state dir.
12
+ */
13
+ import { test } from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { mkdtempSync, rmSync, existsSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { VectorStore } from "../vectorStore.js";
19
+ import { writeGzJson } from "../store.js";
20
+ import { migrateJsonToSqlite, readLegacyCheckpointFile } from "../store/migrate.js";
21
+ import { listCheckpoints, closeStore } from "../store/sqlite.js";
22
+ const baseTmp = mkdtempSync(join(tmpdir(), "mc-migrate-"));
23
+ let counter = 0;
24
+ function stateDir() {
25
+ return join(baseTmp, `run-${counter++}`);
26
+ }
27
+ function msgVec() {
28
+ // Deterministic 8-dim vector.
29
+ return [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8];
30
+ }
31
+ function fakeCheckpoints(sessionId) {
32
+ return [
33
+ {
34
+ checkpointId: "chkpt_001",
35
+ sessionId,
36
+ summary: "Investigated the vector store and added a cosine helper.",
37
+ topicSummary: "Added cosine similarity helper to vector store.",
38
+ summaryHash: "a1b2c3d4e5f6a7b8",
39
+ keyDecisions: ["use linear scan"],
40
+ nextSteps: ["add tests"],
41
+ filesModified: ["src/vectorStore.ts"],
42
+ tokenEstimate: 1200,
43
+ regionHash: "r1",
44
+ embedding: msgVec(),
45
+ timestamp: 1,
46
+ },
47
+ {
48
+ checkpointId: "chkpt_002",
49
+ sessionId,
50
+ summary: "Refactored the recall path to dedupe against the window.",
51
+ topicSummary: "Recall dedup against injected set.",
52
+ summaryHash: "b2c3d4e5f6a7b8c9",
53
+ keyDecisions: [],
54
+ nextSteps: [],
55
+ filesModified: ["src/recall.ts"],
56
+ tokenEstimate: 900,
57
+ regionHash: "r2",
58
+ embedding: msgVec().map((v) => v + 0.01),
59
+ timestamp: 2,
60
+ },
61
+ ];
62
+ }
63
+ test("migration: v0.1.0 JSON checkpoints migrate losslessly into SQLite", () => {
64
+ const dir = stateDir();
65
+ const sid = "sess_migrate_lossless";
66
+ // Write a legacy JSON checkpoint file (the format v0.1.0 shipped).
67
+ writeGzJson(join(dir, `${sid}.checkpoints.json.gz`), fakeCheckpoints(sid));
68
+ const result = migrateJsonToSqlite(dir);
69
+ assert.equal(result.sessionsScanned, 1);
70
+ assert.equal(result.checkpointsMigrated, 2);
71
+ assert.equal(result.alreadyPresent, 0);
72
+ // SQLite now has both checkpoints, regionHash preserved.
73
+ const migrated = listCheckpoints(sid, dir);
74
+ assert.equal(migrated.length, 2, "both checkpoints present");
75
+ assert.ok(migrated.every((c) => c.regionHash && c.regionHash.length > 0), "regionHash preserved");
76
+ assert.deepEqual(migrated.map((c) => c.checkpointId).sort(), ["chkpt_001", "chkpt_002"]);
77
+ // content_hash columns populated (needed by Sprint 9).
78
+ const legacy = readLegacyCheckpointFile(sid, dir);
79
+ assert.equal(legacy.length, 2, "legacy file intact (DR snapshot retained)");
80
+ assert.ok(existsSync(join(dir, `${sid}.checkpoints.json.gz`)), "JSON DR snapshot retained");
81
+ closeStore(dir);
82
+ });
83
+ test("migration: re-running is idempotent (no duplicates)", () => {
84
+ const dir = stateDir();
85
+ const sid = "sess_migrate_idem";
86
+ writeGzJson(join(dir, `${sid}.checkpoints.json.gz`), fakeCheckpoints(sid));
87
+ migrateJsonToSqlite(dir);
88
+ const r2 = migrateJsonToSqlite(dir);
89
+ assert.equal(r2.checkpointsMigrated, 0, "nothing new migrated");
90
+ assert.equal(r2.alreadyPresent, 2, "both counted as already present");
91
+ assert.equal(listCheckpoints(sid, dir).length, 2);
92
+ closeStore(dir);
93
+ });
94
+ test("cross-process recall: fresh VectorStore over same dir recalls prior checkpoint", () => {
95
+ const dir = stateDir();
96
+ const sid = "sess_xproc";
97
+ // Process A: compact a checkpoint into the store.
98
+ const a = new VectorStore({ stateDir: dir });
99
+ const added = a.add({
100
+ sessionId: sid,
101
+ summary: "Cross-process recall proof: persisted in process A.",
102
+ topicSummary: "Persisted checkpoint in process A.",
103
+ regionText: "cross process recall proof session A write path",
104
+ tokenEstimate: 500,
105
+ timestamp: 1,
106
+ });
107
+ assert.equal(added.deduped, false);
108
+ assert.equal(added.checkpoint.checkpointId, "chkpt_001");
109
+ // Force a clean reopen (simulates a new process opening the same file).
110
+ closeStore(dir);
111
+ // Process B: brand-new VectorStore, same stateDir.
112
+ const b = new VectorStore({ stateDir: dir });
113
+ const hits = b.search(sid, "cross process recall proof", 5);
114
+ assert.equal(hits.length, 1, "checkpoint survives cross-process reopen");
115
+ assert.equal(hits[0].checkpoint.checkpointId, "chkpt_001");
116
+ assert.ok(hits[0].score > 0.5, "recall is relevant");
117
+ closeStore(dir);
118
+ });
119
+ test("cross-process recall: injected state persists across reopen", () => {
120
+ const dir = stateDir();
121
+ const sid = "sess_xproc_inj";
122
+ const a = new VectorStore({ stateDir: dir });
123
+ const added = a.add({
124
+ sessionId: sid,
125
+ summary: "A checkpoint to inject and remember across processes.",
126
+ topicSummary: "Injected checkpoint.",
127
+ regionText: "injected state persists across process reopen test",
128
+ timestamp: 1,
129
+ });
130
+ a.markInjected(sid, added.checkpoint.checkpointId);
131
+ assert.equal(a.wasInjected(sid, added.checkpoint.checkpointId), true);
132
+ closeStore(dir);
133
+ const b = new VectorStore({ stateDir: dir });
134
+ assert.equal(b.wasInjected(sid, added.checkpoint.checkpointId), true, "injection remembered");
135
+ closeStore(dir);
136
+ });
137
+ test("cleanup", () => {
138
+ rmSync(baseTmp, { recursive: true, force: true });
139
+ });