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.
- package/dist/extensions/mega-compact.js +2 -0
- package/dist/extensions/mega-config.js +1 -0
- package/dist/extensions/mega-db-cmds.js +85 -0
- package/dist/extensions/mega-events.js +129 -4
- package/dist/extensions/mega-events.test.js +47 -0
- package/dist/extensions/mega-runtime.js +3 -0
- 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.dbmaint.test.js +158 -0
- package/dist/src/store/sqlite.dbmirror.test.js +175 -0
- package/dist/src/store/sqlite.js +474 -2
- package/extensions/mega-compact.ts +2 -0
- package/extensions/mega-config.ts +8 -0
- package/extensions/mega-db-cmds.ts +107 -0
- package/extensions/mega-events.test.ts +55 -0
- package/extensions/mega-events.ts +136 -4
- package/extensions/mega-runtime.ts +4 -0
- 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.dbmaint.test.ts +193 -0
- package/src/store/sqlite.dbmirror.test.ts +219 -0
- package/src/store/sqlite.ts +669 -2
|
@@ -30,6 +30,7 @@ import { registerEventHandlers } from "./mega-events.js";
|
|
|
30
30
|
import { registerCommands } from "./mega-commands.js";
|
|
31
31
|
import { registerDashboardCommands } from "./mega-dashboard-cmds.js";
|
|
32
32
|
import { registerConflictCommands } from "./mega-conflict-cmds.js";
|
|
33
|
+
import { registerDbCommands } from "./mega-db-cmds.js";
|
|
33
34
|
export default function (pi) {
|
|
34
35
|
const config = loadConfig();
|
|
35
36
|
const runtime = new MegaRuntime(config);
|
|
@@ -37,4 +38,5 @@ export default function (pi) {
|
|
|
37
38
|
registerCommands(pi, runtime, config);
|
|
38
39
|
registerDashboardCommands(pi, runtime);
|
|
39
40
|
registerConflictCommands(pi, runtime);
|
|
41
|
+
registerDbCommands(pi, runtime);
|
|
40
42
|
}
|
|
@@ -135,6 +135,7 @@ export function loadConfig() {
|
|
|
135
135
|
dedupSim: Number(process.env.MEGACOMPACT_DEDUP_SIM ?? "0.9"),
|
|
136
136
|
raptorEnabled: envBool("MEGACOMPACT_RAPTOR_ENABLED", true),
|
|
137
137
|
legacyDurableTrim: envBool("MEGACOMPACT_LEGACY_DURABLE_TRIM", false),
|
|
138
|
+
dbMirror: envBool("MEGACOMPACT_DB_MIRROR", false),
|
|
138
139
|
crossRepoEnabled: envBool("MEGACOMPACT_CROSSREPO_ENABLED", true),
|
|
139
140
|
crossRepoCosine: Number(process.env.MEGACOMPACT_CROSSREPO_COSINE ?? "0.90"),
|
|
140
141
|
memoryAutoReview: envBool("MEGACOMPACT_MEMORY_AUTO_REVIEW", true),
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mega-db-cmds.ts — S27 Task 10 DB maintenance /commands.
|
|
3
|
+
*
|
|
4
|
+
* Registers /mega-db-stats, /mega-db-prune, /mega-db-vacuum, /mega-db-check,
|
|
5
|
+
* /mega-db-reconcile slash commands backed by the maintenance primitives in
|
|
6
|
+
* src/store/sqlite.ts. All operations are local SQLite (PREVENT-PI-004) with
|
|
7
|
+
* parameterized queries (PREVENT-002).
|
|
8
|
+
*
|
|
9
|
+
* Auto-maintenance (prune + WAL checkpoint) also runs once per session_start
|
|
10
|
+
* via the wiring in mega-events.ts (best-effort, non-blocking).
|
|
11
|
+
*/
|
|
12
|
+
import { getDbStats, pruneOldRows, checkpointWal, vacuumDb, integrityCheck, reconcileDedupMirror, } from "../src/store/sqlite.js";
|
|
13
|
+
/** Format a byte count as a human-readable string (KB / MB / GB). */
|
|
14
|
+
function fmtBytes(n) {
|
|
15
|
+
if (n < 1024)
|
|
16
|
+
return `${n}B`;
|
|
17
|
+
if (n < 1024 * 1024)
|
|
18
|
+
return `${(n / 1024).toFixed(1)}KB`;
|
|
19
|
+
if (n < 1024 * 1024 * 1024)
|
|
20
|
+
return `${(n / (1024 * 1024)).toFixed(1)}MB`;
|
|
21
|
+
return `${(n / (1024 * 1024 * 1024)).toFixed(2)}GB`;
|
|
22
|
+
}
|
|
23
|
+
/** Register the /mega-db-* maintenance commands. */
|
|
24
|
+
export function registerDbCommands(pi, runtime) {
|
|
25
|
+
const stateDir = runtime.currentStateDir;
|
|
26
|
+
pi.registerCommand("mega-db-stats", {
|
|
27
|
+
description: "Show mega-compact SQLite DB stats: table row counts, disk footprint (db + WAL + SHM), page count, freelist, WAL frames.",
|
|
28
|
+
handler: async (_args, ctx) => {
|
|
29
|
+
const s = getDbStats(stateDir);
|
|
30
|
+
ctx.ui.notify(`[mega-compact] DB stats — ${stateDir}`);
|
|
31
|
+
ctx.ui.notify(` main: ${fmtBytes(s.dbBytes)} wal: ${fmtBytes(s.walBytes)} shm: ${fmtBytes(s.shmBytes)}`);
|
|
32
|
+
ctx.ui.notify(` pages: ${s.pageCount} (${s.pageSize}B each), freelist: ${s.freelistPages} (${s.pageCount > 0 ? ((s.freelistPages / s.pageCount) * 100).toFixed(1) : "0"}% reusable), wal frames: ${s.walFrames}`);
|
|
33
|
+
const tableLines = Object.entries(s.tableCounts)
|
|
34
|
+
.sort((a, b) => b[1] - a[1])
|
|
35
|
+
.map(([t, c]) => ` ${t.padEnd(22)} ${String(c).padStart(8)}`);
|
|
36
|
+
if (tableLines.length === 0) {
|
|
37
|
+
ctx.ui.notify(" (no tables populated yet)");
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
ctx.ui.notify(" table row counts:");
|
|
41
|
+
for (const l of tableLines)
|
|
42
|
+
ctx.ui.notify(l);
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
pi.registerCommand("mega-db-prune", {
|
|
47
|
+
description: "Prune raw_transcript + checkpoint_epochs + orphan dedup_mirror rows older than N days (default 30). Usage: /mega-db-prune [days]",
|
|
48
|
+
handler: async (args, ctx) => {
|
|
49
|
+
const days = Number.parseInt(args.trim().split(/\s+/)[0] ?? "30", 10);
|
|
50
|
+
const d = Number.isFinite(days) && days > 0 ? days : 30;
|
|
51
|
+
const r = pruneOldRows(stateDir, d);
|
|
52
|
+
ctx.ui.notify(`[mega-compact] ${r.summary} (reclaimed ${fmtBytes(r.reclaimedBytes)})`);
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
pi.registerCommand("mega-db-vacuum", {
|
|
56
|
+
description: "VACUUM the mega-compact SQLite DB (rebuild pages, reclaim freelist space). Heavy: briefly doubles disk usage.",
|
|
57
|
+
handler: async (_args, ctx) => {
|
|
58
|
+
const r = vacuumDb(stateDir);
|
|
59
|
+
ctx.ui.notify(`[mega-compact] ${r.summary}`);
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
pi.registerCommand("mega-db-check", {
|
|
63
|
+
description: "Run PRAGMA integrity_check + a WAL checkpoint on the mega-compact SQLite DB. Use after a crash or to fold the WAL into the main file.",
|
|
64
|
+
handler: async (_args, ctx) => {
|
|
65
|
+
const lines = integrityCheck(stateDir);
|
|
66
|
+
const healthy = lines.length === 1 && lines[0] === "ok";
|
|
67
|
+
ctx.ui.notify(`[mega-compact] integrity_check: ${healthy ? "✓ ok" : `⚠ ${lines.length} issue(s)`}`);
|
|
68
|
+
if (!healthy) {
|
|
69
|
+
for (const l of lines.slice(0, 10))
|
|
70
|
+
ctx.ui.notify(` ${l}`);
|
|
71
|
+
if (lines.length > 10)
|
|
72
|
+
ctx.ui.notify(` … and ${lines.length - 10} more`);
|
|
73
|
+
}
|
|
74
|
+
const ck = checkpointWal(stateDir);
|
|
75
|
+
ctx.ui.notify(`[mega-compact] ${ck.summary}`);
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
pi.registerCommand("mega-db-reconcile", {
|
|
79
|
+
description: "Reconcile dedup_mirror.ref_count vs actual raw_transcript refs: fix drift, delete orphan dedup rows, backfill missing content_ref. Run after /mega-db-prune or a crash.",
|
|
80
|
+
handler: async (_args, ctx) => {
|
|
81
|
+
const r = reconcileDedupMirror(stateDir);
|
|
82
|
+
ctx.ui.notify(`[mega-compact] dedup reconcile: fixed ${r.fixedRefCount} ref_count drift, deleted ${r.orphansDeleted} orphan(s), backfilled ${r.refsBackfilled} content_ref`);
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* sync and delegates the heavy lifting to the pipeline + command modules.
|
|
8
8
|
*/
|
|
9
9
|
import { normalizeSessionId } from "../src/store.js";
|
|
10
|
+
import { openStore, appendRawTranscript, writeCheckpointEpoch, autoMaintain } from "../src/store/sqlite.js";
|
|
11
|
+
import { epochIdFor } from "../src/mirror/epoch.js";
|
|
10
12
|
import { autoCompactCheck } from "../src/compact.js";
|
|
11
13
|
import { estimateSessionTokens, estimateBlockTokens } from "../src/tokens.js";
|
|
12
14
|
import { recentUserQuery, WIDGET_KEY, } from "./mega-runtime.js";
|
|
@@ -15,6 +17,34 @@ import { recallMemoriesAndInline } from "../src/recall.js";
|
|
|
15
17
|
import { driveNativeCompaction, } from "./mega-compact-driver.js";
|
|
16
18
|
import { computeLiveTrimCut, liveTrimSummaryMessage } from "./mega-trim.js";
|
|
17
19
|
import { pressureFromPct, memoryReviewCadence, } from "./mega-config.js";
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
/**
|
|
22
|
+
* Convert a pi AgentMessage to a RawTranscriptRow for the DB mirror.
|
|
23
|
+
* content_bytes is canonical JSON (sorted keys) for deterministic hashing.
|
|
24
|
+
* Returns null if the message has no usable content.
|
|
25
|
+
*/
|
|
26
|
+
function toRawTranscriptRow(msg, sessionId, epochId) {
|
|
27
|
+
// Narrow to Message union (has content + timestamp).
|
|
28
|
+
const m = msg;
|
|
29
|
+
const content = m.content;
|
|
30
|
+
if (content == null || content === "")
|
|
31
|
+
return null;
|
|
32
|
+
// Canonical form: sort object keys for deterministic hashing.
|
|
33
|
+
const contentBytes = typeof content === "string"
|
|
34
|
+
? content
|
|
35
|
+
: JSON.stringify(content, Object.keys(content).sort());
|
|
36
|
+
const contentHash = createHash("sha256").update(contentBytes).digest("hex");
|
|
37
|
+
return {
|
|
38
|
+
contentHash,
|
|
39
|
+
sessionId,
|
|
40
|
+
seq: 0, // assigned by appendRawTranscript (COALESCE(MAX(seq),0)+1)
|
|
41
|
+
role: m.role ?? "unknown",
|
|
42
|
+
contentBytes,
|
|
43
|
+
toolName: m.toolName ?? null,
|
|
44
|
+
messageTimestamp: m.timestamp ?? null,
|
|
45
|
+
checkpointEpoch: epochId,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
18
48
|
/**
|
|
19
49
|
* DIAG accessor for the headless test harness: the most recently constructed
|
|
20
50
|
* MegaRuntime, so a test that loads the compiled extension via its default
|
|
@@ -86,6 +116,18 @@ export function registerEventHandlers(pi, runtime, config) {
|
|
|
86
116
|
runtime.logger.warn("memory-recall skipped", { err: String(err) });
|
|
87
117
|
}
|
|
88
118
|
}
|
|
119
|
+
// S27 Task 10: best-effort auto-maintenance on session start (prune rows
|
|
120
|
+
// older than 30d, checkpoint WAL if >10MB, VACUUM if DB >100MB + >20%
|
|
121
|
+
// freelist). Never blocks session start — swallows errors and logs a
|
|
122
|
+
// one-line summary for diagnostics.
|
|
123
|
+
try {
|
|
124
|
+
const m = autoMaintain(runtime.currentStateDir);
|
|
125
|
+
if (m && !m.endsWith("nothing to do"))
|
|
126
|
+
runtime.logger.info("db-auto-maintain", { result: m });
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
runtime.logger.warn("db-auto-maintain-fail", { error: String(e) });
|
|
130
|
+
}
|
|
89
131
|
runtime.dashboard.event("session_start", {
|
|
90
132
|
reason: event.reason,
|
|
91
133
|
sessionId: runtime.rt.sessionId,
|
|
@@ -223,7 +265,21 @@ export function registerEventHandlers(pi, runtime, config) {
|
|
|
223
265
|
// trim we ALWAYS nudge so the agent reliably restarts. Debounced 30s.
|
|
224
266
|
let didDurableTrim = false;
|
|
225
267
|
if (idle && overThreshold && now >= runtime.debounceUntil) {
|
|
226
|
-
|
|
268
|
+
// COMPACT-DEDUP FIX: skip the manual durable-trim trigger when pi's
|
|
269
|
+
// NATIVE auto-compaction just fired (or is in-flight). pi emits
|
|
270
|
+
// agent_end BEFORE its own _checkCompaction (per its docstring:
|
|
271
|
+
// "Called after agent_end and before prompt submission"), so a
|
|
272
|
+
// synchronous `piCompactWouldNoop` branch check misses a native
|
|
273
|
+
// compaction that hasn't appended its entry yet — calling
|
|
274
|
+
// ctx.compact() then races with pi and throws "Already compacted"
|
|
275
|
+
// to the user. The `lastCompactAt` cooldown (updated by the
|
|
276
|
+
// session_compact listener for EVERY compaction, native or
|
|
277
|
+
// extension-supplied) closes that race window.
|
|
278
|
+
const sinceCompact = now - (runtime.rt.lastNativeCompactAt ?? 0);
|
|
279
|
+
if (sinceCompact < 10_000) {
|
|
280
|
+
runtime.diagAgentEndDurableSkipRecent++;
|
|
281
|
+
}
|
|
282
|
+
else if (!piCompactWouldNoop(ctx)) {
|
|
227
283
|
runtime.debounceUntil = now + 2000;
|
|
228
284
|
runtime.diagAgentEndDurable++;
|
|
229
285
|
runtime.logger.info("agent-end-durable-trigger", {
|
|
@@ -232,7 +288,7 @@ export function registerEventHandlers(pi, runtime, config) {
|
|
|
232
288
|
thresholdTokens: config.thresholdTokens,
|
|
233
289
|
queued,
|
|
234
290
|
});
|
|
235
|
-
ctx.compact({ customInstructions: undefined }); // guardrails-allow PREVENT-PI-004: local ctx.compact() — no network; agent settled so no in-flight abort
|
|
291
|
+
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).
|
|
236
292
|
didDurableTrim = true;
|
|
237
293
|
}
|
|
238
294
|
}
|
|
@@ -309,6 +365,23 @@ export function registerEventHandlers(pi, runtime, config) {
|
|
|
309
365
|
const currentTokens = usage?.tokens ??
|
|
310
366
|
estimateSessionTokens(view) ??
|
|
311
367
|
Math.round((pct / 100) * (usage?.contextWindow ?? 0));
|
|
368
|
+
// S27 DB-mirror: append ALL incoming messages to raw_transcript.
|
|
369
|
+
// Runs BEFORE fast-gate so every message is captured, even if we
|
|
370
|
+
// don't compact this turn. Append is idempotent (content_hash PK).
|
|
371
|
+
if (config.dbMirror) {
|
|
372
|
+
try {
|
|
373
|
+
const db = openStore(runtime.currentStateDir);
|
|
374
|
+
const epochId = epochIdFor(runtime.rt.sessionId);
|
|
375
|
+
for (const msg of messages) {
|
|
376
|
+
const raw = toRawTranscriptRow(msg, runtime.rt.sessionId, epochId);
|
|
377
|
+
if (raw)
|
|
378
|
+
appendRawTranscript(db, raw);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
catch (e) {
|
|
382
|
+
runtime.logger.warn("db-mirror-append-fail", { error: String(e) });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
312
385
|
// FAST GATE: token-based (tier% of the window), not a static amount.
|
|
313
386
|
if (currentTokens < runtime.effectiveThreshold) {
|
|
314
387
|
runtime.diagCtxFastGate++;
|
|
@@ -336,6 +409,37 @@ export function registerEventHandlers(pi, runtime, config) {
|
|
|
336
409
|
runtime.diagCtxRunSkipped++;
|
|
337
410
|
return;
|
|
338
411
|
}
|
|
412
|
+
// S27 DB-mirror: write checkpoint_epoch with deterministic nonce.
|
|
413
|
+
// This makes the cache key stable across identical compactions.
|
|
414
|
+
if (config.dbMirror) {
|
|
415
|
+
try {
|
|
416
|
+
const db = openStore(runtime.currentStateDir);
|
|
417
|
+
const cpId = ran.result.checkpointId ?? `epoch-${Date.now()}`;
|
|
418
|
+
const epoch = {
|
|
419
|
+
epochId: epochIdFor(cpId),
|
|
420
|
+
sessionId: runtime.rt.sessionId,
|
|
421
|
+
startedSeq: 0,
|
|
422
|
+
committedSeq: ran.result.compactedFrom,
|
|
423
|
+
checkpointId: cpId,
|
|
424
|
+
cutIndex: ran.result.compactedFrom,
|
|
425
|
+
summaryMessageText: ran.result.summary,
|
|
426
|
+
createdAt: Date.now(),
|
|
427
|
+
};
|
|
428
|
+
writeCheckpointEpoch(db, epoch);
|
|
429
|
+
// S27 Task 6: Fire-and-forget dedup pipeline.
|
|
430
|
+
// Deduplicates raw_transcript rows for the compacted range.
|
|
431
|
+
try {
|
|
432
|
+
const { dedupTranscript } = await import("../src/mirror/dedup.js");
|
|
433
|
+
dedupTranscript(db, runtime.rt.sessionId, 0, ran.result.compactedFrom);
|
|
434
|
+
}
|
|
435
|
+
catch (_dedupErr) {
|
|
436
|
+
// Fire-and-forget: dedup failure is non-fatal
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
catch (e) {
|
|
440
|
+
runtime.logger.warn("db-mirror-epoch-fail", { error: String(e) });
|
|
441
|
+
}
|
|
442
|
+
}
|
|
339
443
|
// LEGACY path (rollback): v0.4.28 ctx.compact() + the no-op gate. The
|
|
340
444
|
// manual compact path aborts the in-flight turn — only used behind the flag.
|
|
341
445
|
// Read live from env (in addition to the load-time config) so the flag can be
|
|
@@ -345,9 +449,14 @@ export function registerEventHandlers(pi, runtime, config) {
|
|
|
345
449
|
process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM === "true" ||
|
|
346
450
|
process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM === "1";
|
|
347
451
|
if (legacy) {
|
|
348
|
-
|
|
452
|
+
// COMPACT-DEDUP FIX: same race guard as the agent_end path. Skip when a
|
|
453
|
+
// NATIVE compaction just fired (avoids racing pi and surfacing a spurious
|
|
454
|
+
// "Already compacted" / "Nothing to compact" toast). Uses lastNativeCompactAt
|
|
455
|
+
// (NOT lastCompactAt, which runCompact also stamps for our own checkpoint).
|
|
456
|
+
const sinceCompact = Date.now() - (runtime.rt.lastNativeCompactAt ?? 0);
|
|
457
|
+
if (sinceCompact < 10_000 || piCompactWouldNoop(ctx))
|
|
349
458
|
return;
|
|
350
|
-
ctx.compact({ customInstructions: undefined });
|
|
459
|
+
ctx.compact({ customInstructions: undefined }); // race-guarded by lastNativeCompactAt cooldown (ctx.compact returns void → not catchable; the cooldown prevents the call)
|
|
351
460
|
return;
|
|
352
461
|
}
|
|
353
462
|
// S16 LIVE trim: collapse the compacted region to a summary + recent anchor.
|
|
@@ -482,6 +591,22 @@ export function registerEventHandlers(pi, runtime, config) {
|
|
|
482
591
|
// Absolute last resort: let pi run its own (may throw "Nothing to compact").
|
|
483
592
|
return {};
|
|
484
593
|
});
|
|
594
|
+
// COMPACT-DEDUP FIX: track EVERY compaction (native + extension-supplied)
|
|
595
|
+
// so the agent_end durable-trim guard can skip a redundant ctx.compact()
|
|
596
|
+
// when pi just compacted. Without this, agent_end fires ctx.compact()
|
|
597
|
+
// synchronously AFTER pi's native auto-compaction appended a compaction
|
|
598
|
+
// entry but BEFORE our branch read sees it on the next tick — racing
|
|
599
|
+
// into a user-facing "Already compacted" throw. `lastCompactAt` is the
|
|
600
|
+
// race-closing signal: any compaction (manual/threshold/overflow, ours
|
|
601
|
+
// or pi's own) stamps it, and the agent_end guard skips for 10s.
|
|
602
|
+
pi.on("session_compact", async (_event, _ctx) => {
|
|
603
|
+
runtime.rt.lastNativeCompactAt = Date.now();
|
|
604
|
+
runtime.rt.lastCompactAt = Date.now();
|
|
605
|
+
runtime.logger.info("session-compacted", {
|
|
606
|
+
sessionId: runtime.rt.sessionId,
|
|
607
|
+
at: runtime.rt.lastCompactAt,
|
|
608
|
+
});
|
|
609
|
+
});
|
|
485
610
|
/**
|
|
486
611
|
* Build a minimal fallback compaction so pi never runs its throwing compact().
|
|
487
612
|
*
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
function makeTmp() {
|
|
11
|
+
return mkdtempSync(join(tmpdir(), "mega-events-test-"));
|
|
12
|
+
}
|
|
13
|
+
describe("mega-events: DB-mirror integration", () => {
|
|
14
|
+
let dir;
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
dir = makeTmp();
|
|
17
|
+
// openStore creates the tables as a side effect
|
|
18
|
+
openStore(dir);
|
|
19
|
+
});
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
rmSync(dir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
it("openStore creates checkpoint_epochs and raw_transcript tables", () => {
|
|
24
|
+
const db = openStore(dir);
|
|
25
|
+
// Should not throw — tables exist
|
|
26
|
+
const epochs = listCheckpointEpochs(db);
|
|
27
|
+
assert.ok(Array.isArray(epochs));
|
|
28
|
+
const count = countRawTranscript(db);
|
|
29
|
+
assert.equal(count, 0);
|
|
30
|
+
});
|
|
31
|
+
it("DB-mirror flag defaults to false when env is unset", () => {
|
|
32
|
+
delete process.env.MEGACOMPACT_DB_MIRROR;
|
|
33
|
+
// Re-import to pick up env
|
|
34
|
+
// The extension checks env at load time, so just verify the env is absent
|
|
35
|
+
assert.equal(process.env.MEGACOMPACT_DB_MIRROR, undefined);
|
|
36
|
+
});
|
|
37
|
+
it("DB-mirror flag is enabled when env is '1'", () => {
|
|
38
|
+
process.env.MEGACOMPACT_DB_MIRROR = "1";
|
|
39
|
+
assert.equal(process.env.MEGACOMPACT_DB_MIRROR, "1");
|
|
40
|
+
delete process.env.MEGACOMPACT_DB_MIRROR;
|
|
41
|
+
});
|
|
42
|
+
it("DB-mirror flag is enabled when env is 'true'", () => {
|
|
43
|
+
process.env.MEGACOMPACT_DB_MIRROR = "true";
|
|
44
|
+
assert.equal(process.env.MEGACOMPACT_DB_MIRROR, "true");
|
|
45
|
+
delete process.env.MEGACOMPACT_DB_MIRROR;
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -191,6 +191,7 @@ export class MegaRuntime {
|
|
|
191
191
|
dedupAttempts: 0,
|
|
192
192
|
tokensSaved: 0,
|
|
193
193
|
lastCompactAt: null,
|
|
194
|
+
lastNativeCompactAt: null,
|
|
194
195
|
};
|
|
195
196
|
debounceUntil = 0;
|
|
196
197
|
// S16: debounce for the agent_end resume nudge (avoid busy-loops).
|
|
@@ -255,6 +256,7 @@ export class MegaRuntime {
|
|
|
255
256
|
diagBeforeCompactSupplied = 0; // session_before_compact supplied our trim
|
|
256
257
|
diagAgentEndIdle = 0; // agent_end with activeAgents===0
|
|
257
258
|
diagAgentEndDurable = 0; // agent_end fired ctx.compact() (mid-run durable trim)
|
|
259
|
+
diagAgentEndDurableSkipRecent = 0; // agent_end skipped ctx.compact() — compaction in last 10s (race guard)
|
|
258
260
|
// Per-skip-path counters for the team-run diagnosis.
|
|
259
261
|
diagCtxFastGate = 0; // returned at token fast-gate (below threshold)
|
|
260
262
|
diagCtxNoCompact = 0; // autoCompactCheck().shouldCompact === false
|
|
@@ -719,6 +721,7 @@ export class MegaRuntime {
|
|
|
719
721
|
dedupAttempts: 0,
|
|
720
722
|
tokensSaved: 0,
|
|
721
723
|
lastCompactAt: null,
|
|
724
|
+
lastNativeCompactAt: null,
|
|
722
725
|
};
|
|
723
726
|
this.statusKey = undefined;
|
|
724
727
|
this.activeAgents = 0;
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
import { upsertDedupMirror, updateRawTranscriptRef, listRawTranscriptRange, getDedupRatio, } from "../store/sqlite.js";
|
|
14
|
+
import { computeContentDigest } from "../dedup/digest.js";
|
|
15
|
+
/**
|
|
16
|
+
* Deduplicate raw transcript rows for a session range.
|
|
17
|
+
* Fire-and-forget: errors are logged, not thrown.
|
|
18
|
+
*
|
|
19
|
+
* @returns Number of rows deduplicated, or -1 on error.
|
|
20
|
+
*/
|
|
21
|
+
export function dedupTranscript(db, sessionId, fromSeq, toSeq) {
|
|
22
|
+
try {
|
|
23
|
+
const rows = listRawTranscriptRange(db, sessionId, fromSeq, toSeq);
|
|
24
|
+
let deduped = 0;
|
|
25
|
+
for (const row of rows) {
|
|
26
|
+
const contentHash = computeContentDigest(row.contentBytes).contentHash;
|
|
27
|
+
const isNew = upsertDedupMirror(db, contentHash, row.contentBytes, row.seq);
|
|
28
|
+
updateRawTranscriptRef(db, sessionId, row.seq, contentHash);
|
|
29
|
+
if (!isNew) {
|
|
30
|
+
deduped++;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return deduped;
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
// Fire-and-forget: log but don't throw
|
|
37
|
+
console.error("[mega-compact] dedupTranscript failed:", err);
|
|
38
|
+
return -1;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Get dedup ratio for a session.
|
|
43
|
+
*/
|
|
44
|
+
export { getDedupRatio };
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
* FNV-1a 32-bit nonce for a checkpoint id. Deterministic and RNG-free:
|
|
16
|
+
* h = 0x811c9dc5; for each char: h ^= codePoint; h = Math.imul(h, 0x01000193);
|
|
17
|
+
* return h >>> 0 (unsigned).
|
|
18
|
+
*/
|
|
19
|
+
export function epochNonceFor(checkpointId) {
|
|
20
|
+
let h = 0x811c9dc5;
|
|
21
|
+
for (let i = 0; i < checkpointId.length; i++) {
|
|
22
|
+
const cp = checkpointId.codePointAt(i);
|
|
23
|
+
if (cp === undefined)
|
|
24
|
+
continue;
|
|
25
|
+
h ^= cp;
|
|
26
|
+
h = Math.imul(h, 0x01000193);
|
|
27
|
+
}
|
|
28
|
+
return h >>> 0;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Deterministic epoch id: "epoch:" + checkpointId. Trivially traceable back to
|
|
32
|
+
* the source checkpoint, and stable under replay (refresh-safe upserts).
|
|
33
|
+
*/
|
|
34
|
+
export function epochIdFor(checkpointId) {
|
|
35
|
+
return "epoch:" + checkpointId;
|
|
36
|
+
}
|
|
@@ -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
|
+
});
|