pi-mega-compact 0.7.2 → 0.7.4
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/mega-config.js +1 -0
- package/dist/extensions/mega-events.js +117 -4
- package/dist/extensions/mega-events.test.js +47 -0
- package/dist/extensions/mega-runtime.js +225 -97
- package/dist/src/mirror/dedup.js +44 -0
- package/dist/src/mirror/epoch.js +36 -0
- package/dist/src/mirror/mirror.test.js +185 -0
- package/dist/src/recall.js +37 -0
- package/dist/src/store/sqlite.dbmirror.test.js +175 -0
- package/dist/src/store/sqlite.js +248 -1
- package/extensions/mega-config.ts +8 -0
- package/extensions/mega-events.test.ts +55 -0
- package/extensions/mega-events.ts +126 -4
- package/extensions/mega-runtime.ts +941 -737
- package/package.json +1 -1
- package/src/mirror/dedup.ts +57 -0
- package/src/mirror/epoch.ts +37 -0
- package/src/mirror/mirror.test.ts +240 -0
- package/src/recall.ts +38 -0
- package/src/store/sqlite.dbmirror.test.ts +219 -0
- package/src/store/sqlite.ts +378 -1
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mirror.test.ts — S27 DB-mirror integration tests.
|
|
3
|
+
*
|
|
4
|
+
* Pi-agnostic: no pi runtime imports (src/ invariant).
|
|
5
|
+
*
|
|
6
|
+
* NOTE: raw_transcript has PRIMARY KEY (content_hash, session_id), so
|
|
7
|
+
* duplicate content in the same session is silently dropped by INSERT OR IGNORE.
|
|
8
|
+
* Tests are designed around this constraint.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
15
|
+
import { openStore, closeStore } from "../../src/store/sqlite.js";
|
|
16
|
+
import { writeCheckpointEpoch, listCheckpointEpochs, appendRawTranscript, listRawTranscriptRange, upsertDedupMirror, getDedupRatio, getDedupMirrorStats, countRawTranscript, } from "../../src/store/sqlite.js";
|
|
17
|
+
import { epochIdFor } from "../../src/mirror/epoch.js";
|
|
18
|
+
import { dedupTranscript } from "../../src/mirror/dedup.js";
|
|
19
|
+
import { computeContentDigest } from "../../src/dedup/digest.js";
|
|
20
|
+
function tmp() {
|
|
21
|
+
return mkdtempSync(join(tmpdir(), "mirror-test-"));
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Build a valid RawTranscriptRow using the canonical content hash from
|
|
25
|
+
* computeContentDigest (matches what dedupTranscript uses).
|
|
26
|
+
*/
|
|
27
|
+
function mkRow(sessionId, seq, // ignored by appendRawTranscript (auto-assigned)
|
|
28
|
+
role, content) {
|
|
29
|
+
const { contentHash } = computeContentDigest(content);
|
|
30
|
+
return {
|
|
31
|
+
contentHash,
|
|
32
|
+
sessionId,
|
|
33
|
+
seq,
|
|
34
|
+
role,
|
|
35
|
+
contentBytes: content,
|
|
36
|
+
toolName: null,
|
|
37
|
+
messageTimestamp: Date.now(),
|
|
38
|
+
checkpointEpoch: "",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
describe("S27 DB-mirror", () => {
|
|
42
|
+
it("epochIdFor is deterministic", () => {
|
|
43
|
+
assert.equal(epochIdFor("cp-abc-123"), epochIdFor("cp-abc-123"));
|
|
44
|
+
assert.notEqual(epochIdFor("cp-abc-123"), epochIdFor("cp-abc-456"));
|
|
45
|
+
const id = epochIdFor("cp-abc-123");
|
|
46
|
+
assert.ok(id.startsWith("epoch:"));
|
|
47
|
+
assert.ok(id.length > 6);
|
|
48
|
+
});
|
|
49
|
+
it("writeCheckpointEpoch + listCheckpointEpochs round-trips", () => {
|
|
50
|
+
const dir = tmp();
|
|
51
|
+
const db = openStore(dir);
|
|
52
|
+
writeCheckpointEpoch(db, {
|
|
53
|
+
epochId: "epoch-test-001",
|
|
54
|
+
sessionId: "sess-abc",
|
|
55
|
+
startedSeq: 0,
|
|
56
|
+
committedSeq: 100,
|
|
57
|
+
checkpointId: "cp-test-001",
|
|
58
|
+
cutIndex: 100,
|
|
59
|
+
summaryMessageText: "Test summary",
|
|
60
|
+
createdAt: Date.now(),
|
|
61
|
+
});
|
|
62
|
+
const rows = listCheckpointEpochs(db);
|
|
63
|
+
assert.ok(rows.length >= 1);
|
|
64
|
+
assert.equal(rows[0].epochId, "epoch-test-001");
|
|
65
|
+
assert.equal(rows[0].sessionId, "sess-abc");
|
|
66
|
+
assert.equal(rows[0].checkpointId, "cp-test-001");
|
|
67
|
+
closeStore(dir);
|
|
68
|
+
rmSync(dir, { recursive: true, force: true });
|
|
69
|
+
});
|
|
70
|
+
it("appendRawTranscript + listRawTranscriptRange round-trips (unique content)", () => {
|
|
71
|
+
const dir = tmp();
|
|
72
|
+
const db = openStore(dir);
|
|
73
|
+
// Use unique content for each row to avoid PK collision
|
|
74
|
+
appendRawTranscript(db, mkRow("sess-abc", 0, "user", "first message"));
|
|
75
|
+
appendRawTranscript(db, mkRow("sess-abc", 1, "assistant", "second message"));
|
|
76
|
+
appendRawTranscript(db, mkRow("sess-abc", 2, "user", "third message"));
|
|
77
|
+
// seq is auto-assigned: 1, 2, 3
|
|
78
|
+
const rows = listRawTranscriptRange(db, "sess-abc", 0, 10);
|
|
79
|
+
assert.equal(rows.length, 3);
|
|
80
|
+
assert.equal(rows[0].contentBytes, "first message");
|
|
81
|
+
assert.equal(rows[0].seq, 1);
|
|
82
|
+
assert.equal(rows[1].contentBytes, "second message");
|
|
83
|
+
assert.equal(rows[1].seq, 2);
|
|
84
|
+
assert.equal(rows[2].contentBytes, "third message");
|
|
85
|
+
assert.equal(rows[2].seq, 3);
|
|
86
|
+
// Range filter: [2..3]
|
|
87
|
+
const rows2 = listRawTranscriptRange(db, "sess-abc", 2, 3);
|
|
88
|
+
assert.equal(rows2.length, 2);
|
|
89
|
+
assert.equal(rows2[0].contentBytes, "second message");
|
|
90
|
+
assert.equal(rows2[1].contentBytes, "third message");
|
|
91
|
+
closeStore(dir);
|
|
92
|
+
rmSync(dir, { recursive: true, force: true });
|
|
93
|
+
});
|
|
94
|
+
it("upsertDedupMirror increments ref_count for duplicate content", () => {
|
|
95
|
+
const dir = tmp();
|
|
96
|
+
const db = openStore(dir);
|
|
97
|
+
const isNew1 = upsertDedupMirror(db, "hash-aaa", "Hello", 0);
|
|
98
|
+
assert.equal(isNew1, true);
|
|
99
|
+
const isNew2 = upsertDedupMirror(db, "hash-aaa", "Hello", 1);
|
|
100
|
+
assert.equal(isNew2, false);
|
|
101
|
+
const stats = getDedupMirrorStats(db);
|
|
102
|
+
assert.equal(stats.rowCount, 1);
|
|
103
|
+
assert.equal(stats.avgRefCount, 2);
|
|
104
|
+
closeStore(dir);
|
|
105
|
+
rmSync(dir, { recursive: true, force: true });
|
|
106
|
+
});
|
|
107
|
+
it("dedupTranscript deduplicates cross-session content via dedup_mirror", () => {
|
|
108
|
+
const dir = tmp();
|
|
109
|
+
const db = openStore(dir);
|
|
110
|
+
// Insert same content in TWO different sessions (raw_transcript PK allows this)
|
|
111
|
+
appendRawTranscript(db, mkRow("sess-a", 0, "user", "shared hello"));
|
|
112
|
+
appendRawTranscript(db, mkRow("sess-a", 1, "assistant", "shared world"));
|
|
113
|
+
appendRawTranscript(db, mkRow("sess-a", 2, "user", "unique A"));
|
|
114
|
+
appendRawTranscript(db, mkRow("sess-b", 0, "user", "shared hello"));
|
|
115
|
+
appendRawTranscript(db, mkRow("sess-b", 1, "assistant", "shared world"));
|
|
116
|
+
appendRawTranscript(db, mkRow("sess-b", 2, "user", "unique B"));
|
|
117
|
+
// Dedup session A: 3 rows, all new → deduped=0
|
|
118
|
+
const dedupedA = dedupTranscript(db, "sess-a", 0, 10);
|
|
119
|
+
assert.equal(dedupedA, 0);
|
|
120
|
+
// Dedup session B: 3 rows, but 2 already in dedup_mirror → deduped=2
|
|
121
|
+
const dedupedB = dedupTranscript(db, "sess-b", 0, 10);
|
|
122
|
+
assert.equal(dedupedB, 2);
|
|
123
|
+
// dedup_mirror has 4 unique hashes: shared-hello, shared-world, unique-A, unique-B
|
|
124
|
+
const stats = getDedupMirrorStats(db);
|
|
125
|
+
assert.equal(stats.rowCount, 4);
|
|
126
|
+
assert.ok(stats.avgRefCount > 1);
|
|
127
|
+
closeStore(dir);
|
|
128
|
+
rmSync(dir, { recursive: true, force: true });
|
|
129
|
+
});
|
|
130
|
+
it("getDedupRatio reflects dedup savings", () => {
|
|
131
|
+
const dir = tmp();
|
|
132
|
+
const db = openStore(dir);
|
|
133
|
+
// Two sessions with identical content → cross-session dedup
|
|
134
|
+
for (let i = 0; i < 3; i++) {
|
|
135
|
+
appendRawTranscript(db, mkRow("sess-x", i, "user", "same content"));
|
|
136
|
+
appendRawTranscript(db, mkRow("sess-y", i, "user", "same content"));
|
|
137
|
+
}
|
|
138
|
+
// Each session has 1 row (PK dedup within session), so 1 row each
|
|
139
|
+
// sess-x: 1 row, sess-y: 1 row
|
|
140
|
+
dedupTranscript(db, "sess-x", 0, 10);
|
|
141
|
+
dedupTranscript(db, "sess-y", 0, 10);
|
|
142
|
+
// For sess-x: totalBytes = LENGTH("same content") = 12, uniqueBytes = 12 → ratio 1.0
|
|
143
|
+
const { totalBytes, uniqueBytes, ratio } = getDedupRatio(db, "sess-x");
|
|
144
|
+
assert.ok(totalBytes > 0);
|
|
145
|
+
assert.ok(uniqueBytes > 0);
|
|
146
|
+
assert.ok(ratio >= 1.0);
|
|
147
|
+
closeStore(dir);
|
|
148
|
+
rmSync(dir, { recursive: true, force: true });
|
|
149
|
+
});
|
|
150
|
+
it("full pipeline: append + dedup + epoch", () => {
|
|
151
|
+
const dir = tmp();
|
|
152
|
+
const db = openStore(dir);
|
|
153
|
+
// Insert 5 unique rows
|
|
154
|
+
const contents = ["alpha", "bravo", "charlie", "delta", "echo"];
|
|
155
|
+
for (let i = 0; i < contents.length; i++) {
|
|
156
|
+
appendRawTranscript(db, mkRow("sess-pipe", i, i % 2 === 0 ? "user" : "assistant", contents[i]));
|
|
157
|
+
}
|
|
158
|
+
const total = countRawTranscript(db);
|
|
159
|
+
assert.ok(total >= 5);
|
|
160
|
+
// Dedup: all 5 unique → deduped = 0
|
|
161
|
+
const deduped = dedupTranscript(db, "sess-pipe", 0, 100);
|
|
162
|
+
assert.equal(deduped, 0);
|
|
163
|
+
// Mirror should have 5 unique hashes
|
|
164
|
+
const stats = getDedupMirrorStats(db);
|
|
165
|
+
assert.equal(stats.rowCount, 5);
|
|
166
|
+
// Write checkpoint epoch
|
|
167
|
+
writeCheckpointEpoch(db, {
|
|
168
|
+
epochId: "epoch-integration",
|
|
169
|
+
sessionId: "sess-pipe",
|
|
170
|
+
startedSeq: 0,
|
|
171
|
+
committedSeq: 100,
|
|
172
|
+
checkpointId: "cp-integration",
|
|
173
|
+
cutIndex: 5,
|
|
174
|
+
summaryMessageText: "Integration test summary",
|
|
175
|
+
createdAt: Date.now(),
|
|
176
|
+
});
|
|
177
|
+
const epochs = listCheckpointEpochs(db);
|
|
178
|
+
assert.ok(epochs.length >= 1);
|
|
179
|
+
assert.equal(epochs[0].epochId, "epoch-integration");
|
|
180
|
+
const rows = listRawTranscriptRange(db, "sess-pipe", 0, 100);
|
|
181
|
+
assert.equal(rows.length, 5);
|
|
182
|
+
closeStore(dir);
|
|
183
|
+
rmSync(dir, { recursive: true, force: true });
|
|
184
|
+
});
|
|
185
|
+
});
|
package/dist/src/recall.js
CHANGED
|
@@ -41,7 +41,44 @@ export function formatRecallBlock(hits) {
|
|
|
41
41
|
* it records injections via `markInjected` so the next call dedupes. The
|
|
42
42
|
* `store` is passed by the extension (defaults to the engine's default store).
|
|
43
43
|
*/
|
|
44
|
+
/**
|
|
45
|
+
* Recall and inline context from the checkpoint store.
|
|
46
|
+
*
|
|
47
|
+
* S27 contract — DB-Mirror demotion:
|
|
48
|
+
*
|
|
49
|
+
* When `MEGACOMPACT_DB_MIRROR` is ON, the `raw_transcript` table is the
|
|
50
|
+
* canonical, byte-stable source of truth for message reconstruction. The
|
|
51
|
+
* `dedup_mirror` provides space-efficient storage with ref_count tracking
|
|
52
|
+
* (see src/mirror/dedup.ts). The legacy JSON checkpoint is retained as a
|
|
53
|
+
* DR snapshot only (see src/store.ts checkpoint helpers).
|
|
54
|
+
*
|
|
55
|
+
* The recall function continues to work from the VectorStore (checkpoint
|
|
56
|
+
* summaries + embeddings) for fast semantic search — this path is unaffected
|
|
57
|
+
* by the mirror flag. If full transcript reconstruction is ever needed
|
|
58
|
+
* (replay, export, debug), prefer reading from `raw_transcript + dedup_mirror`
|
|
59
|
+
* via `listRawTranscriptRange()` + `dedupTranscript()` instead of the legacy
|
|
60
|
+
* JSON checkpoint. Falls back to legacy checkpoint if mirror is empty
|
|
61
|
+
* (pre-migration sessions).
|
|
62
|
+
*
|
|
63
|
+
* Pi-agnostic: no pi runtime imports (src/ invariant).
|
|
64
|
+
*/
|
|
44
65
|
export function recallAndInline(opts, store) {
|
|
66
|
+
// ── S27 Recall Demotion ─────────────────────────────────────────────
|
|
67
|
+
//
|
|
68
|
+
// When MEGACOMPACT_DB_MIRROR is ON, the raw_transcript + dedup_mirror
|
|
69
|
+
// tables are preferred for byte-stable reconstruction. The current
|
|
70
|
+
// recall path (VectorStore search → format → inject) is unaffected —
|
|
71
|
+
// it provides fast semantic search over checkpoint summaries.
|
|
72
|
+
//
|
|
73
|
+
// If full transcript reconstruction is ever needed (replay, export,
|
|
74
|
+
// debug), call reconstructFromMirror(db, sessionId, fromSeq, toSeq)
|
|
75
|
+
// from src/mirror/dedup.ts instead of reading from the legacy JSON
|
|
76
|
+
// checkpoint. Falls back to legacy checkpoint if mirror is empty
|
|
77
|
+
// (pre-migration sessions).
|
|
78
|
+
//
|
|
79
|
+
// Invariant: raw_transcript + dedup_mirror are additive and never
|
|
80
|
+
// lose data. The legacy JSON checkpoint remains as a DR snapshot.
|
|
81
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
45
82
|
const limit = opts.limit ?? 3;
|
|
46
83
|
const skip = opts.skipInjected ?? true;
|
|
47
84
|
const maxTokens = opts.recallMaxTokens ?? 0; // 0 = unbounded (legacy behavior)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for DB-mirror raw_transcript + checkpoint_epochs tables.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { openStore, appendRawTranscript, listRawTranscriptRange, writeCheckpointEpoch, readCheckpointEpoch, getActiveEpochForSession, listCheckpointEpochs, countRawTranscript, } from "./sqlite.js";
|
|
10
|
+
function makeTmp() {
|
|
11
|
+
return mkdtempSync(join(tmpdir(), "dbmirror-test-"));
|
|
12
|
+
}
|
|
13
|
+
function makeRow(overrides = {}) {
|
|
14
|
+
return {
|
|
15
|
+
contentHash: `hash-${Math.random().toString(36).slice(2, 10)}`,
|
|
16
|
+
sessionId: "sess-1",
|
|
17
|
+
seq: 0,
|
|
18
|
+
role: "user",
|
|
19
|
+
contentBytes: "hello world",
|
|
20
|
+
toolName: null,
|
|
21
|
+
messageTimestamp: null,
|
|
22
|
+
checkpointEpoch: "epoch-1",
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function makeEpoch(overrides = {}) {
|
|
27
|
+
return {
|
|
28
|
+
epochId: `epoch-${Math.random().toString(36).slice(2, 10)}`,
|
|
29
|
+
sessionId: "sess-1",
|
|
30
|
+
startedSeq: 0,
|
|
31
|
+
committedSeq: 10,
|
|
32
|
+
checkpointId: "cp-1",
|
|
33
|
+
cutIndex: 10,
|
|
34
|
+
summaryMessageText: "test summary",
|
|
35
|
+
createdAt: Date.now(),
|
|
36
|
+
...overrides,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
describe("DB mirror", () => {
|
|
40
|
+
let dir;
|
|
41
|
+
let db;
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
dir = makeTmp();
|
|
44
|
+
db = openStore(dir);
|
|
45
|
+
});
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
rmSync(dir, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
describe("appendRawTranscript", () => {
|
|
50
|
+
it("inserts a row and auto-assigns seq", () => {
|
|
51
|
+
const row = makeRow();
|
|
52
|
+
appendRawTranscript(db, row);
|
|
53
|
+
const count = countRawTranscript(db);
|
|
54
|
+
assert.equal(count, 1);
|
|
55
|
+
const rows = listRawTranscriptRange(db, "sess-1", 0, 999999);
|
|
56
|
+
assert.equal(rows.length, 1);
|
|
57
|
+
assert.equal(rows[0].contentHash, row.contentHash);
|
|
58
|
+
assert.equal(rows[0].role, "user");
|
|
59
|
+
});
|
|
60
|
+
it("is idempotent on content_hash PK — no duplicate rows", () => {
|
|
61
|
+
const row = makeRow({ contentHash: "fixed-hash" });
|
|
62
|
+
appendRawTranscript(db, row);
|
|
63
|
+
appendRawTranscript(db, row); // duplicate
|
|
64
|
+
const count = countRawTranscript(db);
|
|
65
|
+
assert.equal(count, 1);
|
|
66
|
+
});
|
|
67
|
+
it("increments seq across different messages in same session", () => {
|
|
68
|
+
appendRawTranscript(db, makeRow({ contentHash: "h1", sessionId: "s1" }));
|
|
69
|
+
appendRawTranscript(db, makeRow({ contentHash: "h2", sessionId: "s1" }));
|
|
70
|
+
appendRawTranscript(db, makeRow({ contentHash: "h3", sessionId: "s1" }));
|
|
71
|
+
const count = countRawTranscript(db);
|
|
72
|
+
assert.equal(count, 3);
|
|
73
|
+
});
|
|
74
|
+
it("stores tool_name and message_timestamp when provided", () => {
|
|
75
|
+
appendRawTranscript(db, makeRow({
|
|
76
|
+
contentHash: "tool-row",
|
|
77
|
+
role: "toolResult",
|
|
78
|
+
toolName: "bash",
|
|
79
|
+
messageTimestamp: 1234567890,
|
|
80
|
+
}));
|
|
81
|
+
const rows = listRawTranscriptRange(db, "sess-1", 0, 999999);
|
|
82
|
+
assert.equal(rows[0].toolName, "bash");
|
|
83
|
+
assert.equal(rows[0].messageTimestamp, 1234567890);
|
|
84
|
+
});
|
|
85
|
+
it("stores checkpoint_epoch", () => {
|
|
86
|
+
appendRawTranscript(db, makeRow({ checkpointEpoch: "ep-42" }));
|
|
87
|
+
const rows = listRawTranscriptRange(db, "sess-1", 0, 999999);
|
|
88
|
+
assert.equal(rows[0].checkpointEpoch, "ep-42");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
describe("writeCheckpointEpoch + readCheckpointEpoch", () => {
|
|
92
|
+
it("round-trips a checkpoint epoch", () => {
|
|
93
|
+
const epoch = makeEpoch();
|
|
94
|
+
writeCheckpointEpoch(db, epoch);
|
|
95
|
+
const got = readCheckpointEpoch(db, epoch.epochId);
|
|
96
|
+
assert.ok(got);
|
|
97
|
+
assert.equal(got.epochId, epoch.epochId);
|
|
98
|
+
assert.equal(got.sessionId, epoch.sessionId);
|
|
99
|
+
assert.equal(got.committedSeq, epoch.committedSeq);
|
|
100
|
+
assert.equal(got.checkpointId, epoch.checkpointId);
|
|
101
|
+
assert.equal(got.cutIndex, epoch.cutIndex);
|
|
102
|
+
assert.equal(got.summaryMessageText, epoch.summaryMessageText);
|
|
103
|
+
});
|
|
104
|
+
it("is idempotent on epochId PK", () => {
|
|
105
|
+
const epoch = makeEpoch({ epochId: "ep-dup" });
|
|
106
|
+
writeCheckpointEpoch(db, epoch);
|
|
107
|
+
writeCheckpointEpoch(db, epoch); // duplicate
|
|
108
|
+
// Should not throw, still one row
|
|
109
|
+
const got = readCheckpointEpoch(db, "ep-dup");
|
|
110
|
+
assert.ok(got);
|
|
111
|
+
});
|
|
112
|
+
it("returns null for unknown epochId", () => {
|
|
113
|
+
const got = readCheckpointEpoch(db, "nonexistent");
|
|
114
|
+
assert.equal(got, null);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
describe("getActiveEpochForSession", () => {
|
|
118
|
+
it("returns the most recent epoch for a session", () => {
|
|
119
|
+
writeCheckpointEpoch(db, makeEpoch({
|
|
120
|
+
epochId: "ep-old",
|
|
121
|
+
sessionId: "s1",
|
|
122
|
+
createdAt: 1000,
|
|
123
|
+
}));
|
|
124
|
+
writeCheckpointEpoch(db, makeEpoch({
|
|
125
|
+
epochId: "ep-new",
|
|
126
|
+
sessionId: "s1",
|
|
127
|
+
createdAt: 2000,
|
|
128
|
+
}));
|
|
129
|
+
const got = getActiveEpochForSession(db, "s1");
|
|
130
|
+
assert.ok(got);
|
|
131
|
+
assert.equal(got.epochId, "ep-new");
|
|
132
|
+
});
|
|
133
|
+
it("returns null if no epochs exist for session", () => {
|
|
134
|
+
const got = getActiveEpochForSession(db, "no-such-session");
|
|
135
|
+
assert.equal(got, null);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
describe("checkpointEpochs()", () => {
|
|
139
|
+
it("returns all rows", () => {
|
|
140
|
+
writeCheckpointEpoch(db, makeEpoch({ epochId: "ep-a" }));
|
|
141
|
+
writeCheckpointEpoch(db, makeEpoch({ epochId: "ep-b" }));
|
|
142
|
+
const rows = listCheckpointEpochs(db);
|
|
143
|
+
assert.equal(rows.length, 2);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
describe("integration: raw_transcript + checkpoint_epochs", () => {
|
|
147
|
+
it("full flow: append messages, write epoch, query both", () => {
|
|
148
|
+
// Append 5 messages
|
|
149
|
+
for (let i = 1; i <= 5; i++) {
|
|
150
|
+
appendRawTranscript(db, makeRow({
|
|
151
|
+
contentHash: `msg-${i}`,
|
|
152
|
+
sessionId: "s1",
|
|
153
|
+
checkpointEpoch: "ep-1",
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
// Write epoch
|
|
157
|
+
writeCheckpointEpoch(db, makeEpoch({
|
|
158
|
+
epochId: "ep-1",
|
|
159
|
+
sessionId: "s1",
|
|
160
|
+
committedSeq: 5,
|
|
161
|
+
cutIndex: 5,
|
|
162
|
+
}));
|
|
163
|
+
// Verify transcript rows
|
|
164
|
+
const transcripts = listRawTranscriptRange(db, "s1", 0, 999999);
|
|
165
|
+
assert.equal(transcripts.length, 5);
|
|
166
|
+
for (const t of transcripts) {
|
|
167
|
+
assert.equal(t.checkpointEpoch, "ep-1");
|
|
168
|
+
}
|
|
169
|
+
// Verify epoch
|
|
170
|
+
const epoch = readCheckpointEpoch(db, "ep-1");
|
|
171
|
+
assert.ok(epoch);
|
|
172
|
+
assert.equal(epoch.committedSeq, 5);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
package/dist/src/store/sqlite.js
CHANGED
|
@@ -22,7 +22,7 @@ import { homedir, tmpdir } from "node:os";
|
|
|
22
22
|
import { join } from "node:path";
|
|
23
23
|
import { getStateDir } from "../store.js";
|
|
24
24
|
import { normalizeSessionId } from "../store.js";
|
|
25
|
-
const SCHEMA_VERSION =
|
|
25
|
+
const SCHEMA_VERSION = 2;
|
|
26
26
|
/** Encode a float vector as a little-endian Float32 BLOB for cosine scanning. */
|
|
27
27
|
function encodeEmbedding(v) {
|
|
28
28
|
const buf = Buffer.allocUnsafe(v.length * 4);
|
|
@@ -457,6 +457,54 @@ function initSchema(db) {
|
|
|
457
457
|
normalized_text,
|
|
458
458
|
tokenize='trigram'
|
|
459
459
|
);
|
|
460
|
+
|
|
461
|
+
-- S27: durable raw-transcript mirror (MEGACOMPACT_DB_MIRROR). Appended
|
|
462
|
+
-- RAW message bytes per session so a compacted window can be rehydrated
|
|
463
|
+
-- from the local store instead of the pi runtime transcript (which is
|
|
464
|
+
-- trimmed). PK is (content_hash, session_id) — NOT content_hash alone —
|
|
465
|
+
-- so identical content in different sessions never collides. Additive:
|
|
466
|
+
-- CREATE TABLE IF NOT EXISTS leaves existing DBs untouched on open until
|
|
467
|
+
-- the S27 mirror flag is flipped on. All queries parameterized (PREVENT-002).
|
|
468
|
+
CREATE TABLE IF NOT EXISTS raw_transcript (
|
|
469
|
+
content_hash TEXT NOT NULL,
|
|
470
|
+
session_id TEXT NOT NULL,
|
|
471
|
+
seq INTEGER NOT NULL,
|
|
472
|
+
role TEXT NOT NULL,
|
|
473
|
+
content_bytes TEXT NOT NULL,
|
|
474
|
+
tool_name TEXT,
|
|
475
|
+
message_timestamp INTEGER, -- ORIGINAL msg ts at append, NOT served
|
|
476
|
+
checkpoint_epoch TEXT NOT NULL,
|
|
477
|
+
PRIMARY KEY (content_hash, session_id)
|
|
478
|
+
);
|
|
479
|
+
CREATE INDEX IF NOT EXISTS idx_rt_session_seq ON raw_transcript(session_id, seq);
|
|
480
|
+
CREATE INDEX IF NOT EXISTS idx_rt_epoch ON raw_transcript(checkpoint_epoch);
|
|
481
|
+
|
|
482
|
+
-- S27: checkpoint-epoch registry. One row per compaction epoch; the
|
|
483
|
+
-- summary_message_text is the verbatim system message that replaced the
|
|
484
|
+
-- trimmed prefix. Informational bookkeeping (the raw_transcript rows are
|
|
485
|
+
-- authoritative); refresh-safe via ON CONFLICT(epoch_id) DO UPDATE.
|
|
486
|
+
CREATE TABLE IF NOT EXISTS checkpoint_epochs (
|
|
487
|
+
epoch_id TEXT PRIMARY KEY,
|
|
488
|
+
session_id TEXT NOT NULL,
|
|
489
|
+
started_seq INTEGER NOT NULL,
|
|
490
|
+
committed_seq INTEGER NOT NULL,
|
|
491
|
+
summary_message_text TEXT NOT NULL,
|
|
492
|
+
cut_index INTEGER NOT NULL,
|
|
493
|
+
checkpoint_id TEXT NOT NULL,
|
|
494
|
+
created_at INTEGER NOT NULL
|
|
495
|
+
);
|
|
496
|
+
CREATE INDEX IF NOT EXISTS idx_epoch_session ON checkpoint_epochs(session_id, created_at DESC);
|
|
497
|
+
|
|
498
|
+
-- S27 Task 6: dedup_mirror for space-efficient deduplicated storage.
|
|
499
|
+
-- Each unique content_hash stores its bytes ONCE; raw_transcript rows
|
|
500
|
+
-- reference this table via content_ref instead of storing duplicate content_bytes inline.
|
|
501
|
+
CREATE TABLE IF NOT EXISTS dedup_mirror (
|
|
502
|
+
content_hash TEXT PRIMARY KEY,
|
|
503
|
+
content_bytes TEXT NOT NULL,
|
|
504
|
+
ref_count INTEGER NOT NULL DEFAULT 1,
|
|
505
|
+
first_seen_seq INTEGER NOT NULL,
|
|
506
|
+
created_at INTEGER NOT NULL
|
|
507
|
+
);
|
|
460
508
|
`);
|
|
461
509
|
// Idempotent column migrations. `CREATE TABLE IF NOT EXISTS` is a no-op on a
|
|
462
510
|
// pre-existing table, so new columns added to context_chunks after a store was
|
|
@@ -464,6 +512,8 @@ function initSchema(db) {
|
|
|
464
512
|
// databases created by an older version — otherwise repoStats()/upsert crash
|
|
465
513
|
// with "no such column" and the extension fails to load. Additive only.
|
|
466
514
|
ensureColumn(db, "context_chunks", "original_token_estimate", "INTEGER");
|
|
515
|
+
// S27 Task 6: content_ref column in raw_transcript for dedup_mirror references.
|
|
516
|
+
ensureColumn(db, "raw_transcript", "content_ref", "TEXT");
|
|
467
517
|
// S20 memory-RAG extension: additive columns for auto-review ops. Idempotent —
|
|
468
518
|
// only alters DBs created by an older version that lack these columns.
|
|
469
519
|
ensureColumn(db, "memories", "category", "TEXT");
|
|
@@ -1112,3 +1162,200 @@ export function clearRaptorNodes(sessionId, stateDir = getStateDir()) {
|
|
|
1112
1162
|
const db = openStore(stateDir);
|
|
1113
1163
|
db.prepare("DELETE FROM raptor_nodes WHERE session_id = ?").run(normalizeSessionId(sessionId));
|
|
1114
1164
|
}
|
|
1165
|
+
function rowToRawTranscript(row) {
|
|
1166
|
+
return {
|
|
1167
|
+
contentHash: row.content_hash,
|
|
1168
|
+
sessionId: row.session_id,
|
|
1169
|
+
seq: Number(row.seq),
|
|
1170
|
+
role: row.role,
|
|
1171
|
+
contentBytes: row.content_bytes,
|
|
1172
|
+
toolName: row.tool_name ?? null,
|
|
1173
|
+
messageTimestamp: row.message_timestamp == null ? null : Number(row.message_timestamp),
|
|
1174
|
+
checkpointEpoch: row.checkpoint_epoch,
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
function rowToCheckpointEpoch(row) {
|
|
1178
|
+
return {
|
|
1179
|
+
epochId: row.epoch_id,
|
|
1180
|
+
sessionId: row.session_id,
|
|
1181
|
+
startedSeq: Number(row.started_seq),
|
|
1182
|
+
committedSeq: Number(row.committed_seq),
|
|
1183
|
+
summaryMessageText: row.summary_message_text,
|
|
1184
|
+
cutIndex: Number(row.cut_index),
|
|
1185
|
+
checkpointId: row.checkpoint_id,
|
|
1186
|
+
createdAt: Number(row.created_at),
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
/**
|
|
1190
|
+
* Append one raw-message row to the durable mirror. Idempotent by
|
|
1191
|
+
* (content_hash, session_id) via INSERT OR IGNORE — re-appending the same
|
|
1192
|
+
* content for the same session is a no-op. seq is assigned server-side as
|
|
1193
|
+
* COALESCE(MAX(seq),0)+1 within the session, so callers never need to compute
|
|
1194
|
+
* it. Pass an open store handle (openStore) — matches the other DatabaseSync
|
|
1195
|
+
* helpers. Parameterized (PREVENT-002).
|
|
1196
|
+
*/
|
|
1197
|
+
export function appendRawTranscript(db, row) {
|
|
1198
|
+
withTx(db, () => {
|
|
1199
|
+
db.prepare(`INSERT OR IGNORE INTO raw_transcript
|
|
1200
|
+
(content_hash, session_id, seq, role, content_bytes, tool_name, message_timestamp, checkpoint_epoch)
|
|
1201
|
+
VALUES (
|
|
1202
|
+
@content_hash, @session_id,
|
|
1203
|
+
COALESCE((SELECT MAX(seq) FROM raw_transcript WHERE session_id = @session_id), 0) + 1,
|
|
1204
|
+
@role, @content_bytes, @tool_name, @message_timestamp, @checkpoint_epoch
|
|
1205
|
+
)`).run({
|
|
1206
|
+
"@content_hash": row.contentHash,
|
|
1207
|
+
"@session_id": row.sessionId,
|
|
1208
|
+
"@role": row.role,
|
|
1209
|
+
"@content_bytes": row.contentBytes,
|
|
1210
|
+
"@tool_name": row.toolName,
|
|
1211
|
+
"@message_timestamp": row.messageTimestamp,
|
|
1212
|
+
"@checkpoint_epoch": row.checkpointEpoch,
|
|
1213
|
+
});
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
/**
|
|
1217
|
+
* List raw-transcript rows for a session in [fromSeq, toSeq], ordered by seq
|
|
1218
|
+
* ascending. Returns camel-cased RawTranscriptRow[]. Parameterized.
|
|
1219
|
+
*/
|
|
1220
|
+
export function listRawTranscriptRange(db, sessionId, fromSeq, toSeq) {
|
|
1221
|
+
const rows = db
|
|
1222
|
+
.prepare(`SELECT content_hash, session_id, seq, role, content_bytes, tool_name, message_timestamp, checkpoint_epoch
|
|
1223
|
+
FROM raw_transcript
|
|
1224
|
+
WHERE session_id = @session_id AND seq >= @from_seq AND seq <= @to_seq
|
|
1225
|
+
ORDER BY seq ASC`)
|
|
1226
|
+
.all({
|
|
1227
|
+
"@session_id": sessionId,
|
|
1228
|
+
"@from_seq": fromSeq,
|
|
1229
|
+
"@to_seq": toSeq,
|
|
1230
|
+
});
|
|
1231
|
+
return rows.map(rowToRawTranscript);
|
|
1232
|
+
}
|
|
1233
|
+
/**
|
|
1234
|
+
* Insert (or refresh) a checkpoint-epoch row. ON CONFLICT(epoch_id) DO UPDATE
|
|
1235
|
+
* so re-running the same compaction epoch is idempotent / refresh-safe.
|
|
1236
|
+
* Parameterized (PREVENT-002).
|
|
1237
|
+
*/
|
|
1238
|
+
export function writeCheckpointEpoch(db, epoch) {
|
|
1239
|
+
withTx(db, () => {
|
|
1240
|
+
db.prepare(`INSERT INTO checkpoint_epochs
|
|
1241
|
+
(epoch_id, session_id, started_seq, committed_seq, summary_message_text, cut_index, checkpoint_id, created_at)
|
|
1242
|
+
VALUES (@epoch_id, @session_id, @started_seq, @committed_seq, @summary_message_text, @cut_index, @checkpoint_id, @created_at)
|
|
1243
|
+
ON CONFLICT(epoch_id) DO UPDATE SET
|
|
1244
|
+
session_id = excluded.session_id,
|
|
1245
|
+
started_seq = excluded.started_seq,
|
|
1246
|
+
committed_seq = excluded.committed_seq,
|
|
1247
|
+
summary_message_text = excluded.summary_message_text,
|
|
1248
|
+
cut_index = excluded.cut_index,
|
|
1249
|
+
checkpoint_id = excluded.checkpoint_id,
|
|
1250
|
+
created_at = excluded.created_at`).run({
|
|
1251
|
+
"@epoch_id": epoch.epochId,
|
|
1252
|
+
"@session_id": epoch.sessionId,
|
|
1253
|
+
"@started_seq": epoch.startedSeq,
|
|
1254
|
+
"@committed_seq": epoch.committedSeq,
|
|
1255
|
+
"@summary_message_text": epoch.summaryMessageText,
|
|
1256
|
+
"@cut_index": epoch.cutIndex,
|
|
1257
|
+
"@checkpoint_id": epoch.checkpointId,
|
|
1258
|
+
"@created_at": epoch.createdAt,
|
|
1259
|
+
});
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
/** Read one checkpoint-epoch row by id (or null if absent). Parameterized. */
|
|
1263
|
+
export function readCheckpointEpoch(db, epochId) {
|
|
1264
|
+
const row = db
|
|
1265
|
+
.prepare(`SELECT epoch_id, session_id, started_seq, committed_seq, summary_message_text, cut_index, checkpoint_id, created_at
|
|
1266
|
+
FROM checkpoint_epochs WHERE epoch_id = @epoch_id`)
|
|
1267
|
+
.get({ "@epoch_id": epochId });
|
|
1268
|
+
return row ? rowToCheckpointEpoch(row) : null;
|
|
1269
|
+
}
|
|
1270
|
+
/**
|
|
1271
|
+
* Latest checkpoint-epoch row for a session (highest created_at), or null if
|
|
1272
|
+
* none. Parameterized (PREVENT-002).
|
|
1273
|
+
*/
|
|
1274
|
+
export function getActiveEpochForSession(db, sessionId) {
|
|
1275
|
+
const row = db
|
|
1276
|
+
.prepare(`SELECT epoch_id, session_id, started_seq, committed_seq, summary_message_text, cut_index, checkpoint_id, created_at
|
|
1277
|
+
FROM checkpoint_epochs
|
|
1278
|
+
WHERE session_id = @session_id
|
|
1279
|
+
ORDER BY created_at DESC
|
|
1280
|
+
LIMIT 1`)
|
|
1281
|
+
.get({ "@session_id": sessionId });
|
|
1282
|
+
return row ? rowToCheckpointEpoch(row) : null;
|
|
1283
|
+
}
|
|
1284
|
+
/** List all checkpoint epochs (diagnostic / test helper). */
|
|
1285
|
+
export function listCheckpointEpochs(db) {
|
|
1286
|
+
const rows = db
|
|
1287
|
+
.prepare(`SELECT epoch_id, session_id, started_seq, committed_seq, summary_message_text, cut_index, checkpoint_id, created_at
|
|
1288
|
+
FROM checkpoint_epochs
|
|
1289
|
+
ORDER BY created_at DESC`)
|
|
1290
|
+
.all();
|
|
1291
|
+
return rows.map(rowToCheckpointEpoch);
|
|
1292
|
+
}
|
|
1293
|
+
/** Count raw transcript rows (diagnostic / test helper). */
|
|
1294
|
+
export function countRawTranscript(db) {
|
|
1295
|
+
const row = db.prepare(`SELECT COUNT(*) AS cnt FROM raw_transcript`).get();
|
|
1296
|
+
return row.cnt;
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Upsert a row into dedup_mirror. If the hash already exists, increment ref_count.
|
|
1300
|
+
* Returns true if this was a NEW unique content (first insert), false if it was a duplicate.
|
|
1301
|
+
*/
|
|
1302
|
+
export function upsertDedupMirror(db, contentHash, contentBytes, seq) {
|
|
1303
|
+
const now = Date.now();
|
|
1304
|
+
const existing = db
|
|
1305
|
+
.prepare(`SELECT content_hash FROM dedup_mirror WHERE content_hash = @hash`)
|
|
1306
|
+
.get({ "@hash": contentHash });
|
|
1307
|
+
if (existing) {
|
|
1308
|
+
db.prepare(`UPDATE dedup_mirror SET ref_count = ref_count + 1 WHERE content_hash = @hash`).run({
|
|
1309
|
+
"@hash": contentHash,
|
|
1310
|
+
});
|
|
1311
|
+
return false;
|
|
1312
|
+
}
|
|
1313
|
+
db.prepare(`INSERT INTO dedup_mirror (content_hash, content_bytes, ref_count, first_seen_seq, created_at)
|
|
1314
|
+
VALUES (@hash, @bytes, 1, @seq, @now)`).run({
|
|
1315
|
+
"@hash": contentHash,
|
|
1316
|
+
"@bytes": contentBytes,
|
|
1317
|
+
"@seq": seq,
|
|
1318
|
+
"@now": now,
|
|
1319
|
+
});
|
|
1320
|
+
return true;
|
|
1321
|
+
}
|
|
1322
|
+
/**
|
|
1323
|
+
* Get dedup ratio for a session: total bytes vs unique bytes.
|
|
1324
|
+
*/
|
|
1325
|
+
export function getDedupRatio(db, sessionId) {
|
|
1326
|
+
const totalRow = db
|
|
1327
|
+
.prepare(`SELECT COALESCE(SUM(LENGTH(content_bytes)), 0) AS total
|
|
1328
|
+
FROM raw_transcript
|
|
1329
|
+
WHERE session_id = @session_id`)
|
|
1330
|
+
.get({ "@session_id": sessionId });
|
|
1331
|
+
const uniqueRow = db
|
|
1332
|
+
.prepare(`SELECT COALESCE(SUM(LENGTH(content_bytes)), 0) AS unique_bytes
|
|
1333
|
+
FROM dedup_mirror`)
|
|
1334
|
+
.get();
|
|
1335
|
+
const totalBytes = totalRow.total;
|
|
1336
|
+
const uniqueBytes = uniqueRow.unique_bytes;
|
|
1337
|
+
const ratio = uniqueBytes > 0 ? totalBytes / uniqueBytes : 1;
|
|
1338
|
+
return { totalBytes, uniqueBytes, ratio };
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* Get dedup mirror stats (diagnostic / test helper).
|
|
1342
|
+
*/
|
|
1343
|
+
export function getDedupMirrorStats(db) {
|
|
1344
|
+
const row = db
|
|
1345
|
+
.prepare(`SELECT COUNT(*) AS cnt,
|
|
1346
|
+
COALESCE(SUM(LENGTH(content_bytes)), 0) AS total_bytes,
|
|
1347
|
+
COALESCE(AVG(ref_count), 0) AS avg_ref
|
|
1348
|
+
FROM dedup_mirror`)
|
|
1349
|
+
.get();
|
|
1350
|
+
return { rowCount: row.cnt, totalBytes: row.total_bytes, avgRefCount: row.avg_ref };
|
|
1351
|
+
}
|
|
1352
|
+
/**
|
|
1353
|
+
* Update raw_transcript.content_ref to point to dedup_mirror.
|
|
1354
|
+
*/
|
|
1355
|
+
export function updateRawTranscriptRef(db, sessionId, seq, contentHash) {
|
|
1356
|
+
db.prepare(`UPDATE raw_transcript SET content_ref = @ref WHERE session_id = @sid AND seq = @seq`).run({
|
|
1357
|
+
"@ref": contentHash,
|
|
1358
|
+
"@sid": sessionId,
|
|
1359
|
+
"@seq": seq,
|
|
1360
|
+
});
|
|
1361
|
+
}
|