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.
@@ -75,6 +75,13 @@ export interface MegaConfig {
75
75
  * trim + pi native auto-compaction instead (compact and continue). Kept for
76
76
  * one release as rollback. */
77
77
  legacyDurableTrim: boolean;
78
+ /** S27: durable raw-transcript DB mirror (MEGACOMPACT_DB_MIRROR). When on,
79
+ * raw message bytes + checkpoint-epoch bookkeeping are appended to the
80
+ * SQLite store so a compacted window can be rehydrated locally instead of
81
+ * from the pi runtime transcript. Default OFF — additive, no behavior
82
+ * change until flipped on. legacyDurableTrim takes precedence (the legacy
83
+ * v0.4.28 ctx.compact() path does not emit the S27 mirror hook). */
84
+ dbMirror: boolean;
78
85
  /** Cross-repo recall enabled (S17). Resume + /mega-recall --cross-repo can
79
86
  * pull checkpoints from OTHER repos via the PGlite HNSW index. Default true. */
80
87
  crossRepoEnabled: boolean;
@@ -214,6 +221,7 @@ export function loadConfig(): MegaConfig {
214
221
  dedupSim: Number(process.env.MEGACOMPACT_DEDUP_SIM ?? "0.9"),
215
222
  raptorEnabled: envBool("MEGACOMPACT_RAPTOR_ENABLED", true),
216
223
  legacyDurableTrim: envBool("MEGACOMPACT_LEGACY_DURABLE_TRIM", false),
224
+ dbMirror: envBool("MEGACOMPACT_DB_MIRROR", false),
217
225
  crossRepoEnabled: envBool("MEGACOMPACT_CROSSREPO_ENABLED", true),
218
226
  crossRepoCosine: Number(process.env.MEGACOMPACT_CROSSREPO_COSINE ?? "0.90"),
219
227
  memoryAutoReview: envBool("MEGACOMPACT_MEMORY_AUTO_REVIEW", true),
@@ -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, 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
@@ -271,7 +307,20 @@ export function registerEventHandlers(
271
307
  // trim we ALWAYS nudge so the agent reliably restarts. Debounced 30s.
272
308
  let didDurableTrim = false;
273
309
  if (idle && overThreshold && now >= runtime.debounceUntil) {
274
- if (!piCompactWouldNoop(ctx)) {
310
+ // COMPACT-DEDUP FIX: skip the manual durable-trim trigger when pi's
311
+ // NATIVE auto-compaction just fired (or is in-flight). pi emits
312
+ // agent_end BEFORE its own _checkCompaction (per its docstring:
313
+ // "Called after agent_end and before prompt submission"), so a
314
+ // synchronous `piCompactWouldNoop` branch check misses a native
315
+ // compaction that hasn't appended its entry yet — calling
316
+ // ctx.compact() then races with pi and throws "Already compacted"
317
+ // to the user. The `lastCompactAt` cooldown (updated by the
318
+ // session_compact listener for EVERY compaction, native or
319
+ // extension-supplied) closes that race window.
320
+ const sinceCompact = now - (runtime.rt.lastNativeCompactAt ?? 0);
321
+ if (sinceCompact < 10_000) {
322
+ runtime.diagAgentEndDurableSkipRecent++;
323
+ } else if (!piCompactWouldNoop(ctx)) {
275
324
  runtime.debounceUntil = now + 2000;
276
325
  runtime.diagAgentEndDurable++;
277
326
  runtime.logger.info("agent-end-durable-trigger", {
@@ -280,7 +329,7 @@ export function registerEventHandlers(
280
329
  thresholdTokens: config.thresholdTokens,
281
330
  queued,
282
331
  });
283
- ctx.compact({ customInstructions: undefined }); // guardrails-allow PREVENT-PI-004: local ctx.compact() — no network; agent settled so no in-flight abort
332
+ 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
333
  didDurableTrim = true;
285
334
  }
286
335
  }
@@ -370,6 +419,22 @@ export function registerEventHandlers(
370
419
  estimateSessionTokens(view) ??
371
420
  Math.round((pct / 100) * (usage?.contextWindow ?? 0));
372
421
 
422
+ // S27 DB-mirror: append ALL incoming messages to raw_transcript.
423
+ // Runs BEFORE fast-gate so every message is captured, even if we
424
+ // don't compact this turn. Append is idempotent (content_hash PK).
425
+ if (config.dbMirror) {
426
+ try {
427
+ const db = openStore(runtime.currentStateDir);
428
+ const epochId = epochIdFor(runtime.rt.sessionId);
429
+ for (const msg of messages) {
430
+ const raw = toRawTranscriptRow(msg, runtime.rt.sessionId, epochId);
431
+ if (raw) appendRawTranscript(db, raw);
432
+ }
433
+ } catch (e) {
434
+ runtime.logger.warn("db-mirror-append-fail", { error: String(e) });
435
+ }
436
+ }
437
+
373
438
  // FAST GATE: token-based (tier% of the window), not a static amount.
374
439
  if (currentTokens < runtime.effectiveThreshold) {
375
440
  runtime.diagCtxFastGate++;
@@ -401,6 +466,41 @@ export function registerEventHandlers(
401
466
  return;
402
467
  }
403
468
 
469
+ // S27 DB-mirror: write checkpoint_epoch with deterministic nonce.
470
+ // This makes the cache key stable across identical compactions.
471
+ if (config.dbMirror) {
472
+ try {
473
+ const db = openStore(runtime.currentStateDir);
474
+ const cpId = ran.result.checkpointId ?? `epoch-${Date.now()}`;
475
+ const epoch: CheckpointEpoch = {
476
+ epochId: epochIdFor(cpId),
477
+ sessionId: runtime.rt.sessionId,
478
+ startedSeq: 0,
479
+ committedSeq: ran.result.compactedFrom,
480
+ checkpointId: cpId,
481
+ cutIndex: ran.result.compactedFrom,
482
+ summaryMessageText: ran.result.summary,
483
+ createdAt: Date.now(),
484
+ };
485
+ writeCheckpointEpoch(db, epoch);
486
+ // S27 Task 6: Fire-and-forget dedup pipeline.
487
+ // Deduplicates raw_transcript rows for the compacted range.
488
+ try {
489
+ const { dedupTranscript } = await import("../src/mirror/dedup.js");
490
+ dedupTranscript(
491
+ db,
492
+ runtime.rt.sessionId,
493
+ 0,
494
+ ran.result.compactedFrom,
495
+ );
496
+ } catch (_dedupErr) {
497
+ // Fire-and-forget: dedup failure is non-fatal
498
+ }
499
+ } catch (e) {
500
+ runtime.logger.warn("db-mirror-epoch-fail", { error: String(e) });
501
+ }
502
+ }
503
+
404
504
  // LEGACY path (rollback): v0.4.28 ctx.compact() + the no-op gate. The
405
505
  // manual compact path aborts the in-flight turn — only used behind the flag.
406
506
  // Read live from env (in addition to the load-time config) so the flag can be
@@ -411,8 +511,13 @@ export function registerEventHandlers(
411
511
  process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM === "true" ||
412
512
  process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM === "1";
413
513
  if (legacy) {
414
- if (piCompactWouldNoop(ctx)) return;
415
- ctx.compact({ customInstructions: undefined });
514
+ // COMPACT-DEDUP FIX: same race guard as the agent_end path. Skip when a
515
+ // NATIVE compaction just fired (avoids racing pi and surfacing a spurious
516
+ // "Already compacted" / "Nothing to compact" toast). Uses lastNativeCompactAt
517
+ // (NOT lastCompactAt, which runCompact also stamps for our own checkpoint).
518
+ const sinceCompact = Date.now() - (runtime.rt.lastNativeCompactAt ?? 0);
519
+ if (sinceCompact < 10_000 || piCompactWouldNoop(ctx)) return;
520
+ ctx.compact({ customInstructions: undefined }); // race-guarded by lastNativeCompactAt cooldown (ctx.compact returns void → not catchable; the cooldown prevents the call)
416
521
  return;
417
522
  }
418
523
 
@@ -551,6 +656,23 @@ export function registerEventHandlers(
551
656
  },
552
657
  );
553
658
 
659
+ // COMPACT-DEDUP FIX: track EVERY compaction (native + extension-supplied)
660
+ // so the agent_end durable-trim guard can skip a redundant ctx.compact()
661
+ // when pi just compacted. Without this, agent_end fires ctx.compact()
662
+ // synchronously AFTER pi's native auto-compaction appended a compaction
663
+ // entry but BEFORE our branch read sees it on the next tick — racing
664
+ // into a user-facing "Already compacted" throw. `lastCompactAt` is the
665
+ // race-closing signal: any compaction (manual/threshold/overflow, ours
666
+ // or pi's own) stamps it, and the agent_end guard skips for 10s.
667
+ pi.on("session_compact", async (_event: SessionCompactEvent, _ctx: ExtensionContext) => {
668
+ runtime.rt.lastNativeCompactAt = Date.now();
669
+ runtime.rt.lastCompactAt = Date.now();
670
+ runtime.logger.info("session-compacted", {
671
+ sessionId: runtime.rt.sessionId,
672
+ at: runtime.rt.lastCompactAt,
673
+ });
674
+ });
675
+
554
676
  /**
555
677
  * Build a minimal fallback compaction so pi never runs its throwing compact().
556
678
  *