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.
- package/dist/extensions/dashboard-server.js +450 -0
- package/dist/extensions/dashboard-server.test.js +111 -0
- package/dist/extensions/error-patterns.js +115 -0
- package/dist/extensions/mega-compact.js +782 -0
- package/dist/extensions/mega-compact.test.js +328 -0
- package/dist/extensions/openclaw-mega-compact.js +291 -0
- package/dist/src/adapt.js +106 -0
- package/dist/src/boundary.js +88 -0
- package/dist/src/boundary.test.js +53 -0
- package/dist/src/canary.js +118 -0
- package/dist/src/compact.js +250 -0
- package/dist/src/compact.test.js +78 -0
- package/dist/src/config/dedup.js +81 -0
- package/dist/src/config.js +12 -0
- package/dist/src/dedup/dedup.test.js +41 -0
- package/dist/src/dedup/digest.js +30 -0
- package/dist/src/dedup/l1-lsh.js +52 -0
- package/dist/src/dedup/l1-minhash.js +91 -0
- package/dist/src/dedup/l1-verify.js +54 -0
- package/dist/src/dedup/l1.test.js +50 -0
- package/dist/src/dedup/mmr.js +45 -0
- package/dist/src/dedup/normalize.js +39 -0
- package/dist/src/dedup/raptor/guardrails.js +83 -0
- package/dist/src/dedup/raptor/index.js +94 -0
- package/dist/src/dedup/raptor/kmeans.js +152 -0
- package/dist/src/dedup/raptor/raptor.test.js +205 -0
- package/dist/src/dedup/raptor/retrieval.js +81 -0
- package/dist/src/dedup/raptor/summarizer.js +85 -0
- package/dist/src/dedup/raptor/tree.js +177 -0
- package/dist/src/dedup/sprint12.test.js +219 -0
- package/dist/src/dedup/topk.js +60 -0
- package/dist/src/dedup-engine.test.js +447 -0
- package/dist/src/e2e.test.js +698 -0
- package/dist/src/embedder.js +102 -0
- package/dist/src/engine.js +137 -0
- package/dist/src/engine.test.js +111 -0
- package/dist/src/extractive.js +209 -0
- package/dist/src/extractive.test.js +130 -0
- package/dist/src/httpEmbedder.js +143 -0
- package/dist/src/log.js +47 -0
- package/dist/src/log.test.js +42 -0
- package/dist/src/minilm.js +92 -0
- package/dist/src/monitoring.js +131 -0
- package/dist/src/ratio.bench.test.js +897 -0
- package/dist/src/recall.integration.test.js +77 -0
- package/dist/src/recall.js +60 -0
- package/dist/src/recall.test.js +50 -0
- package/dist/src/sprint14.test.js +219 -0
- package/dist/src/store/backfill.js +189 -0
- package/dist/src/store/bloom.js +114 -0
- package/dist/src/store/compression.js +177 -0
- package/dist/src/store/compression.test.js +67 -0
- package/dist/src/store/integrity.js +44 -0
- package/dist/src/store/migrate.js +79 -0
- package/dist/src/store/migrate.test.js +139 -0
- package/dist/src/store/sprint10.test.js +186 -0
- package/dist/src/store/sqlite.js +574 -0
- package/dist/src/store.js +115 -0
- package/dist/src/store.test.js +142 -0
- package/dist/src/supersede.js +68 -0
- package/dist/src/supersede.test.js +36 -0
- package/dist/src/tokens.js +31 -0
- package/dist/src/types.js +8 -0
- package/dist/src/types.test.js +9 -0
- package/dist/src/vectorStore.js +465 -0
- package/dist/src/vectorStore.test.js +479 -0
- package/dist/src/wordpiece.js +129 -0
- package/extensions/mega-compact.ts +9 -3
- package/package.json +4 -2
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sqlite.ts — Sprint 8 storage backbone (the "one store").
|
|
3
|
+
*
|
|
4
|
+
* Replaces the per-session gzipped-JSON checkpoint files with a single local
|
|
5
|
+
* SQLite database (better-sqlite3, in-process, FS-backed, ZERO network calls —
|
|
6
|
+
* honors PREVENT-PI-004). Chosen over PGlite because PGlite is async-only in
|
|
7
|
+
* every published version, and VectorStore (engine.ts / recall.ts / the
|
|
8
|
+
* extension) is fully synchronous — adopting PGlite would have cascaded async
|
|
9
|
+
* through the whole call chain. SQLite keeps every VectorStore signature sync.
|
|
10
|
+
*
|
|
11
|
+
* FTS5 `trigram` tokenizer is created for the Sprint 9+ dedup tiers (MinHash/LSH
|
|
12
|
+
* / pg_trgm-equivalent verification). The default cosine path stays a linear
|
|
13
|
+
* scan over `embedding_blob` (checkpoint counts are small, no ANN index needed).
|
|
14
|
+
*
|
|
15
|
+
* All queries are parameterized (PREVENT-002) — never string-concatenated.
|
|
16
|
+
*/
|
|
17
|
+
import Database from "better-sqlite3";
|
|
18
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { getStateDir } from "../store.js";
|
|
21
|
+
import { normalizeSessionId } from "../store.js";
|
|
22
|
+
const SCHEMA_VERSION = 1;
|
|
23
|
+
/** Encode a float vector as a little-endian Float32 BLOB for cosine scanning. */
|
|
24
|
+
function encodeEmbedding(v) {
|
|
25
|
+
const buf = Buffer.allocUnsafe(v.length * 4);
|
|
26
|
+
for (let i = 0; i < v.length; i++)
|
|
27
|
+
buf.writeFloatLE(v[i] ?? 0, i * 4);
|
|
28
|
+
return buf;
|
|
29
|
+
}
|
|
30
|
+
/** Decode a Float32 BLOB back to a number[]. */
|
|
31
|
+
function decodeEmbedding(buf) {
|
|
32
|
+
if (!buf || buf.length === 0)
|
|
33
|
+
return [];
|
|
34
|
+
const n = buf.length / 4;
|
|
35
|
+
const out = new Array(n);
|
|
36
|
+
for (let i = 0; i < n; i++)
|
|
37
|
+
out[i] = buf.readFloatLE(i * 4);
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
function jsonText(v) {
|
|
41
|
+
return JSON.stringify(v ?? []);
|
|
42
|
+
}
|
|
43
|
+
// In-process cache so the same stateDir reuses one connection (and so a fresh
|
|
44
|
+
// VectorStore over the same dir shares the open DB). Cross-process durability
|
|
45
|
+
// comes from reopening the same file path — proven by the integration test.
|
|
46
|
+
const cache = new Map();
|
|
47
|
+
/** Open (or reuse) the SQLite store for a state dir. */
|
|
48
|
+
export function openStore(stateDir = getStateDir()) {
|
|
49
|
+
const existing = cache.get(stateDir);
|
|
50
|
+
if (existing)
|
|
51
|
+
return existing;
|
|
52
|
+
if (!existsSync(stateDir))
|
|
53
|
+
mkdirSync(stateDir, { recursive: true });
|
|
54
|
+
const db = new Database(join(stateDir, "sqlite.db"));
|
|
55
|
+
db.pragma("journal_mode = WAL");
|
|
56
|
+
db.pragma("foreign_keys = ON");
|
|
57
|
+
initSchema(db);
|
|
58
|
+
cache.set(stateDir, db);
|
|
59
|
+
return db;
|
|
60
|
+
}
|
|
61
|
+
function initSchema(db) {
|
|
62
|
+
db.exec(`
|
|
63
|
+
CREATE TABLE IF NOT EXISTS context_chunks (
|
|
64
|
+
id TEXT NOT NULL,
|
|
65
|
+
session_id TEXT NOT NULL,
|
|
66
|
+
region_hash TEXT,
|
|
67
|
+
content_hash TEXT,
|
|
68
|
+
content_hash2 TEXT,
|
|
69
|
+
content_hash_version INTEGER,
|
|
70
|
+
normalized_text TEXT,
|
|
71
|
+
summary TEXT,
|
|
72
|
+
topic_summary TEXT,
|
|
73
|
+
summary_hash TEXT,
|
|
74
|
+
key_decisions TEXT, -- JSON array
|
|
75
|
+
next_steps TEXT, -- JSON array
|
|
76
|
+
files_modified TEXT, -- JSON array
|
|
77
|
+
embedding_blob BLOB, -- float32 vector
|
|
78
|
+
token_estimate INTEGER,
|
|
79
|
+
original_token_estimate INTEGER, -- dropped region size (tokens saved = orig − stored)
|
|
80
|
+
timestamp INTEGER,
|
|
81
|
+
dedup_status TEXT DEFAULT 'active',
|
|
82
|
+
compressed_original BLOB -- optional DR copy
|
|
83
|
+
);
|
|
84
|
+
-- Primary key is (session_id, id): checkpoint ids are unique per session
|
|
85
|
+
-- (chkpt_001 per session), not globally, so a bare id PK would collide
|
|
86
|
+
-- across sessions on the nextCheckpointId sequence.
|
|
87
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_chunks_pk
|
|
88
|
+
ON context_chunks(session_id, id);
|
|
89
|
+
CREATE INDEX IF NOT EXISTS idx_chunks_session ON context_chunks(session_id);
|
|
90
|
+
CREATE INDEX IF NOT EXISTS idx_chunks_region ON context_chunks(region_hash);
|
|
91
|
+
CREATE INDEX IF NOT EXISTS idx_chunks_content ON context_chunks(content_hash);
|
|
92
|
+
-- Partial UNIQUE (QA #1): null content_hash rows never violate the constraint;
|
|
93
|
+
-- ON CONFLICT DO NOTHING makes backfill + L0 inserts safe.
|
|
94
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_content_hash
|
|
95
|
+
ON context_chunks(session_id, content_hash) WHERE content_hash IS NOT NULL;
|
|
96
|
+
|
|
97
|
+
-- Sprint 11: MinHash signature + LSH bucket tables for L1 near-dup dedup.
|
|
98
|
+
CREATE TABLE IF NOT EXISTS minhash_signatures (
|
|
99
|
+
chunk_id TEXT NOT NULL,
|
|
100
|
+
session_id TEXT NOT NULL,
|
|
101
|
+
signature_version INTEGER NOT NULL,
|
|
102
|
+
signatures TEXT NOT NULL, -- JSON array of 256 uint32
|
|
103
|
+
PRIMARY KEY (chunk_id, signature_version)
|
|
104
|
+
);
|
|
105
|
+
CREATE INDEX IF NOT EXISTS idx_minhash_session ON minhash_signatures(session_id);
|
|
106
|
+
|
|
107
|
+
CREATE TABLE IF NOT EXISTS dedup_lsh_buckets (
|
|
108
|
+
bucket_key TEXT NOT NULL,
|
|
109
|
+
chunk_id TEXT NOT NULL,
|
|
110
|
+
session_id TEXT NOT NULL,
|
|
111
|
+
signature_version INTEGER NOT NULL,
|
|
112
|
+
PRIMARY KEY (bucket_key, chunk_id)
|
|
113
|
+
);
|
|
114
|
+
CREATE INDEX IF NOT EXISTS idx_lsh_bucket ON dedup_lsh_buckets(bucket_key, session_id);
|
|
115
|
+
|
|
116
|
+
CREATE TABLE IF NOT EXISTS session_state (
|
|
117
|
+
session_id TEXT PRIMARY KEY,
|
|
118
|
+
injected_checkpoint_ids TEXT, -- JSON array
|
|
119
|
+
stored_region_hashes TEXT -- JSON array
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
123
|
+
key TEXT PRIMARY KEY,
|
|
124
|
+
value TEXT
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
-- Sprint 13 (RAPTOR): hierarchical summary tree nodes. children are a JSON
|
|
128
|
+
-- array of child node ids (or raw leaf ids at the bottom); embedding_blob
|
|
129
|
+
-- is the node centroid. Additive; retrieval ignores this table until
|
|
130
|
+
-- Sprint 14 promotes RAPTOR out of shadow mode.
|
|
131
|
+
CREATE TABLE IF NOT EXISTS raptor_nodes (
|
|
132
|
+
id TEXT NOT NULL,
|
|
133
|
+
session_id TEXT NOT NULL,
|
|
134
|
+
level INTEGER NOT NULL,
|
|
135
|
+
parent_id TEXT,
|
|
136
|
+
children TEXT, -- JSON array of child ids
|
|
137
|
+
summary TEXT,
|
|
138
|
+
embedding_blob BLOB, -- float32 centroid
|
|
139
|
+
quality_marker TEXT DEFAULT 'low',
|
|
140
|
+
token_estimate INTEGER,
|
|
141
|
+
PRIMARY KEY (session_id, id)
|
|
142
|
+
);
|
|
143
|
+
CREATE INDEX IF NOT EXISTS idx_raptor_session ON raptor_nodes(session_id);
|
|
144
|
+
CREATE INDEX IF NOT EXISTS idx_raptor_parent ON raptor_nodes(parent_id);
|
|
145
|
+
|
|
146
|
+
-- Foundation for future features (resume sessions, daily log, lessons
|
|
147
|
+
-- learned). Scaffolded now so all store data lives in SQLite from day one;
|
|
148
|
+
-- population is minimal (touchSession / logDaily on compact) and the full
|
|
149
|
+
-- UI/recall for these lands in later sprints.
|
|
150
|
+
|
|
151
|
+
-- Per-session registry (resume + per-repo session history).
|
|
152
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
153
|
+
session_id TEXT PRIMARY KEY,
|
|
154
|
+
repo TEXT,
|
|
155
|
+
started_at INTEGER,
|
|
156
|
+
ended_at INTEGER,
|
|
157
|
+
last_compacted_at INTEGER,
|
|
158
|
+
status TEXT DEFAULT 'active'
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
-- Append-only daily activity log (the "daily log" feature seed).
|
|
162
|
+
CREATE TABLE IF NOT EXISTS daily_log (
|
|
163
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
164
|
+
day TEXT NOT NULL, -- YYYY-MM-DD
|
|
165
|
+
session_id TEXT,
|
|
166
|
+
event TEXT, -- e.g. 'compact'
|
|
167
|
+
detail TEXT,
|
|
168
|
+
tokens_saved INTEGER DEFAULT 0,
|
|
169
|
+
ts INTEGER
|
|
170
|
+
);
|
|
171
|
+
CREATE INDEX IF NOT EXISTS idx_daily_log_day ON daily_log(day);
|
|
172
|
+
|
|
173
|
+
-- Lessons learned (future recall/browse feature seed).
|
|
174
|
+
CREATE TABLE IF NOT EXISTS lessons (
|
|
175
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
176
|
+
session_id TEXT,
|
|
177
|
+
repo TEXT,
|
|
178
|
+
lesson TEXT,
|
|
179
|
+
ts INTEGER
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
-- FTS5 trigram virtual table (Sprint 9+ pg_trgm-equivalent verification).
|
|
183
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS context_chunks_trgm USING fts5(
|
|
184
|
+
id UNINDEXED,
|
|
185
|
+
normalized_text,
|
|
186
|
+
tokenize='trigram'
|
|
187
|
+
);
|
|
188
|
+
`);
|
|
189
|
+
// Idempotent column migrations. `CREATE TABLE IF NOT EXISTS` is a no-op on a
|
|
190
|
+
// pre-existing table, so new columns added to context_chunks after a store was
|
|
191
|
+
// first created (e.g. original_token_estimate in v0.4.2) must be ALTERed in for
|
|
192
|
+
// databases created by an older version — otherwise repoStats()/upsert crash
|
|
193
|
+
// with "no such column" and the extension fails to load. Additive only.
|
|
194
|
+
ensureColumn(db, "context_chunks", "original_token_estimate", "INTEGER");
|
|
195
|
+
const v = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get();
|
|
196
|
+
if (!v) {
|
|
197
|
+
db.prepare("INSERT INTO meta(key, value) VALUES(?, ?)").run("schema_version", String(SCHEMA_VERSION));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Add `column` (with `decl`, e.g. "INTEGER") to `table` if it does not already
|
|
202
|
+
* exist. Idempotent: checks PRAGMA table_info first, so it is safe to run on
|
|
203
|
+
* every open. Table/column/decl are code-controlled constants (never user
|
|
204
|
+
* input), so the unavoidable identifier interpolation here does not violate
|
|
205
|
+
* PREVENT-002 (no external data reaches this SQL).
|
|
206
|
+
*/
|
|
207
|
+
function ensureColumn(db, table, column, decl) {
|
|
208
|
+
const cols = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
209
|
+
if (cols.some((c) => c.name === column))
|
|
210
|
+
return;
|
|
211
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${decl}`);
|
|
212
|
+
}
|
|
213
|
+
/** Read a string-valued meta key (or undefined). Used for cumulative counters. */
|
|
214
|
+
export function getMeta(key, stateDir = getStateDir()) {
|
|
215
|
+
const db = openStore(stateDir);
|
|
216
|
+
const row = db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
|
|
217
|
+
return row?.value;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Cumulative "tokens saved" — the sum of stored checkpoint token estimates across
|
|
221
|
+
* all compactions in this store (one per repo). Persisted in the SQLite `meta`
|
|
222
|
+
* table so it survives session restarts and travels with the repo's state dir,
|
|
223
|
+
* mirroring how `storageDedupRate` is cumulative. Incremented in VectorStore.add()
|
|
224
|
+
* when a new (non-deduped) checkpoint is persisted.
|
|
225
|
+
*/
|
|
226
|
+
export function getTokensSaved(stateDir = getStateDir()) {
|
|
227
|
+
const raw = getMeta("tokens_saved", stateDir);
|
|
228
|
+
const n = raw == null ? 0 : Number(raw);
|
|
229
|
+
return Number.isFinite(n) ? n : 0;
|
|
230
|
+
}
|
|
231
|
+
/** Add `delta` (>=0) to the cumulative tokens-saved counter. */
|
|
232
|
+
export function addTokensSaved(delta, stateDir = getStateDir()) {
|
|
233
|
+
if (!(delta > 0))
|
|
234
|
+
return;
|
|
235
|
+
const db = openStore(stateDir);
|
|
236
|
+
db.prepare(`INSERT INTO meta(key, value) VALUES('tokens_saved', ?)
|
|
237
|
+
ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(value AS INTEGER) + ? AS TEXT)`).run(String(delta), delta);
|
|
238
|
+
}
|
|
239
|
+
/** Read a store-wide integer counter from the meta table (0 if absent). */
|
|
240
|
+
export function getMetaNumber(key, stateDir = getStateDir()) {
|
|
241
|
+
const raw = getMeta(key, stateDir);
|
|
242
|
+
const n = raw == null ? 0 : Number(raw);
|
|
243
|
+
return Number.isFinite(n) ? n : 0;
|
|
244
|
+
}
|
|
245
|
+
/** Atomically add `delta` to an integer meta counter. */
|
|
246
|
+
function incMeta(key, delta, stateDir = getStateDir()) {
|
|
247
|
+
if (!(delta > 0))
|
|
248
|
+
return;
|
|
249
|
+
const db = openStore(stateDir);
|
|
250
|
+
db.prepare(`INSERT INTO meta(key, value) VALUES(?, ?)
|
|
251
|
+
ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(value AS INTEGER) + ? AS TEXT)`).run(key, String(delta), delta);
|
|
252
|
+
}
|
|
253
|
+
/** Read the cumulative store-wide dedup counters. */
|
|
254
|
+
export function getDedupStats(stateDir = getStateDir()) {
|
|
255
|
+
return {
|
|
256
|
+
attempts: getMetaNumber("dedup_attempts", stateDir),
|
|
257
|
+
deduped: getMetaNumber("deduped", stateDir),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
/** Increment the store-wide dedup counters for one add() call. */
|
|
261
|
+
export function bumpDedupStats(deduped, stateDir = getStateDir()) {
|
|
262
|
+
incMeta("dedup_attempts", 1, stateDir);
|
|
263
|
+
if (deduped)
|
|
264
|
+
incMeta("deduped", 1, stateDir);
|
|
265
|
+
}
|
|
266
|
+
// --- Future-feature foundation (resume sessions / daily log / lessons) -------
|
|
267
|
+
// Scaffolded tables + minimal helpers so all store data lives in SQLite from
|
|
268
|
+
// day one. Full UI/recall for these lands in later sprints.
|
|
269
|
+
/** Upsert a `sessions` row (resume + per-repo session history). */
|
|
270
|
+
export function touchSession(sessionId, repo, stateDir = getStateDir()) {
|
|
271
|
+
const db = openStore(stateDir);
|
|
272
|
+
const sid = normalizeSessionId(sessionId);
|
|
273
|
+
const existing = db
|
|
274
|
+
.prepare("SELECT started_at FROM sessions WHERE session_id = ?")
|
|
275
|
+
.get(sid);
|
|
276
|
+
const now = Math.floor(Date.now() / 1000);
|
|
277
|
+
if (!existing) {
|
|
278
|
+
db.prepare(`INSERT INTO sessions(session_id, repo, started_at, last_compacted_at, status)
|
|
279
|
+
VALUES(?, ?, ?, ?, 'active')`).run(sid, repo ?? null, now, now);
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
db.prepare("UPDATE sessions SET last_compacted_at = ?, repo = COALESCE(?, repo), status = 'active' WHERE session_id = ?").run(now, repo ?? null, sid);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/** Append a `daily_log` entry (day = YYYY-MM-DD, local-naive from Date). */
|
|
286
|
+
export function logDaily(sessionId, event, detail, tokensSaved, stateDir = getStateDir()) {
|
|
287
|
+
const db = openStore(stateDir);
|
|
288
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
289
|
+
const now = Math.floor(Date.now() / 1000);
|
|
290
|
+
db.prepare(`INSERT INTO daily_log(day, session_id, event, detail, tokens_saved, ts)
|
|
291
|
+
VALUES(?, ?, ?, ?, ?, ?)`).run(day, normalizeSessionId(sessionId), event, detail ?? null, tokensSaved, now);
|
|
292
|
+
}
|
|
293
|
+
/** Append a `lessons` entry (future lessons-learned browse/recall). */
|
|
294
|
+
export function addLesson(sessionId, repo, lesson, stateDir = getStateDir()) {
|
|
295
|
+
const db = openStore(stateDir);
|
|
296
|
+
const now = Math.floor(Date.now() / 1000);
|
|
297
|
+
db.prepare(`INSERT INTO lessons(session_id, repo, lesson, ts) VALUES(?, ?, ?, ?)`).run(normalizeSessionId(sessionId), repo ?? null, lesson, now);
|
|
298
|
+
}
|
|
299
|
+
/** Map a DB row to the public StoredCheckpoint shape. */
|
|
300
|
+
function rowToCheckpoint(row) {
|
|
301
|
+
return {
|
|
302
|
+
checkpointId: row.id,
|
|
303
|
+
sessionId: row.session_id,
|
|
304
|
+
summary: row.summary ?? "",
|
|
305
|
+
topicSummary: row.topic_summary ?? undefined,
|
|
306
|
+
summaryHash: row.summary_hash ?? undefined,
|
|
307
|
+
keyDecisions: row.key_decisions ? JSON.parse(row.key_decisions) : [],
|
|
308
|
+
nextSteps: row.next_steps ? JSON.parse(row.next_steps) : [],
|
|
309
|
+
filesModified: row.files_modified ? JSON.parse(row.files_modified) : [],
|
|
310
|
+
tokenEstimate: row.token_estimate ?? 0,
|
|
311
|
+
originalTokenEstimate: row.original_token_estimate ?? undefined,
|
|
312
|
+
regionHash: row.region_hash ?? "",
|
|
313
|
+
contentHash: row.content_hash ?? undefined,
|
|
314
|
+
contentHash2: row.content_hash2 ?? undefined,
|
|
315
|
+
contentHashVersion: row.content_hash_version ?? undefined,
|
|
316
|
+
normalizedText: row.normalized_text ?? undefined,
|
|
317
|
+
compressedOriginal: row.compressed_original ?? undefined,
|
|
318
|
+
embedding: decodeEmbedding(row.embedding_blob),
|
|
319
|
+
timestamp: Number(row.timestamp ?? 0),
|
|
320
|
+
dedupStatus: row.dedup_status ?? undefined,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
/** Insert or replace a checkpoint (idempotent by id). */
|
|
324
|
+
export function upsertCheckpoint(cp, stateDir = getStateDir()) {
|
|
325
|
+
const db = openStore(stateDir);
|
|
326
|
+
const sid = normalizeSessionId(cp.sessionId);
|
|
327
|
+
const tx = db.transaction(() => {
|
|
328
|
+
db.prepare(`INSERT INTO context_chunks
|
|
329
|
+
(id, session_id, region_hash, content_hash, content_hash2, content_hash_version,
|
|
330
|
+
normalized_text, summary, topic_summary, summary_hash,
|
|
331
|
+
key_decisions, next_steps, files_modified, embedding_blob,
|
|
332
|
+
token_estimate, original_token_estimate, timestamp, dedup_status, compressed_original)
|
|
333
|
+
VALUES (@id, @sid, @region_hash, @content_hash, @content_hash2, @content_hash_version,
|
|
334
|
+
@normalized_text, @summary, @topic_summary, @summary_hash,
|
|
335
|
+
@key_decisions, @next_steps, @files_modified, @embedding_blob,
|
|
336
|
+
@token_estimate, @original_token_estimate, @timestamp, @dedup_status, @compressed_original)
|
|
337
|
+
ON CONFLICT(session_id, id) DO UPDATE SET
|
|
338
|
+
summary=excluded.summary,
|
|
339
|
+
topic_summary=excluded.topic_summary,
|
|
340
|
+
summary_hash=excluded.summary_hash,
|
|
341
|
+
key_decisions=excluded.key_decisions,
|
|
342
|
+
next_steps=excluded.next_steps,
|
|
343
|
+
files_modified=excluded.files_modified,
|
|
344
|
+
embedding_blob=excluded.embedding_blob,
|
|
345
|
+
token_estimate=excluded.token_estimate,
|
|
346
|
+
original_token_estimate=excluded.original_token_estimate,
|
|
347
|
+
timestamp=excluded.timestamp,
|
|
348
|
+
dedup_status=excluded.dedup_status,
|
|
349
|
+
compressed_original=excluded.compressed_original`).run({
|
|
350
|
+
id: cp.checkpointId,
|
|
351
|
+
sid,
|
|
352
|
+
region_hash: cp.regionHash ?? null,
|
|
353
|
+
content_hash: cp.contentHash ?? null,
|
|
354
|
+
content_hash2: cp.contentHash2 ?? null,
|
|
355
|
+
content_hash_version: cp.contentHashVersion ?? null,
|
|
356
|
+
normalized_text: cp.normalizedText ?? null,
|
|
357
|
+
summary: cp.summary ?? "",
|
|
358
|
+
topic_summary: cp.topicSummary ?? null,
|
|
359
|
+
summary_hash: cp.summaryHash ?? null,
|
|
360
|
+
key_decisions: jsonText(cp.keyDecisions),
|
|
361
|
+
next_steps: jsonText(cp.nextSteps),
|
|
362
|
+
files_modified: jsonText(cp.filesModified),
|
|
363
|
+
embedding_blob: encodeEmbedding(cp.embedding ?? []),
|
|
364
|
+
token_estimate: cp.tokenEstimate ?? 0,
|
|
365
|
+
original_token_estimate: cp.originalTokenEstimate ?? null,
|
|
366
|
+
timestamp: cp.timestamp ?? 0,
|
|
367
|
+
dedup_status: "active",
|
|
368
|
+
compressed_original: cp.compressedOriginal ?? null,
|
|
369
|
+
});
|
|
370
|
+
// FTS5 virtual tables don't support UPSERT — delete any prior row, reinsert.
|
|
371
|
+
// Store normalized_text (the L1 verify key); fall back to summary for rows
|
|
372
|
+
// that predate normalized_text population.
|
|
373
|
+
db.prepare("DELETE FROM context_chunks_trgm WHERE id = ?").run(cp.checkpointId);
|
|
374
|
+
db.prepare("INSERT INTO context_chunks_trgm(id, normalized_text) VALUES(?, ?)").run(cp.checkpointId, cp.normalizedText ?? cp.summary ?? "");
|
|
375
|
+
});
|
|
376
|
+
tx();
|
|
377
|
+
}
|
|
378
|
+
// --- Sprint 11: MinHash signatures + LSH buckets --------------------------
|
|
379
|
+
/** Persist a checkpoint's MinHash signature (idempotent by chunk_id + version). */
|
|
380
|
+
export function upsertMinhashSignature(chunkId, sessionId, signatureVersion, signatures, stateDir = getStateDir()) {
|
|
381
|
+
const db = openStore(stateDir);
|
|
382
|
+
const sid = normalizeSessionId(sessionId);
|
|
383
|
+
db.prepare(`INSERT INTO minhash_signatures(chunk_id, session_id, signature_version, signatures)
|
|
384
|
+
VALUES(?, ?, ?, ?)
|
|
385
|
+
ON CONFLICT(chunk_id, signature_version) DO UPDATE SET
|
|
386
|
+
session_id=excluded.session_id, signatures=excluded.signatures`).run(chunkId, sid, signatureVersion, JSON.stringify(signatures));
|
|
387
|
+
}
|
|
388
|
+
/** Persist LSH bucket memberships for a chunk (one row per bucket key). */
|
|
389
|
+
export function insertLshBuckets(chunkId, sessionId, signatureVersion, bucketKeys, stateDir = getStateDir()) {
|
|
390
|
+
const db = openStore(stateDir);
|
|
391
|
+
const sid = normalizeSessionId(sessionId);
|
|
392
|
+
const del = db.prepare("DELETE FROM dedup_lsh_buckets WHERE chunk_id = ?");
|
|
393
|
+
const ins = db.prepare("INSERT OR IGNORE INTO dedup_lsh_buckets(bucket_key, chunk_id, session_id, signature_version) VALUES(?, ?, ?, ?)");
|
|
394
|
+
const tx = db.transaction(() => {
|
|
395
|
+
del.run(chunkId);
|
|
396
|
+
for (const key of bucketKeys)
|
|
397
|
+
ins.run(key, chunkId, sid, signatureVersion);
|
|
398
|
+
});
|
|
399
|
+
tx();
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Candidate chunk_ids sharing any LSH bucket with `bucketKeys`, scoped to the
|
|
403
|
+
* session, capped at `limit`. Single query (no N loops) — QA #15 amplification
|
|
404
|
+
* guard. Returns DISTINCT chunk_ids excluding `excludeChunkId` (the new row).
|
|
405
|
+
*/
|
|
406
|
+
export function lshCandidateChunks(bucketKeys, sessionId, excludeChunkId, stateDir = getStateDir(), limit = 100) {
|
|
407
|
+
if (bucketKeys.length === 0)
|
|
408
|
+
return [];
|
|
409
|
+
const db = openStore(stateDir);
|
|
410
|
+
const sid = normalizeSessionId(sessionId);
|
|
411
|
+
const placeholders = bucketKeys.map(() => "?").join(",");
|
|
412
|
+
const rows = db
|
|
413
|
+
.prepare(`SELECT DISTINCT chunk_id FROM dedup_lsh_buckets
|
|
414
|
+
WHERE bucket_key IN (${placeholders}) AND session_id = ? AND chunk_id != ?
|
|
415
|
+
LIMIT ?`)
|
|
416
|
+
.all(...bucketKeys, sid, excludeChunkId, limit);
|
|
417
|
+
return rows.map((r) => r.chunk_id);
|
|
418
|
+
}
|
|
419
|
+
/** All checkpoints for a session, sorted by id. */
|
|
420
|
+
export function listCheckpoints(sessionId, stateDir = getStateDir()) {
|
|
421
|
+
const db = openStore(stateDir);
|
|
422
|
+
const sid = normalizeSessionId(sessionId);
|
|
423
|
+
const rows = db
|
|
424
|
+
.prepare("SELECT * FROM context_chunks WHERE session_id = ? ORDER BY id ASC")
|
|
425
|
+
.all(sid);
|
|
426
|
+
return rows.map(rowToCheckpoint);
|
|
427
|
+
}
|
|
428
|
+
/** Next sequential checkpoint id (chkpt_001 …) for a session. */
|
|
429
|
+
export function nextCheckpointId(sessionId, stateDir = getStateDir()) {
|
|
430
|
+
const db = openStore(stateDir);
|
|
431
|
+
const sid = normalizeSessionId(sessionId);
|
|
432
|
+
const row = db
|
|
433
|
+
.prepare("SELECT MAX(CAST(SUBSTR(id, 7) AS INTEGER)) AS n FROM context_chunks WHERE session_id = ?")
|
|
434
|
+
.get(sid);
|
|
435
|
+
const next = (row.n ?? 0) + 1;
|
|
436
|
+
return `chkpt_${String(next).padStart(3, "0")}`;
|
|
437
|
+
}
|
|
438
|
+
/** True if a checkpoint id already exists for a session. */
|
|
439
|
+
export function hasCheckpoint(sessionId, checkpointId, stateDir = getStateDir()) {
|
|
440
|
+
const db = openStore(stateDir);
|
|
441
|
+
const row = db
|
|
442
|
+
.prepare("SELECT 1 FROM context_chunks WHERE session_id = ? AND id = ? LIMIT 1")
|
|
443
|
+
.get(normalizeSessionId(sessionId), checkpointId);
|
|
444
|
+
return row !== undefined;
|
|
445
|
+
}
|
|
446
|
+
/** Mark a checkpoint's dedup_status (e.g. 'removed' by SemDeDup). */
|
|
447
|
+
export function setDedupStatus(checkpointId, sessionId, status, stateDir = getStateDir()) {
|
|
448
|
+
const db = openStore(stateDir);
|
|
449
|
+
db.prepare("UPDATE context_chunks SET dedup_status = ? WHERE id = ? AND session_id = ?").run(status, checkpointId, normalizeSessionId(sessionId));
|
|
450
|
+
}
|
|
451
|
+
// --- Session state (injection tracking) ------------------------------------
|
|
452
|
+
function loadSessionStateRow(sid, db) {
|
|
453
|
+
const row = db.prepare("SELECT * FROM session_state WHERE session_id = ?").get(sid);
|
|
454
|
+
if (!row) {
|
|
455
|
+
return { injectedCheckpointIds: [], storedRegionHashes: [] };
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
injectedCheckpointIds: row.injected_checkpoint_ids ? JSON.parse(row.injected_checkpoint_ids) : [],
|
|
459
|
+
storedRegionHashes: row.stored_region_hashes ? JSON.parse(row.stored_region_hashes) : [],
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
export function loadSessionState(sessionId, stateDir = getStateDir()) {
|
|
463
|
+
return loadSessionStateRow(normalizeSessionId(sessionId), openStore(stateDir));
|
|
464
|
+
}
|
|
465
|
+
export function saveSessionState(sessionId, state, stateDir = getStateDir()) {
|
|
466
|
+
const db = openStore(stateDir);
|
|
467
|
+
const sid = normalizeSessionId(sessionId);
|
|
468
|
+
db.prepare(`INSERT INTO session_state(session_id, injected_checkpoint_ids, stored_region_hashes)
|
|
469
|
+
VALUES(@sid, @inj, @reg)
|
|
470
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
471
|
+
injected_checkpoint_ids=excluded.injected_checkpoint_ids,
|
|
472
|
+
stored_region_hashes=excluded.stored_region_hashes`).run({
|
|
473
|
+
sid,
|
|
474
|
+
inj: jsonText(state.injectedCheckpointIds),
|
|
475
|
+
reg: jsonText(state.storedRegionHashes),
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
export function storeStats(sessionId, stateDir = getStateDir()) {
|
|
479
|
+
const db = openStore(stateDir);
|
|
480
|
+
const sid = normalizeSessionId(sessionId);
|
|
481
|
+
const row = db
|
|
482
|
+
.prepare(`SELECT COUNT(*) AS c, COALESCE(SUM(token_estimate),0) AS tok,
|
|
483
|
+
MAX(id) AS lastId
|
|
484
|
+
FROM context_chunks WHERE session_id = ?`)
|
|
485
|
+
.get(sid);
|
|
486
|
+
let lastSummary;
|
|
487
|
+
if (row.lastId) {
|
|
488
|
+
const s = db.prepare("SELECT summary FROM context_chunks WHERE id = ?").get(row.lastId);
|
|
489
|
+
lastSummary = s?.summary;
|
|
490
|
+
}
|
|
491
|
+
return {
|
|
492
|
+
checkpointCount: row.c,
|
|
493
|
+
totalTokenEstimate: row.tok,
|
|
494
|
+
lastCheckpointId: row.lastId ?? undefined,
|
|
495
|
+
lastSummary,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
export function repoStats(stateDir = getStateDir()) {
|
|
499
|
+
const db = openStore(stateDir);
|
|
500
|
+
const row = db
|
|
501
|
+
.prepare(`SELECT COUNT(*) AS c, COALESCE(SUM(token_estimate),0) AS tok,
|
|
502
|
+
COALESCE(SUM(original_token_estimate),0) AS orig,
|
|
503
|
+
COUNT(DISTINCT session_id) AS sessions
|
|
504
|
+
FROM context_chunks WHERE dedup_status != 'removed'`)
|
|
505
|
+
.get();
|
|
506
|
+
const ds = getDedupStats(stateDir);
|
|
507
|
+
return {
|
|
508
|
+
checkpointCount: row.c,
|
|
509
|
+
totalTokenEstimate: row.tok,
|
|
510
|
+
originalTokens: row.orig,
|
|
511
|
+
sessionCount: row.sessions,
|
|
512
|
+
tokensSaved: getMetaNumber("tokens_saved", stateDir),
|
|
513
|
+
dedupAttempts: ds.attempts,
|
|
514
|
+
dedupCollapsed: ds.deduped,
|
|
515
|
+
storageDedupRate: ds.attempts === 0 ? 0 : ds.deduped / ds.attempts,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
/** Close and evict a cached connection (test teardown only). */
|
|
519
|
+
export function closeStore(stateDir) {
|
|
520
|
+
const db = cache.get(stateDir);
|
|
521
|
+
if (db) {
|
|
522
|
+
db.close();
|
|
523
|
+
cache.delete(stateDir);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
/** Persist a single RAPTOR node (upsert by (session_id, id)). */
|
|
527
|
+
export function upsertRaptorNode(node, stateDir = getStateDir()) {
|
|
528
|
+
const db = openStore(stateDir);
|
|
529
|
+
db.prepare(`INSERT INTO raptor_nodes(id, session_id, level, parent_id, children, summary, embedding_blob, quality_marker, token_estimate)
|
|
530
|
+
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
531
|
+
ON CONFLICT(session_id, id) DO UPDATE SET
|
|
532
|
+
level=excluded.level, parent_id=excluded.parent_id, children=excluded.children,
|
|
533
|
+
summary=excluded.summary, embedding_blob=excluded.embedding_blob,
|
|
534
|
+
quality_marker=excluded.quality_marker, token_estimate=excluded.token_estimate`).run(node.id, node.sessionId, node.level, node.parentId, jsonText(node.children), node.summary, encodeEmbedding(node.embedding), node.qualityMarker, node.tokenEstimate);
|
|
535
|
+
}
|
|
536
|
+
/** Persist an entire built RAPTOR tree for a session (shadow or live). */
|
|
537
|
+
export function saveRaptorTree(sessionId, tree, stateDir = getStateDir()) {
|
|
538
|
+
for (const node of tree.nodes.values()) {
|
|
539
|
+
upsertRaptorNode({
|
|
540
|
+
id: node.id,
|
|
541
|
+
sessionId,
|
|
542
|
+
level: node.level,
|
|
543
|
+
parentId: node.parentId,
|
|
544
|
+
children: node.children,
|
|
545
|
+
summary: node.summary,
|
|
546
|
+
embedding: node.embedding,
|
|
547
|
+
qualityMarker: node.qualityMarker,
|
|
548
|
+
tokenEstimate: node.tokenEstimate,
|
|
549
|
+
}, stateDir);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
/** Load all RAPTOR nodes for a session. */
|
|
553
|
+
export function listRaptorNodes(sessionId, stateDir = getStateDir()) {
|
|
554
|
+
const db = openStore(stateDir);
|
|
555
|
+
const rows = db
|
|
556
|
+
.prepare("SELECT * FROM raptor_nodes WHERE session_id = ? ORDER BY level ASC, id ASC")
|
|
557
|
+
.all(normalizeSessionId(sessionId));
|
|
558
|
+
return rows.map((row) => ({
|
|
559
|
+
id: row.id,
|
|
560
|
+
sessionId: row.session_id,
|
|
561
|
+
level: row.level,
|
|
562
|
+
parentId: row.parent_id ?? null,
|
|
563
|
+
children: row.children ? JSON.parse(row.children) : [],
|
|
564
|
+
summary: row.summary ?? "",
|
|
565
|
+
embedding: decodeEmbedding(row.embedding_blob),
|
|
566
|
+
qualityMarker: row.quality_marker ?? "low",
|
|
567
|
+
tokenEstimate: row.token_estimate ?? 0,
|
|
568
|
+
}));
|
|
569
|
+
}
|
|
570
|
+
/** Delete all RAPTOR nodes for a session (rollback/cleanup). */
|
|
571
|
+
export function clearRaptorNodes(sessionId, stateDir = getStateDir()) {
|
|
572
|
+
const db = openStore(stateDir);
|
|
573
|
+
db.prepare("DELETE FROM raptor_nodes WHERE session_id = ?").run(normalizeSessionId(sessionId));
|
|
574
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* store.ts — persistence primitives for checkpoints + session state.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors memory-mcp session_context.py: sessions normalize to `sess_xxx`,
|
|
5
|
+
* checkpoints are sequential `chkpt_001` per session. State lives under
|
|
6
|
+
* ~/.pi/agent/extensions/mega-compact/ as gzipped JSON.
|
|
7
|
+
*/
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { compressSmart, decompressSmart } from "./store/compression.js";
|
|
13
|
+
// Re-export the compression primitives from their extracted home so existing
|
|
14
|
+
// imports (`import { compressSmart } from "./store.js"`) keep working.
|
|
15
|
+
export { compressSmart, decompressSmart, compressZstd, compressZstdMax, decompressZstd, isVersioned, isZstd, detectFormat, decompressSyncAuto, } from "./store/compression.js";
|
|
16
|
+
/**
|
|
17
|
+
* State directory. Read lazily (per call) so tests can redirect it via
|
|
18
|
+
* MEGACOMPACT_STATE_DIR without re-importing the module. Defaults to
|
|
19
|
+
* ~/.pi/agent/extensions/mega-compact/.
|
|
20
|
+
*/
|
|
21
|
+
export function getStateDir() {
|
|
22
|
+
return process.env.MEGACOMPACT_STATE_DIR ?? join(homedir(), ".pi", "agent", "extensions", "mega-compact");
|
|
23
|
+
}
|
|
24
|
+
/** Normalize an arbitrary session id to the `sess_xxx` form (port of memory-mcp). */
|
|
25
|
+
export function normalizeSessionId(sessionId) {
|
|
26
|
+
if (!sessionId)
|
|
27
|
+
return `sess_${randomBytes(8).toString("hex")}`;
|
|
28
|
+
if (sessionId.startsWith("sess_"))
|
|
29
|
+
return sessionId;
|
|
30
|
+
if (sessionId.length >= 32 && sessionId.includes("-")) {
|
|
31
|
+
return `sess_${sessionId.replace(/-/g, "").slice(0, 16)}`;
|
|
32
|
+
}
|
|
33
|
+
return `sess_${sessionId}`;
|
|
34
|
+
}
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// JSON persistence (DR snapshots + migration source).
|
|
37
|
+
// Compression lives in ./store/compression.ts; compressSmart/decompressSmart
|
|
38
|
+
// are imported above and re-exported for backward-compatible call sites.
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
/**
|
|
41
|
+
* Read smart-compressed JSON, returning `fallback` on missing/corrupt file.
|
|
42
|
+
*
|
|
43
|
+
* Backward-compatible: detects legacy gzip files (magic byte 0x1f) and
|
|
44
|
+
* decompresses them correctly alongside new tagged files.
|
|
45
|
+
*/
|
|
46
|
+
export function readGzJson(path, fallback) {
|
|
47
|
+
try {
|
|
48
|
+
if (!existsSync(path))
|
|
49
|
+
return fallback;
|
|
50
|
+
const buf = readFileSync(path);
|
|
51
|
+
const out = decompressSmart(buf);
|
|
52
|
+
return JSON.parse(out.toString("utf-8"));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return fallback;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Write JSON with dynamic compression; creates parent dirs. */
|
|
59
|
+
export function writeGzJson(path, data) {
|
|
60
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
61
|
+
const jsonBuf = Buffer.from(JSON.stringify(data), "utf-8");
|
|
62
|
+
const compressed = compressSmart(jsonBuf);
|
|
63
|
+
writeFileSync(path, compressed);
|
|
64
|
+
}
|
|
65
|
+
/** Append a checkpoint to the per-session checkpoint file (gzipped). */
|
|
66
|
+
export function appendCheckpoint(cp, stateDir = getStateDir()) {
|
|
67
|
+
const file = join(stateDir, `${cp.sessionId}.checkpoints.json.gz`);
|
|
68
|
+
const existing = readGzJson(file, []);
|
|
69
|
+
existing.push(cp);
|
|
70
|
+
writeGzJson(file, existing);
|
|
71
|
+
}
|
|
72
|
+
/** All checkpoints for a session (across branches). */
|
|
73
|
+
export function listCheckpoints(sessionId, stateDir = getStateDir()) {
|
|
74
|
+
const file = join(stateDir, `${normalizeSessionId(sessionId)}.checkpoints.json.gz`);
|
|
75
|
+
return readGzJson(file, []);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Rewrite ALL checkpoints for a session (in-place update).
|
|
79
|
+
*
|
|
80
|
+
* Used by VectorStore when summaryHash or contentSimilarity dedup updates an
|
|
81
|
+
* existing checkpoint's timestamp/metadata instead of creating a new one.
|
|
82
|
+
*/
|
|
83
|
+
export function rewriteCheckpoints(sessionId, checkpoints, stateDir = getStateDir()) {
|
|
84
|
+
const file = join(stateDir, `${normalizeSessionId(sessionId)}.checkpoints.json.gz`);
|
|
85
|
+
writeGzJson(file, checkpoints);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Generate the next sequential checkpoint id for a session (chkpt_001 ...).
|
|
89
|
+
*/
|
|
90
|
+
export function nextCheckpointId(sessionId, stateDir = getStateDir()) {
|
|
91
|
+
const list = listCheckpoints(sessionId, stateDir);
|
|
92
|
+
const max = list.reduce((m, c) => {
|
|
93
|
+
const n = parseInt(c.checkpointId.replace("chkpt_", ""), 10);
|
|
94
|
+
return Number.isFinite(n) && n > m ? n : m;
|
|
95
|
+
}, 0);
|
|
96
|
+
return `chkpt_${String(max + 1).padStart(3, "0")}`;
|
|
97
|
+
}
|
|
98
|
+
/** Load mutable session state (created on demand). */
|
|
99
|
+
export function loadSessionState(sessionId, stateDir = getStateDir()) {
|
|
100
|
+
const file = join(stateDir, `${normalizeSessionId(sessionId)}.state.json.gz`);
|
|
101
|
+
return readGzJson(file, {
|
|
102
|
+
injectedCheckpointIds: [],
|
|
103
|
+
storedRegionHashes: [],
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
export function saveSessionState(sessionId, state, stateDir = getStateDir()) {
|
|
107
|
+
const file = join(stateDir, `${normalizeSessionId(sessionId)}.state.json.gz`);
|
|
108
|
+
writeGzJson(file, state);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Cumulative store-wide dedup accounting now lives in the SQLite `meta` table
|
|
112
|
+
* (see store/sqlite.ts: getDedupStats / bumpDedupStats). All store stats are
|
|
113
|
+
* SQLite-backed so they survive session restarts and travel with the repo's
|
|
114
|
+
* state dir. The legacy JSON `dedup-stats.json` path was removed.
|
|
115
|
+
*/
|