pi-mega-compact 0.7.3 → 0.7.5

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.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Tests for mega-events extension — DB-mirror event wiring.
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, listCheckpointEpochs, countRawTranscript } from "../src/store/sqlite.js";
10
+
11
+ function makeTmp(): string {
12
+ return mkdtempSync(join(tmpdir(), "mega-events-test-"));
13
+ }
14
+
15
+ describe("mega-events: DB-mirror integration", () => {
16
+ let dir: string;
17
+
18
+ beforeEach(() => {
19
+ dir = makeTmp();
20
+ // openStore creates the tables as a side effect
21
+ openStore(dir);
22
+ });
23
+
24
+ afterEach(() => {
25
+ rmSync(dir, { recursive: true, force: true });
26
+ });
27
+
28
+ it("openStore creates checkpoint_epochs and raw_transcript tables", () => {
29
+ const db = openStore(dir);
30
+ // Should not throw — tables exist
31
+ const epochs = listCheckpointEpochs(db);
32
+ assert.ok(Array.isArray(epochs));
33
+ const count = countRawTranscript(db);
34
+ assert.equal(count, 0);
35
+ });
36
+
37
+ it("DB-mirror flag defaults to false when env is unset", () => {
38
+ delete process.env.MEGACOMPACT_DB_MIRROR;
39
+ // Re-import to pick up env
40
+ // The extension checks env at load time, so just verify the env is absent
41
+ assert.equal(process.env.MEGACOMPACT_DB_MIRROR, undefined);
42
+ });
43
+
44
+ it("DB-mirror flag is enabled when env is '1'", () => {
45
+ process.env.MEGACOMPACT_DB_MIRROR = "1";
46
+ assert.equal(process.env.MEGACOMPACT_DB_MIRROR, "1");
47
+ delete process.env.MEGACOMPACT_DB_MIRROR;
48
+ });
49
+
50
+ it("DB-mirror flag is enabled when env is 'true'", () => {
51
+ process.env.MEGACOMPACT_DB_MIRROR = "true";
52
+ assert.equal(process.env.MEGACOMPACT_DB_MIRROR, "true");
53
+ delete process.env.MEGACOMPACT_DB_MIRROR;
54
+ });
55
+ });
@@ -12,9 +12,12 @@ import type {
12
12
  ExtensionContext,
13
13
  ContextEvent,
14
14
  SessionBeforeCompactEvent,
15
+ SessionCompactEvent,
15
16
  } from "@earendil-works/pi-coding-agent";
16
17
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
17
18
  import { normalizeSessionId } from "../src/store.js";
19
+ import { openStore, appendRawTranscript, writeCheckpointEpoch, autoMaintain, type CheckpointEpoch } from "../src/store/sqlite.js";
20
+ import { epochIdFor } from "../src/mirror/epoch.js";
18
21
  import { autoCompactCheck } from "../src/compact.js";
19
22
  import { estimateSessionTokens, estimateBlockTokens } from "../src/tokens.js";
20
23
  import {
@@ -40,6 +43,39 @@ import {
40
43
  memoryReviewCadence,
41
44
  type MegaConfig,
42
45
  } from "./mega-config.js";
46
+ import type { RawTranscriptRow } from "../src/store/sqlite.js";
47
+ import { createHash } from "node:crypto";
48
+
49
+ /**
50
+ * Convert a pi AgentMessage to a RawTranscriptRow for the DB mirror.
51
+ * content_bytes is canonical JSON (sorted keys) for deterministic hashing.
52
+ * Returns null if the message has no usable content.
53
+ */
54
+ function toRawTranscriptRow(
55
+ msg: AgentMessage,
56
+ sessionId: string,
57
+ epochId: string,
58
+ ): RawTranscriptRow | null {
59
+ // Narrow to Message union (has content + timestamp).
60
+ const m = msg as { role?: string; content?: unknown; timestamp?: number; toolName?: string };
61
+ const content = m.content;
62
+ if (content == null || content === "") return null;
63
+ // Canonical form: sort object keys for deterministic hashing.
64
+ const contentBytes = typeof content === "string"
65
+ ? content
66
+ : JSON.stringify(content, Object.keys(content as object).sort());
67
+ const contentHash = createHash("sha256").update(contentBytes).digest("hex");
68
+ return {
69
+ contentHash,
70
+ sessionId,
71
+ seq: 0, // assigned by appendRawTranscript (COALESCE(MAX(seq),0)+1)
72
+ role: m.role ?? "unknown",
73
+ contentBytes,
74
+ toolName: m.toolName ?? null,
75
+ messageTimestamp: m.timestamp ?? null,
76
+ checkpointEpoch: epochId,
77
+ };
78
+ }
43
79
 
44
80
  /**
45
81
  * DIAG accessor for the headless test harness: the most recently constructed
@@ -122,6 +158,16 @@ export function registerEventHandlers(
122
158
  runtime.logger.warn("memory-recall skipped", { err: String(err) });
123
159
  }
124
160
  }
161
+ // S27 Task 10: best-effort auto-maintenance on session start (prune rows
162
+ // older than 30d, checkpoint WAL if >10MB, VACUUM if DB >100MB + >20%
163
+ // freelist). Never blocks session start — swallows errors and logs a
164
+ // one-line summary for diagnostics.
165
+ try {
166
+ const m = autoMaintain(runtime.currentStateDir);
167
+ if (m && !m.endsWith("nothing to do")) runtime.logger.info("db-auto-maintain", { result: m });
168
+ } catch (e) {
169
+ runtime.logger.warn("db-auto-maintain-fail", { error: String(e) });
170
+ }
125
171
  runtime.dashboard.event("session_start", {
126
172
  reason: event.reason,
127
173
  sessionId: runtime.rt.sessionId,
@@ -271,7 +317,20 @@ export function registerEventHandlers(
271
317
  // trim we ALWAYS nudge so the agent reliably restarts. Debounced 30s.
272
318
  let didDurableTrim = false;
273
319
  if (idle && overThreshold && now >= runtime.debounceUntil) {
274
- if (!piCompactWouldNoop(ctx)) {
320
+ // COMPACT-DEDUP FIX: skip the manual durable-trim trigger when pi's
321
+ // NATIVE auto-compaction just fired (or is in-flight). pi emits
322
+ // agent_end BEFORE its own _checkCompaction (per its docstring:
323
+ // "Called after agent_end and before prompt submission"), so a
324
+ // synchronous `piCompactWouldNoop` branch check misses a native
325
+ // compaction that hasn't appended its entry yet — calling
326
+ // ctx.compact() then races with pi and throws "Already compacted"
327
+ // to the user. The `lastCompactAt` cooldown (updated by the
328
+ // session_compact listener for EVERY compaction, native or
329
+ // extension-supplied) closes that race window.
330
+ const sinceCompact = now - (runtime.rt.lastNativeCompactAt ?? 0);
331
+ if (sinceCompact < 10_000) {
332
+ runtime.diagAgentEndDurableSkipRecent++;
333
+ } else if (!piCompactWouldNoop(ctx)) {
275
334
  runtime.debounceUntil = now + 2000;
276
335
  runtime.diagAgentEndDurable++;
277
336
  runtime.logger.info("agent-end-durable-trigger", {
@@ -280,7 +339,7 @@ export function registerEventHandlers(
280
339
  thresholdTokens: config.thresholdTokens,
281
340
  queued,
282
341
  });
283
- ctx.compact({ customInstructions: undefined }); // guardrails-allow PREVENT-PI-004: local ctx.compact() — no network; agent settled so no in-flight abort
342
+ ctx.compact({ customInstructions: undefined }); // guardrails-allow PREVENT-PI-004: local ctx.compact() — no network; agent settled so no in-flight abort. Race-guarded by lastCompactAt cooldown above (ctx.compact returns void → throw is surfaced by pi as compaction_end; the cooldown prevents the call entirely).
284
343
  didDurableTrim = true;
285
344
  }
286
345
  }
@@ -370,6 +429,22 @@ export function registerEventHandlers(
370
429
  estimateSessionTokens(view) ??
371
430
  Math.round((pct / 100) * (usage?.contextWindow ?? 0));
372
431
 
432
+ // S27 DB-mirror: append ALL incoming messages to raw_transcript.
433
+ // Runs BEFORE fast-gate so every message is captured, even if we
434
+ // don't compact this turn. Append is idempotent (content_hash PK).
435
+ if (config.dbMirror) {
436
+ try {
437
+ const db = openStore(runtime.currentStateDir);
438
+ const epochId = epochIdFor(runtime.rt.sessionId);
439
+ for (const msg of messages) {
440
+ const raw = toRawTranscriptRow(msg, runtime.rt.sessionId, epochId);
441
+ if (raw) appendRawTranscript(db, raw);
442
+ }
443
+ } catch (e) {
444
+ runtime.logger.warn("db-mirror-append-fail", { error: String(e) });
445
+ }
446
+ }
447
+
373
448
  // FAST GATE: token-based (tier% of the window), not a static amount.
374
449
  if (currentTokens < runtime.effectiveThreshold) {
375
450
  runtime.diagCtxFastGate++;
@@ -401,6 +476,41 @@ export function registerEventHandlers(
401
476
  return;
402
477
  }
403
478
 
479
+ // S27 DB-mirror: write checkpoint_epoch with deterministic nonce.
480
+ // This makes the cache key stable across identical compactions.
481
+ if (config.dbMirror) {
482
+ try {
483
+ const db = openStore(runtime.currentStateDir);
484
+ const cpId = ran.result.checkpointId ?? `epoch-${Date.now()}`;
485
+ const epoch: CheckpointEpoch = {
486
+ epochId: epochIdFor(cpId),
487
+ sessionId: runtime.rt.sessionId,
488
+ startedSeq: 0,
489
+ committedSeq: ran.result.compactedFrom,
490
+ checkpointId: cpId,
491
+ cutIndex: ran.result.compactedFrom,
492
+ summaryMessageText: ran.result.summary,
493
+ createdAt: Date.now(),
494
+ };
495
+ writeCheckpointEpoch(db, epoch);
496
+ // S27 Task 6: Fire-and-forget dedup pipeline.
497
+ // Deduplicates raw_transcript rows for the compacted range.
498
+ try {
499
+ const { dedupTranscript } = await import("../src/mirror/dedup.js");
500
+ dedupTranscript(
501
+ db,
502
+ runtime.rt.sessionId,
503
+ 0,
504
+ ran.result.compactedFrom,
505
+ );
506
+ } catch (_dedupErr) {
507
+ // Fire-and-forget: dedup failure is non-fatal
508
+ }
509
+ } catch (e) {
510
+ runtime.logger.warn("db-mirror-epoch-fail", { error: String(e) });
511
+ }
512
+ }
513
+
404
514
  // LEGACY path (rollback): v0.4.28 ctx.compact() + the no-op gate. The
405
515
  // manual compact path aborts the in-flight turn — only used behind the flag.
406
516
  // Read live from env (in addition to the load-time config) so the flag can be
@@ -411,8 +521,13 @@ export function registerEventHandlers(
411
521
  process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM === "true" ||
412
522
  process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM === "1";
413
523
  if (legacy) {
414
- if (piCompactWouldNoop(ctx)) return;
415
- ctx.compact({ customInstructions: undefined });
524
+ // COMPACT-DEDUP FIX: same race guard as the agent_end path. Skip when a
525
+ // NATIVE compaction just fired (avoids racing pi and surfacing a spurious
526
+ // "Already compacted" / "Nothing to compact" toast). Uses lastNativeCompactAt
527
+ // (NOT lastCompactAt, which runCompact also stamps for our own checkpoint).
528
+ const sinceCompact = Date.now() - (runtime.rt.lastNativeCompactAt ?? 0);
529
+ if (sinceCompact < 10_000 || piCompactWouldNoop(ctx)) return;
530
+ ctx.compact({ customInstructions: undefined }); // race-guarded by lastNativeCompactAt cooldown (ctx.compact returns void → not catchable; the cooldown prevents the call)
416
531
  return;
417
532
  }
418
533
 
@@ -551,6 +666,23 @@ export function registerEventHandlers(
551
666
  },
552
667
  );
553
668
 
669
+ // COMPACT-DEDUP FIX: track EVERY compaction (native + extension-supplied)
670
+ // so the agent_end durable-trim guard can skip a redundant ctx.compact()
671
+ // when pi just compacted. Without this, agent_end fires ctx.compact()
672
+ // synchronously AFTER pi's native auto-compaction appended a compaction
673
+ // entry but BEFORE our branch read sees it on the next tick — racing
674
+ // into a user-facing "Already compacted" throw. `lastCompactAt` is the
675
+ // race-closing signal: any compaction (manual/threshold/overflow, ours
676
+ // or pi's own) stamps it, and the agent_end guard skips for 10s.
677
+ pi.on("session_compact", async (_event: SessionCompactEvent, _ctx: ExtensionContext) => {
678
+ runtime.rt.lastNativeCompactAt = Date.now();
679
+ runtime.rt.lastCompactAt = Date.now();
680
+ runtime.logger.info("session-compacted", {
681
+ sessionId: runtime.rt.sessionId,
682
+ at: runtime.rt.lastCompactAt,
683
+ });
684
+ });
685
+
554
686
  /**
555
687
  * Build a minimal fallback compaction so pi never runs its throwing compact().
556
688
  *
@@ -72,6 +72,7 @@ interface SessionRuntime {
72
72
  dedupAttempts: number; // total compaction attempts (for hit-rate denominator)
73
73
  tokensSaved: number; // this session-instance only: reset on session_start
74
74
  lastCompactAt: number | null; // wall-clock ms of the last compaction this session
75
+ lastNativeCompactAt: number | null; // COMPACT-DEDUP FIX: wall-clock ms of the last NATIVE pi compaction (session_compact event) — used by the agent_end/legacy race guard to skip a redundant ctx.compact() that would throw "Already compacted".
75
76
  }
76
77
 
77
78
  /** ANSI palette for the toolbar. The pi TUI's Text component preserves ANSI
@@ -272,6 +273,7 @@ export class MegaRuntime {
272
273
  dedupAttempts: 0,
273
274
  tokensSaved: 0,
274
275
  lastCompactAt: null,
276
+ lastNativeCompactAt: null,
275
277
  };
276
278
  debounceUntil = 0;
277
279
  // S16: debounce for the agent_end resume nudge (avoid busy-loops).
@@ -339,6 +341,7 @@ export class MegaRuntime {
339
341
  diagBeforeCompactSupplied = 0; // session_before_compact supplied our trim
340
342
  diagAgentEndIdle = 0; // agent_end with activeAgents===0
341
343
  diagAgentEndDurable = 0; // agent_end fired ctx.compact() (mid-run durable trim)
344
+ diagAgentEndDurableSkipRecent = 0; // agent_end skipped ctx.compact() — compaction in last 10s (race guard)
342
345
  // Per-skip-path counters for the team-run diagnosis.
343
346
  diagCtxFastGate = 0; // returned at token fast-gate (below threshold)
344
347
  diagCtxNoCompact = 0; // autoCompactCheck().shouldCompact === false
@@ -845,6 +848,7 @@ export class MegaRuntime {
845
848
  dedupAttempts: 0,
846
849
  tokensSaved: 0,
847
850
  lastCompactAt: null,
851
+ lastNativeCompactAt: null,
848
852
  };
849
853
  this.statusKey = undefined;
850
854
  this.activeAgents = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
4
4
  "description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
5
5
  "type": "module",
6
6
  "license": "BSD-2-Clause",
@@ -0,0 +1,57 @@
1
+ /**
2
+ * dedup.ts — S27 Task 6: Fork snapshot → compress/dedupe pipeline.
3
+ *
4
+ * After the served window is handed to pi, asynchronously:
5
+ * 1. Read raw_transcript rows [0..cut_index] for the epoch
6
+ * 2. For each row, compute content_hash (reuse digest from dedup/)
7
+ * 3. INSERT OR IGNORE INTO dedup_mirror (stores bytes once per unique hash)
8
+ * 4. Update raw_transcript.content_ref to point to dedup_mirror
9
+ * 5. Increment dedup_mirror.ref_count for existing hashes
10
+ *
11
+ * Pi-agnostic: no pi runtime imports (src/ invariant).
12
+ */
13
+
14
+ import type { DatabaseSync } from "node:sqlite";
15
+ import {
16
+ upsertDedupMirror,
17
+ updateRawTranscriptRef,
18
+ listRawTranscriptRange,
19
+ getDedupRatio,
20
+ } from "../store/sqlite.js";
21
+ import { computeContentDigest } from "../dedup/digest.js";
22
+
23
+ /**
24
+ * Deduplicate raw transcript rows for a session range.
25
+ * Fire-and-forget: errors are logged, not thrown.
26
+ *
27
+ * @returns Number of rows deduplicated, or -1 on error.
28
+ */
29
+ export function dedupTranscript(
30
+ db: DatabaseSync,
31
+ sessionId: string,
32
+ fromSeq: number,
33
+ toSeq: number,
34
+ ): number {
35
+ try {
36
+ const rows = listRawTranscriptRange(db, sessionId, fromSeq, toSeq);
37
+ let deduped = 0;
38
+ for (const row of rows) {
39
+ const contentHash = computeContentDigest(row.contentBytes).contentHash;
40
+ const isNew = upsertDedupMirror(db, contentHash, row.contentBytes, row.seq);
41
+ updateRawTranscriptRef(db, sessionId, row.seq, contentHash);
42
+ if (!isNew) {
43
+ deduped++;
44
+ }
45
+ }
46
+ return deduped;
47
+ } catch (err) {
48
+ // Fire-and-forget: log but don't throw
49
+ console.error("[mega-compact] dedupTranscript failed:", err);
50
+ return -1;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Get dedup ratio for a session.
56
+ */
57
+ export { getDedupRatio };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * epoch.ts — deterministic epoch-id derivation for the S27 DB-mirror.
3
+ *
4
+ * The epoch id MUST be a pure function of the checkpoint it decorates so that
5
+ * replaying / refreshing the same compaction yields the SAME epoch id (idempotent
6
+ * appends + ON CONFLICT refresh). No Date.now / uuid / crypto — this is the
7
+ * only source of randomness-free epoch naming in the mirror stack.
8
+ *
9
+ * - epochIdFor(cp) → "epoch:" + cp (human-traceable back to its checkpoint)
10
+ * - epochNonceFor(cp) → FNV-1a 32-bit hash (cheap, well-distributed nonce)
11
+ *
12
+ * Pi-agnostic: no pi runtime imports (src/ invariant).
13
+ */
14
+
15
+ /**
16
+ * FNV-1a 32-bit nonce for a checkpoint id. Deterministic and RNG-free:
17
+ * h = 0x811c9dc5; for each char: h ^= codePoint; h = Math.imul(h, 0x01000193);
18
+ * return h >>> 0 (unsigned).
19
+ */
20
+ export function epochNonceFor(checkpointId: string): number {
21
+ let h = 0x811c9dc5;
22
+ for (let i = 0; i < checkpointId.length; i++) {
23
+ const cp = checkpointId.codePointAt(i);
24
+ if (cp === undefined) continue;
25
+ h ^= cp;
26
+ h = Math.imul(h, 0x01000193);
27
+ }
28
+ return h >>> 0;
29
+ }
30
+
31
+ /**
32
+ * Deterministic epoch id: "epoch:" + checkpointId. Trivially traceable back to
33
+ * the source checkpoint, and stable under replay (refresh-safe upserts).
34
+ */
35
+ export function epochIdFor(checkpointId: string): string {
36
+ return "epoch:" + checkpointId;
37
+ }
@@ -0,0 +1,240 @@
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
+
11
+ import { describe, it } from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { mkdtempSync, rmSync } from "node:fs";
16
+ import type { DatabaseSync } from "node:sqlite";
17
+ import { openStore, closeStore } from "../../src/store/sqlite.js";
18
+ import {
19
+ writeCheckpointEpoch,
20
+ listCheckpointEpochs,
21
+ appendRawTranscript,
22
+ listRawTranscriptRange,
23
+ upsertDedupMirror,
24
+ getDedupRatio,
25
+ getDedupMirrorStats,
26
+ countRawTranscript,
27
+ } from "../../src/store/sqlite.js";
28
+ import { epochIdFor } from "../../src/mirror/epoch.js";
29
+ import { dedupTranscript } from "../../src/mirror/dedup.js";
30
+ import { computeContentDigest } from "../../src/dedup/digest.js";
31
+
32
+ function tmp(): string {
33
+ return mkdtempSync(join(tmpdir(), "mirror-test-"));
34
+ }
35
+
36
+ /**
37
+ * Build a valid RawTranscriptRow using the canonical content hash from
38
+ * computeContentDigest (matches what dedupTranscript uses).
39
+ */
40
+ function mkRow(
41
+ sessionId: string,
42
+ seq: number, // ignored by appendRawTranscript (auto-assigned)
43
+ role: "user" | "assistant",
44
+ content: string,
45
+ ) {
46
+ const { contentHash } = computeContentDigest(content);
47
+ return {
48
+ contentHash,
49
+ sessionId,
50
+ seq,
51
+ role,
52
+ contentBytes: content,
53
+ toolName: null as string | null,
54
+ messageTimestamp: Date.now() as number | null,
55
+ checkpointEpoch: "",
56
+ };
57
+ }
58
+
59
+ describe("S27 DB-mirror", () => {
60
+ it("epochIdFor is deterministic", () => {
61
+ assert.equal(epochIdFor("cp-abc-123"), epochIdFor("cp-abc-123"));
62
+ assert.notEqual(epochIdFor("cp-abc-123"), epochIdFor("cp-abc-456"));
63
+ const id = epochIdFor("cp-abc-123");
64
+ assert.ok(id.startsWith("epoch:"));
65
+ assert.ok(id.length > 6);
66
+ });
67
+
68
+ it("writeCheckpointEpoch + listCheckpointEpochs round-trips", () => {
69
+ const dir = tmp();
70
+ const db: DatabaseSync = openStore(dir);
71
+
72
+ writeCheckpointEpoch(db, {
73
+ epochId: "epoch-test-001",
74
+ sessionId: "sess-abc",
75
+ startedSeq: 0,
76
+ committedSeq: 100,
77
+ checkpointId: "cp-test-001",
78
+ cutIndex: 100,
79
+ summaryMessageText: "Test summary",
80
+ createdAt: Date.now(),
81
+ });
82
+
83
+ const rows = listCheckpointEpochs(db);
84
+ assert.ok(rows.length >= 1);
85
+ assert.equal(rows[0].epochId, "epoch-test-001");
86
+ assert.equal(rows[0].sessionId, "sess-abc");
87
+ assert.equal(rows[0].checkpointId, "cp-test-001");
88
+
89
+ closeStore(dir);
90
+ rmSync(dir, { recursive: true, force: true });
91
+ });
92
+
93
+ it("appendRawTranscript + listRawTranscriptRange round-trips (unique content)", () => {
94
+ const dir = tmp();
95
+ const db: DatabaseSync = openStore(dir);
96
+
97
+ // Use unique content for each row to avoid PK collision
98
+ appendRawTranscript(db, mkRow("sess-abc", 0, "user", "first message"));
99
+ appendRawTranscript(db, mkRow("sess-abc", 1, "assistant", "second message"));
100
+ appendRawTranscript(db, mkRow("sess-abc", 2, "user", "third message"));
101
+
102
+ // seq is auto-assigned: 1, 2, 3
103
+ const rows = listRawTranscriptRange(db, "sess-abc", 0, 10);
104
+ assert.equal(rows.length, 3);
105
+ assert.equal(rows[0].contentBytes, "first message");
106
+ assert.equal(rows[0].seq, 1);
107
+ assert.equal(rows[1].contentBytes, "second message");
108
+ assert.equal(rows[1].seq, 2);
109
+ assert.equal(rows[2].contentBytes, "third message");
110
+ assert.equal(rows[2].seq, 3);
111
+
112
+ // Range filter: [2..3]
113
+ const rows2 = listRawTranscriptRange(db, "sess-abc", 2, 3);
114
+ assert.equal(rows2.length, 2);
115
+ assert.equal(rows2[0].contentBytes, "second message");
116
+ assert.equal(rows2[1].contentBytes, "third message");
117
+
118
+ closeStore(dir);
119
+ rmSync(dir, { recursive: true, force: true });
120
+ });
121
+
122
+ it("upsertDedupMirror increments ref_count for duplicate content", () => {
123
+ const dir = tmp();
124
+ const db: DatabaseSync = openStore(dir);
125
+
126
+ const isNew1 = upsertDedupMirror(db, "hash-aaa", "Hello", 0);
127
+ assert.equal(isNew1, true);
128
+
129
+ const isNew2 = upsertDedupMirror(db, "hash-aaa", "Hello", 1);
130
+ assert.equal(isNew2, false);
131
+
132
+ const stats = getDedupMirrorStats(db);
133
+ assert.equal(stats.rowCount, 1);
134
+ assert.equal(stats.avgRefCount, 2);
135
+
136
+ closeStore(dir);
137
+ rmSync(dir, { recursive: true, force: true });
138
+ });
139
+
140
+ it("dedupTranscript deduplicates cross-session content via dedup_mirror", () => {
141
+ const dir = tmp();
142
+ const db: DatabaseSync = openStore(dir);
143
+
144
+ // Insert same content in TWO different sessions (raw_transcript PK allows this)
145
+ appendRawTranscript(db, mkRow("sess-a", 0, "user", "shared hello"));
146
+ appendRawTranscript(db, mkRow("sess-a", 1, "assistant", "shared world"));
147
+ appendRawTranscript(db, mkRow("sess-a", 2, "user", "unique A"));
148
+ appendRawTranscript(db, mkRow("sess-b", 0, "user", "shared hello"));
149
+ appendRawTranscript(db, mkRow("sess-b", 1, "assistant", "shared world"));
150
+ appendRawTranscript(db, mkRow("sess-b", 2, "user", "unique B"));
151
+
152
+ // Dedup session A: 3 rows, all new → deduped=0
153
+ const dedupedA = dedupTranscript(db, "sess-a", 0, 10);
154
+ assert.equal(dedupedA, 0);
155
+
156
+ // Dedup session B: 3 rows, but 2 already in dedup_mirror → deduped=2
157
+ const dedupedB = dedupTranscript(db, "sess-b", 0, 10);
158
+ assert.equal(dedupedB, 2);
159
+
160
+ // dedup_mirror has 4 unique hashes: shared-hello, shared-world, unique-A, unique-B
161
+ const stats = getDedupMirrorStats(db);
162
+ assert.equal(stats.rowCount, 4);
163
+ assert.ok(stats.avgRefCount > 1);
164
+
165
+ closeStore(dir);
166
+ rmSync(dir, { recursive: true, force: true });
167
+ });
168
+
169
+ it("getDedupRatio reflects dedup savings", () => {
170
+ const dir = tmp();
171
+ const db: DatabaseSync = openStore(dir);
172
+
173
+ // Two sessions with identical content → cross-session dedup
174
+ for (let i = 0; i < 3; i++) {
175
+ appendRawTranscript(db, mkRow("sess-x", i, "user", "same content"));
176
+ appendRawTranscript(db, mkRow("sess-y", i, "user", "same content"));
177
+ }
178
+ // Each session has 1 row (PK dedup within session), so 1 row each
179
+ // sess-x: 1 row, sess-y: 1 row
180
+
181
+ dedupTranscript(db, "sess-x", 0, 10);
182
+ dedupTranscript(db, "sess-y", 0, 10);
183
+
184
+ // For sess-x: totalBytes = LENGTH("same content") = 12, uniqueBytes = 12 → ratio 1.0
185
+ const { totalBytes, uniqueBytes, ratio } = getDedupRatio(db, "sess-x");
186
+ assert.ok(totalBytes > 0);
187
+ assert.ok(uniqueBytes > 0);
188
+ assert.ok(ratio >= 1.0);
189
+
190
+ closeStore(dir);
191
+ rmSync(dir, { recursive: true, force: true });
192
+ });
193
+
194
+ it("full pipeline: append + dedup + epoch", () => {
195
+ const dir = tmp();
196
+ const db: DatabaseSync = openStore(dir);
197
+
198
+ // Insert 5 unique rows
199
+ const contents = ["alpha", "bravo", "charlie", "delta", "echo"];
200
+ for (let i = 0; i < contents.length; i++) {
201
+ appendRawTranscript(
202
+ db,
203
+ mkRow("sess-pipe", i, i % 2 === 0 ? "user" : "assistant", contents[i]),
204
+ );
205
+ }
206
+
207
+ const total = countRawTranscript(db);
208
+ assert.ok(total >= 5);
209
+
210
+ // Dedup: all 5 unique → deduped = 0
211
+ const deduped = dedupTranscript(db, "sess-pipe", 0, 100);
212
+ assert.equal(deduped, 0);
213
+
214
+ // Mirror should have 5 unique hashes
215
+ const stats = getDedupMirrorStats(db);
216
+ assert.equal(stats.rowCount, 5);
217
+
218
+ // Write checkpoint epoch
219
+ writeCheckpointEpoch(db, {
220
+ epochId: "epoch-integration",
221
+ sessionId: "sess-pipe",
222
+ startedSeq: 0,
223
+ committedSeq: 100,
224
+ checkpointId: "cp-integration",
225
+ cutIndex: 5,
226
+ summaryMessageText: "Integration test summary",
227
+ createdAt: Date.now(),
228
+ });
229
+
230
+ const epochs = listCheckpointEpochs(db);
231
+ assert.ok(epochs.length >= 1);
232
+ assert.equal(epochs[0].epochId, "epoch-integration");
233
+
234
+ const rows = listRawTranscriptRange(db, "sess-pipe", 0, 100);
235
+ assert.equal(rows.length, 5);
236
+
237
+ closeStore(dir);
238
+ rmSync(dir, { recursive: true, force: true });
239
+ });
240
+ });
package/src/recall.ts CHANGED
@@ -84,10 +84,48 @@ export function formatRecallBlock(hits: SearchHit[]): string {
84
84
  * it records injections via `markInjected` so the next call dedupes. The
85
85
  * `store` is passed by the extension (defaults to the engine's default store).
86
86
  */
87
+ /**
88
+ * Recall and inline context from the checkpoint store.
89
+ *
90
+ * S27 contract — DB-Mirror demotion:
91
+ *
92
+ * When `MEGACOMPACT_DB_MIRROR` is ON, the `raw_transcript` table is the
93
+ * canonical, byte-stable source of truth for message reconstruction. The
94
+ * `dedup_mirror` provides space-efficient storage with ref_count tracking
95
+ * (see src/mirror/dedup.ts). The legacy JSON checkpoint is retained as a
96
+ * DR snapshot only (see src/store.ts checkpoint helpers).
97
+ *
98
+ * The recall function continues to work from the VectorStore (checkpoint
99
+ * summaries + embeddings) for fast semantic search — this path is unaffected
100
+ * by the mirror flag. If full transcript reconstruction is ever needed
101
+ * (replay, export, debug), prefer reading from `raw_transcript + dedup_mirror`
102
+ * via `listRawTranscriptRange()` + `dedupTranscript()` instead of the legacy
103
+ * JSON checkpoint. Falls back to legacy checkpoint if mirror is empty
104
+ * (pre-migration sessions).
105
+ *
106
+ * Pi-agnostic: no pi runtime imports (src/ invariant).
107
+ */
87
108
  export function recallAndInline(
88
109
  opts: RecallInjectOptions,
89
110
  store: Pick<VectorStore, "search" | "wasInjected" | "markInjected">,
90
111
  ): RecallInjectResult {
112
+ // ── S27 Recall Demotion ─────────────────────────────────────────────
113
+ //
114
+ // When MEGACOMPACT_DB_MIRROR is ON, the raw_transcript + dedup_mirror
115
+ // tables are preferred for byte-stable reconstruction. The current
116
+ // recall path (VectorStore search → format → inject) is unaffected —
117
+ // it provides fast semantic search over checkpoint summaries.
118
+ //
119
+ // If full transcript reconstruction is ever needed (replay, export,
120
+ // debug), call reconstructFromMirror(db, sessionId, fromSeq, toSeq)
121
+ // from src/mirror/dedup.ts instead of reading from the legacy JSON
122
+ // checkpoint. Falls back to legacy checkpoint if mirror is empty
123
+ // (pre-migration sessions).
124
+ //
125
+ // Invariant: raw_transcript + dedup_mirror are additive and never
126
+ // lose data. The legacy JSON checkpoint remains as a DR snapshot.
127
+ // ─────────────────────────────────────────────────────────────────────
128
+
91
129
  const limit = opts.limit ?? 3;
92
130
  const skip = opts.skipInjected ?? true;
93
131
  const maxTokens = opts.recallMaxTokens ?? 0; // 0 = unbounded (legacy behavior)