pi-mega-compact 0.6.0 → 0.6.1

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/README.md CHANGED
@@ -6,7 +6,7 @@ sessions into a **local SQLite store** and offers **deduped inline recall** —
6
6
  running **locally inside the extension**, with **no remote MCP server** and
7
7
  **zero network calls at runtime** (PREVENT-PI-004).
8
8
 
9
- > **Current version:** `v0.6.0` — storage backend is **`node:sqlite`**
9
+ > **Current version:** `v0.6.1` — storage backend is **`node:sqlite`**
10
10
  > (`DatabaseSync`, a Node ≥22.13 built-in), replacing the old `better-sqlite3`
11
11
  > native addon and the per-session gzipped JSON checkpoint files. **Zero native
12
12
  > build step, fully local, zero network at runtime.** Legacy
@@ -408,6 +408,54 @@ test("explicit MEGACOMPACT_THRESHOLD_TOKENS overrides the tier", async () => {
408
408
  delete process.env.MEGACOMPACT_TIER;
409
409
  assert.ok(h.notifies.some((n) => n.includes("preset=custom") && n.includes("threshold=777")), "explicit threshold wins over tier (preset=custom)");
410
410
  });
411
+ // ---- S24: memory review tied to pressure / compaction -----------------------
412
+ // Build a decision-bearing session large enough to guarantee a real (non-skipped,
413
+ // non-deduped) compaction. Each user turn contains a decision phrase
414
+ // (/\bactually\b/i, /\bwe (?:use|decided)\b/i) so reviewConversation yields ops.
415
+ function decisionSession() {
416
+ const out = [];
417
+ for (let i = 0; i < 14; i++) {
418
+ out.push({ role: "user", content: `actually we decided to use approach ${i} for module ${i}`, timestamp: i });
419
+ out.push({ role: "assistant", content: [{ type: "toolCall", name: "Edit", id: `c${i}`, arguments: {} }], api: "anthropic-messages", provider: "anthropic", model: "m", usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, stopReason: "tool_use", timestamp: i });
420
+ out.push({ role: "toolResult", content: [{ type: "text", text: `edited module ${i}` }], toolCallId: `c${i}`, toolName: "Edit", isError: false, timestamp: i });
421
+ }
422
+ return out;
423
+ }
424
+ test("S24: high pressure triggers a memory review on compaction", async () => {
425
+ const h = harness();
426
+ // Force a real (non-legacy) compaction at full pressure → pressureBand "mega",
427
+ // which must fire the shared runMemoryReview on compact (review-on-compact).
428
+ process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM = "false";
429
+ try {
430
+ const messages = decisionSession();
431
+ const ctx = h.ctx({ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
432
+ await h.fire("context", { type: "context", messages }, ctx);
433
+ // review-on-compact runs as a fire-and-forget async (doCompact is sync), so
434
+ // let the microtask/macrotask queue drain before asserting the side effect.
435
+ await new Promise((r) => setTimeout(r, 20));
436
+ const { listMemories, listCheckpoints } = await import("../src/store/sqlite.js");
437
+ // A checkpoint must have been persisted (proves compaction ran, not skipped).
438
+ assert.ok(listCheckpoints("sess_ext_001", h.stateDir).length > 0, "checkpoint persisted to local vector db");
439
+ // The just-compacted region is worth remembering, so durable memories must
440
+ // have been written to the SQLite store (review-on-compact path).
441
+ const mem = listMemories(null, 50, h.stateDir);
442
+ assert.ok(mem.length > 0, "memory review wrote durable memories on compact");
443
+ }
444
+ finally {
445
+ delete process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM;
446
+ }
447
+ });
448
+ test("S24: /mega-status reports the live pressure band + %", async () => {
449
+ const h = harness();
450
+ // Populate the runtime's live context first (a context event sets
451
+ // lastCtxTokens/lastCtxPercent), then read /mega-status. At 100% usage the live
452
+ // band must read "mega" and pressure must report 100%.
453
+ const ctx = h.ctx({ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
454
+ await h.fire("context", { type: "context", messages: h.session }, ctx);
455
+ await h.commands["mega-status"].handler("", ctx);
456
+ assert.ok(h.notifies.some((n) => n.includes("tier=mega (live)")), "live band reported as mega at 100% pressure");
457
+ assert.ok(h.notifies.some((n) => n.includes("pressure=100%")), "live pressure % reported");
458
+ });
411
459
  // ---- /dashboard commands ----------------------------------------------------
412
460
  test("/dashboard-status reports no server when pid file missing", async () => {
413
461
  // Private base so this asserts "no server" on a range nothing else uses,
@@ -10,7 +10,7 @@ import { normalizeSessionId } from "../src/store.js";
10
10
  import { autoCompactCheck } from "../src/compact.js";
11
11
  import { estimateSessionTokens } from "../src/tokens.js";
12
12
  import { recentUserQuery, WIDGET_KEY } from "./mega-runtime.js";
13
- import { runCompact, doRecall, doRecallAsync, piCompactWouldNoop } from "./mega-pipeline.js";
13
+ import { runCompact, doRecall, doRecallAsync, piCompactWouldNoop, runMemoryReview } from "./mega-pipeline.js";
14
14
  import { recallMemoriesAndInline } from "../src/recall.js";
15
15
  import { driveNativeCompaction } from "./mega-compact-driver.js";
16
16
  import { computeLiveTrimCut, liveTrimSummaryMessage } from "./mega-trim.js";
@@ -166,23 +166,13 @@ export function registerEventHandlers(pi, runtime, config) {
166
166
  if (config.memoryAutoReview && runtime.currentTurn > 0) {
167
167
  const cadence = memoryReviewCadence(runtime.pressureBand, config.memoryReviewInterval);
168
168
  if (runtime.currentTurn % cadence === 0) {
169
- try {
170
- const { reviewConversation } = await import("../src/memory.js");
171
- const { applyMemoryOps } = await import("../src/memoryOps.js");
172
- const entries = ctx.sessionManager.getEntries();
173
- const view = runtime.engineView(entries.flatMap((e) => (e.message ? [e.message] : [])));
174
- const ops = reviewConversation(view, []);
175
- if (ops.length) {
176
- await applyMemoryOps(ops, runtime.currentStateDir);
177
- // S21.2: a memory op landed in this turn window. The pipeline reads
178
- // this counter after a successful compaction and fires
179
- // `consolidateMemories` only when it's > 0.
180
- runtime.memoriesTouchedThisCompaction += ops.length;
181
- }
182
- }
183
- catch {
184
- /* non-fatal — auto-review must not break the turn loop */
185
- }
169
+ // S20+S24: review the conversation and persist durable memories. The
170
+ // cadence scales with pressure (memoryReviewCadence): as context fills,
171
+ // the conversation is reviewed more often so memories keep pace with
172
+ // faster churn. Shared runMemoryReview body (also used on compact).
173
+ const entries = ctx.sessionManager.getEntries();
174
+ const view = runtime.engineView(entries.flatMap((e) => (e.message ? [e.message] : [])));
175
+ await runMemoryReview(runtime, view, "turn");
186
176
  }
187
177
  }
188
178
  });
@@ -18,6 +18,35 @@ import { resolveRepoRoot, preserveRecentForPressure } from "./mega-config.js";
18
18
  import { runRaptor } from "../src/dedup/raptor/index.js";
19
19
  import { loadDedupConfig } from "../src/config/dedup.js";
20
20
  import { upsertEmbedding as indexUpsertEmbedding } from "../src/store/vectorIndex.js";
21
+ /**
22
+ * Review the live conversation and persist durable memories (S20+S24). Shared by
23
+ * the pressure-scaled turn-end cadence (mega-events.ts) AND review-on-compact
24
+ * (below) so both paths run the identical review body. Best-effort + non-fatal:
25
+ * a review failure is swallowed and never breaks the caller. On success, the
26
+ * number of applied ops is returned so callers can feed the consolidation gate.
27
+ *
28
+ * @param view the engine message view to review (caller builds it)
29
+ * @param label a short source tag for the ticker line (e.g. "pressure" / "turn")
30
+ */
31
+ export async function runMemoryReview(runtime, view, label) {
32
+ try {
33
+ const { reviewConversation } = await import("../src/memory.js");
34
+ const { applyMemoryOps } = await import("../src/memoryOps.js");
35
+ const ops = reviewConversation(view, []);
36
+ if (ops.length) {
37
+ await applyMemoryOps(ops, runtime.currentStateDir);
38
+ // S21.2: ops landed — the compaction path reads this counter and fires
39
+ // `consolidateMemories` only when > 0.
40
+ runtime.memoriesTouchedThisCompaction += ops.length;
41
+ runtime.pushTicker(`${C.green}🧠${C.reset} reviewed ${ops.length} memory op${ops.length === 1 ? "" : "s"} (${label})`);
42
+ }
43
+ return ops.length;
44
+ }
45
+ catch {
46
+ /* non-fatal — auto-review must never break the turn loop / compaction */
47
+ return 0;
48
+ }
49
+ }
21
50
  /** Run the full compaction pipeline and persist a checkpoint. Returns the result. */
22
51
  export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
23
52
  runtime.bindRepo(ctx.cwd);
@@ -135,25 +164,11 @@ function doCompact(view, keepFrom, opts, sid, config, pi, ctx, runtime) {
135
164
  }
136
165
  // S24 review-on-compact: when pressure is high, the just-compacted region is
137
166
  // exactly the context worth remembering, so review it immediately rather than
138
- // waiting for the next turn-cadence tick. Fire-and-forget (doCompact is sync):
139
- // best-effort + non-fatal, paralleling the consolidate pass above. Only fires
140
- // above the `high` band so low-pressure compactions don't pay the review cost.
167
+ // waiting for the next turn-cadence tick. Uses the shared runMemoryReview
168
+ // helper (fire-and-forget; doCompact is sync). Best-effort + non-fatal. Only
169
+ // fires above the `high` band so low-pressure compactions don't pay the cost.
141
170
  if (!result.deduped && config.memoryAutoReview && runtime.pressureBand !== "low" && runtime.pressureBand !== "medium") {
142
- void (async () => {
143
- try {
144
- const { reviewConversation } = await import("../src/memory.js");
145
- const { applyMemoryOps } = await import("../src/memoryOps.js");
146
- const ops = reviewConversation(view, []);
147
- if (ops.length) {
148
- await applyMemoryOps(ops, runtime.currentStateDir);
149
- runtime.memoriesTouchedThisCompaction += ops.length;
150
- runtime.pushTicker(`${C.green}🧠${C.reset} reviewed ${ops.length} memory op${ops.length === 1 ? "" : "s"} (pressure)`);
151
- }
152
- }
153
- catch {
154
- /* non-fatal — review-on-compact must never break the compaction */
155
- }
156
- })();
171
+ void runMemoryReview(runtime, view, "pressure");
157
172
  }
158
173
  // Sentinel marker: a non-LLM bookkeeping entry so subsequent triggers can
159
174
  // skip re-vectorizing an already-compacted region (zero token cost).
@@ -4,7 +4,7 @@ import { mkdtempSync, rmSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { applyMemoryOps } from "./memoryOps.js";
7
- import { addMemory, listMemories, replaceMemory, referenceMemory, MEMORY_MAX_CHARS, MEMORY_MAX_ROWS, } from "./store/sqlite.js";
7
+ import { addMemory, listMemories, replaceMemory, referenceMemory, MEMORY_MAX_CHARS, } from "./store/sqlite.js";
8
8
  const baseTmp = mkdtempSync(join(tmpdir(), "mc-memops-"));
9
9
  test("applyMemoryOps: ADD inserts a new memory", async () => {
10
10
  const dir = join(baseTmp, "add");
@@ -58,32 +58,55 @@ test("S24: replaceMemory also truncates oversized content", () => {
58
58
  assert.ok(row.content.endsWith("…[truncated]"), "marker appended");
59
59
  });
60
60
  test("S24: addMemory evicts LRU rows past MEMORY_MAX_ROWS per repo", () => {
61
- const dir = join(baseTmp, "lru");
62
- const n = MEMORY_MAX_ROWS;
63
- const seeds = n - 2;
64
- for (let i = 0; i < seeds; i++)
65
- addMemory({ content: `seed-${i}`, category: "note" }, null, dir);
66
- const keep1 = addMemory({ content: "keep-recent-1", category: "note" }, null, dir);
67
- const keep2 = addMemory({ content: "keep-recent-2", category: "note" }, null, dir);
68
- // Mark the two as referenced so the LRU eviction spares them (they get a
69
- // higher last_referenced than the un-referenced seeds).
70
- assert.ok(referenceMemory(keep1, dir), "reference keep1");
71
- assert.ok(referenceMemory(keep2, dir), "reference keep2");
72
- // Insert 3 more 3 over the cap across the inserts. The two referenced rows
73
- // must survive; only un-referenced (oldest) seeds should be evicted.
74
- addMemory({ content: "new-1", category: "note" }, null, dir);
75
- addMemory({ content: "new-2", category: "note" }, null, dir);
76
- addMemory({ content: "new-3", category: "note" }, null, dir);
77
- const rows = listMemories(null, 1000, dir);
78
- assert.equal(rows.length, n, "row count clamped to MEMORY_MAX_ROWS");
79
- assert.ok(rows.some((m) => /keep-recent-1/.test(m.content)), "referenced row survived");
80
- assert.ok(rows.some((m) => /keep-recent-2/.test(m.content)), "referenced row survived");
81
- assert.ok(rows.some((m) => /new-3/.test(m.content)), "newest row present");
82
- const seedRows = rows.filter((m) => /seed-/.test(m.content));
83
- // 3 rows were evicted (the inserts pushed 3 past the cap); all evicted rows
84
- // must be un-referenced seeds — the referenced rows survived above.
85
- assert.equal(seedRows.length, seeds - 3, "exactly 3 oldest un-referenced seeds evicted");
86
- assert.ok(!seedRows.some((m) => /seed-0/.test(m.content)), "oldest un-referenced seed evicted");
61
+ // Use a small env cap for a fast, deterministic LRU check (the production
62
+ // default is 500; this exercises the same code path).
63
+ process.env.MEGACOMPACT_MEMORY_MAX_ROWS = "10";
64
+ try {
65
+ const dir = join(baseTmp, "lru");
66
+ const n = 10;
67
+ const seeds = n - 2;
68
+ for (let i = 0; i < seeds; i++)
69
+ addMemory({ content: `seed-${i}`, category: "note" }, null, dir);
70
+ const keep1 = addMemory({ content: "keep-recent-1", category: "note" }, null, dir);
71
+ const keep2 = addMemory({ content: "keep-recent-2", category: "note" }, null, dir);
72
+ // Mark the two as referenced so the LRU eviction spares them (they get a
73
+ // higher last_referenced than the un-referenced seeds).
74
+ assert.ok(referenceMemory(keep1, dir), "reference keep1");
75
+ assert.ok(referenceMemory(keep2, dir), "reference keep2");
76
+ // Insert 3 more 3 over the cap across the inserts. The two referenced rows
77
+ // must survive; only un-referenced (oldest) seeds should be evicted.
78
+ addMemory({ content: "new-1", category: "note" }, null, dir);
79
+ addMemory({ content: "new-2", category: "note" }, null, dir);
80
+ addMemory({ content: "new-3", category: "note" }, null, dir);
81
+ const rows = listMemories(null, 1000, dir);
82
+ assert.equal(rows.length, n, "row count clamped to MEMORY_MAX_ROWS");
83
+ assert.ok(rows.some((m) => /keep-recent-1/.test(m.content)), "referenced row survived");
84
+ assert.ok(rows.some((m) => /keep-recent-2/.test(m.content)), "referenced row survived");
85
+ assert.ok(rows.some((m) => /new-3/.test(m.content)), "newest row present");
86
+ const seedRows = rows.filter((m) => /seed-/.test(m.content));
87
+ // 3 rows were evicted (the inserts pushed 3 past the cap); all evicted rows
88
+ // must be un-referenced seeds — the referenced rows survived above.
89
+ assert.equal(seedRows.length, seeds - 3, "exactly 3 oldest un-referenced seeds evicted");
90
+ assert.ok(!seedRows.some((m) => /seed-0/.test(m.content)), "oldest un-referenced seed evicted");
91
+ }
92
+ finally {
93
+ delete process.env.MEGACOMPACT_MEMORY_MAX_ROWS;
94
+ }
95
+ });
96
+ test("S24: MEGACOMPACT_MEMORY_MAX_CHARS env override truncates content", () => {
97
+ process.env.MEGACOMPACT_MEMORY_MAX_CHARS = "50";
98
+ try {
99
+ const dir = join(baseTmp, "cap-env");
100
+ const id = addMemory({ content: "x".repeat(500), category: "note" }, null, dir);
101
+ const rows = listMemories(null, 50, dir);
102
+ const row = rows.find((m) => m.id === id);
103
+ assert.ok(row, "row present");
104
+ assert.equal(row.content.length, 50 + "…[truncated]".length, "truncated to env cap + marker");
105
+ assert.ok(row.content.endsWith("…[truncated]"), "marker appended");
106
+ }
107
+ finally {
108
+ delete process.env.MEGACOMPACT_MEMORY_MAX_CHARS;
109
+ }
87
110
  });
88
111
  test("cleanup memops", () => {
89
112
  rmSync(baseTmp, { recursive: true, force: true });
@@ -581,14 +581,32 @@ export function addLesson(sessionId, repo, lesson, stateDir = getStateDir()) {
581
581
  // file-backed memory caps a single entry at ~5k chars). We truncate content at
582
582
  // MEMORY_MAX_CHARS and evict the least-recently-referenced rows past
583
583
  // MEMORY_MAX_ROWS per repo via LRU. Both are SQLite-only (PREVENT-PI-004): no
584
- // file-backed memory is written anywhere.
584
+ // file-backed memory is written anywhere. Defaults are overridable via env
585
+ // (MEGACOMPACT_MEMORY_MAX_CHARS / MEGACOMPACT_MEMORY_MAX_ROWS).
585
586
  export const MEMORY_MAX_CHARS = 4000;
586
- export const MEMORY_MAX_ROWS = 200;
587
+ export const MEMORY_MAX_ROWS = 500;
588
+ /** Read an env override as a positive int, falling back to `fallback`. */
589
+ function envInt(name, fallback) {
590
+ const v = process.env[name];
591
+ if (v == null || v === "")
592
+ return fallback;
593
+ const n = Number(v);
594
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
595
+ }
596
+ /** Effective per-entry char cap (env-overridable, default MEMORY_MAX_CHARS). */
597
+ export function memoryMaxChars() {
598
+ return envInt("MEGACOMPACT_MEMORY_MAX_CHARS", MEMORY_MAX_CHARS);
599
+ }
600
+ /** Effective per-repo row cap (env-overridable, default MEMORY_MAX_ROWS). */
601
+ export function memoryMaxRows() {
602
+ return envInt("MEGACOMPACT_MEMORY_MAX_ROWS", MEMORY_MAX_ROWS);
603
+ }
587
604
  /** Truncate memory content to the per-entry cap, preserving a trailing marker. */
588
605
  function capMemoryContent(content) {
589
- if (content.length <= MEMORY_MAX_CHARS)
606
+ const cap = memoryMaxChars();
607
+ if (content.length <= cap)
590
608
  return content;
591
- return content.slice(0, MEMORY_MAX_CHARS) + "…[truncated]";
609
+ return content.slice(0, cap) + "…[truncated]";
592
610
  }
593
611
  /**
594
612
  * Evict the least-recently-referenced rows for a repo past MEMORY_MAX_ROWS.
@@ -598,6 +616,7 @@ function capMemoryContent(content) {
598
616
  */
599
617
  function evictMemoryLru(repo, stateDir) {
600
618
  const db = openStore(stateDir);
619
+ const maxRows = memoryMaxRows();
601
620
  // SQLite `= NULL` is never true, so the null-repo scope (memories are
602
621
  // stateDir-scoped when repo is null — the applyMemoryOps path) needs `IS NULL`.
603
622
  const where = repo == null ? "repo IS NULL" : "repo = ?";
@@ -605,7 +624,7 @@ function evictMemoryLru(repo, stateDir) {
605
624
  ? db.prepare(`SELECT COUNT(*) AS n FROM memories WHERE ${where}`).get()
606
625
  : db.prepare(`SELECT COUNT(*) AS n FROM memories WHERE ${where}`).get(repo);
607
626
  const count = countRow.n;
608
- const over = count - MEMORY_MAX_ROWS;
627
+ const over = count - maxRows;
609
628
  if (over <= 0)
610
629
  return;
611
630
  // Delete the `over` least-recently-used rows. ORDER BY the LRU key ASC, id ASC
@@ -448,6 +448,56 @@ test("explicit MEGACOMPACT_THRESHOLD_TOKENS overrides the tier", async () => {
448
448
  );
449
449
  });
450
450
 
451
+ // ---- S24: memory review tied to pressure / compaction -----------------------
452
+ // Build a decision-bearing session large enough to guarantee a real (non-skipped,
453
+ // non-deduped) compaction. Each user turn contains a decision phrase
454
+ // (/\bactually\b/i, /\bwe (?:use|decided)\b/i) so reviewConversation yields ops.
455
+ function decisionSession(): AgentMessage[] {
456
+ const out: AgentMessage[] = [];
457
+ for (let i = 0; i < 14; i++) {
458
+ out.push({ role: "user", content: `actually we decided to use approach ${i} for module ${i}`, timestamp: i } as unknown as AgentMessage);
459
+ out.push({ role: "assistant", content: [{ type: "toolCall", name: "Edit", id: `c${i}`, arguments: {} }], api: "anthropic-messages", provider: "anthropic", model: "m", usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, stopReason: "tool_use", timestamp: i } as unknown as AgentMessage);
460
+ out.push({ role: "toolResult", content: [{ type: "text", text: `edited module ${i}` }], toolCallId: `c${i}`, toolName: "Edit", isError: false, timestamp: i } as unknown as AgentMessage);
461
+ }
462
+ return out;
463
+ }
464
+
465
+ test("S24: high pressure triggers a memory review on compaction", async () => {
466
+ const h = harness();
467
+ // Force a real (non-legacy) compaction at full pressure → pressureBand "mega",
468
+ // which must fire the shared runMemoryReview on compact (review-on-compact).
469
+ process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM = "false";
470
+ try {
471
+ const messages = decisionSession();
472
+ const ctx = h.ctx({ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
473
+ await h.fire("context", { type: "context", messages }, ctx);
474
+ // review-on-compact runs as a fire-and-forget async (doCompact is sync), so
475
+ // let the microtask/macrotask queue drain before asserting the side effect.
476
+ await new Promise((r) => setTimeout(r, 20));
477
+ const { listMemories, listCheckpoints } = await import("../src/store/sqlite.js");
478
+ // A checkpoint must have been persisted (proves compaction ran, not skipped).
479
+ assert.ok(listCheckpoints("sess_ext_001", h.stateDir).length > 0, "checkpoint persisted to local vector db");
480
+ // The just-compacted region is worth remembering, so durable memories must
481
+ // have been written to the SQLite store (review-on-compact path).
482
+ const mem = listMemories(null, 50, h.stateDir);
483
+ assert.ok(mem.length > 0, "memory review wrote durable memories on compact");
484
+ } finally {
485
+ delete process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM;
486
+ }
487
+ });
488
+
489
+ test("S24: /mega-status reports the live pressure band + %", async () => {
490
+ const h = harness();
491
+ // Populate the runtime's live context first (a context event sets
492
+ // lastCtxTokens/lastCtxPercent), then read /mega-status. At 100% usage the live
493
+ // band must read "mega" and pressure must report 100%.
494
+ const ctx = h.ctx({ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
495
+ await h.fire("context", { type: "context", messages: h.session }, ctx);
496
+ await h.commands["mega-status"].handler("", ctx);
497
+ assert.ok(h.notifies.some((n) => n.includes("tier=mega (live)")), "live band reported as mega at 100% pressure");
498
+ assert.ok(h.notifies.some((n) => n.includes("pressure=100%")), "live pressure % reported");
499
+ });
500
+
451
501
  // ---- /dashboard commands ----------------------------------------------------
452
502
  test("/dashboard-status reports no server when pid file missing", async () => {
453
503
  // Private base so this asserts "no server" on a range nothing else uses,
@@ -13,7 +13,7 @@ import { normalizeSessionId } from "../src/store.js";
13
13
  import { autoCompactCheck } from "../src/compact.js";
14
14
  import { estimateSessionTokens } from "../src/tokens.js";
15
15
  import { MegaRuntime, recentUserQuery, WIDGET_KEY } from "./mega-runtime.js";
16
- import { runCompact, doRecall, doRecallAsync, piCompactWouldNoop } from "./mega-pipeline.js";
16
+ import { runCompact, doRecall, doRecallAsync, piCompactWouldNoop, runMemoryReview } from "./mega-pipeline.js";
17
17
  import { recallMemoriesAndInline } from "../src/recall.js";
18
18
  import { driveNativeCompaction } from "./mega-compact-driver.js";
19
19
  import { computeLiveTrimCut, liveTrimSummaryMessage } from "./mega-trim.js";
@@ -172,22 +172,13 @@ export function registerEventHandlers(pi: ExtensionAPI, runtime: MegaRuntime, co
172
172
  if (config.memoryAutoReview && runtime.currentTurn > 0) {
173
173
  const cadence = memoryReviewCadence(runtime.pressureBand, config.memoryReviewInterval);
174
174
  if (runtime.currentTurn % cadence === 0) {
175
- try {
176
- const { reviewConversation } = await import("../src/memory.js");
177
- const { applyMemoryOps } = await import("../src/memoryOps.js");
178
- const entries = ctx.sessionManager.getEntries();
179
- const view = runtime.engineView(entries.flatMap((e: any) => (e.message ? [e.message] : [])));
180
- const ops = reviewConversation(view, []);
181
- if (ops.length) {
182
- await applyMemoryOps(ops, runtime.currentStateDir);
183
- // S21.2: a memory op landed in this turn window. The pipeline reads
184
- // this counter after a successful compaction and fires
185
- // `consolidateMemories` only when it's > 0.
186
- runtime.memoriesTouchedThisCompaction += ops.length;
187
- }
188
- } catch {
189
- /* non-fatal — auto-review must not break the turn loop */
190
- }
175
+ // S20+S24: review the conversation and persist durable memories. The
176
+ // cadence scales with pressure (memoryReviewCadence): as context fills,
177
+ // the conversation is reviewed more often so memories keep pace with
178
+ // faster churn. Shared runMemoryReview body (also used on compact).
179
+ const entries = ctx.sessionManager.getEntries();
180
+ const view = runtime.engineView(entries.flatMap((e: any) => (e.message ? [e.message] : [])));
181
+ await runMemoryReview(runtime, view, "turn");
191
182
  }
192
183
  }
193
184
  });
@@ -31,6 +31,39 @@ export type RunCompactResult =
31
31
  | { skipped: true }
32
32
  | { skipped: false; result: ReturnType<typeof compactSession>; keepFrom: number; saved: number };
33
33
 
34
+ /**
35
+ * Review the live conversation and persist durable memories (S20+S24). Shared by
36
+ * the pressure-scaled turn-end cadence (mega-events.ts) AND review-on-compact
37
+ * (below) so both paths run the identical review body. Best-effort + non-fatal:
38
+ * a review failure is swallowed and never breaks the caller. On success, the
39
+ * number of applied ops is returned so callers can feed the consolidation gate.
40
+ *
41
+ * @param view the engine message view to review (caller builds it)
42
+ * @param label a short source tag for the ticker line (e.g. "pressure" / "turn")
43
+ */
44
+ export async function runMemoryReview(
45
+ runtime: MegaRuntime,
46
+ view: ReturnType<MegaRuntime["engineView"]>,
47
+ label: string,
48
+ ): Promise<number> {
49
+ try {
50
+ const { reviewConversation } = await import("../src/memory.js");
51
+ const { applyMemoryOps } = await import("../src/memoryOps.js");
52
+ const ops = reviewConversation(view, []);
53
+ if (ops.length) {
54
+ await applyMemoryOps(ops, runtime.currentStateDir);
55
+ // S21.2: ops landed — the compaction path reads this counter and fires
56
+ // `consolidateMemories` only when > 0.
57
+ runtime.memoriesTouchedThisCompaction += ops.length;
58
+ runtime.pushTicker(`${C.green}🧠${C.reset} reviewed ${ops.length} memory op${ops.length === 1 ? "" : "s"} (${label})`);
59
+ }
60
+ return ops.length;
61
+ } catch {
62
+ /* non-fatal — auto-review must never break the turn loop / compaction */
63
+ return 0;
64
+ }
65
+ }
66
+
34
67
  /** Run the full compaction pipeline and persist a checkpoint. Returns the result. */
35
68
  export function runCompact(
36
69
  pi: ExtensionAPI,
@@ -177,24 +210,11 @@ function doCompact(
177
210
 
178
211
  // S24 review-on-compact: when pressure is high, the just-compacted region is
179
212
  // exactly the context worth remembering, so review it immediately rather than
180
- // waiting for the next turn-cadence tick. Fire-and-forget (doCompact is sync):
181
- // best-effort + non-fatal, paralleling the consolidate pass above. Only fires
182
- // above the `high` band so low-pressure compactions don't pay the review cost.
213
+ // waiting for the next turn-cadence tick. Uses the shared runMemoryReview
214
+ // helper (fire-and-forget; doCompact is sync). Best-effort + non-fatal. Only
215
+ // fires above the `high` band so low-pressure compactions don't pay the cost.
183
216
  if (!result.deduped && config.memoryAutoReview && runtime.pressureBand !== "low" && runtime.pressureBand !== "medium") {
184
- void (async () => {
185
- try {
186
- const { reviewConversation } = await import("../src/memory.js");
187
- const { applyMemoryOps } = await import("../src/memoryOps.js");
188
- const ops = reviewConversation(view, []);
189
- if (ops.length) {
190
- await applyMemoryOps(ops, runtime.currentStateDir);
191
- runtime.memoriesTouchedThisCompaction += ops.length;
192
- runtime.pushTicker(`${C.green}🧠${C.reset} reviewed ${ops.length} memory op${ops.length === 1 ? "" : "s"} (pressure)`);
193
- }
194
- } catch {
195
- /* non-fatal — review-on-compact must never break the compaction */
196
- }
197
- })();
217
+ void runMemoryReview(runtime, view, "pressure");
198
218
  }
199
219
 
200
220
  // Sentinel marker: a non-LLM bookkeeping entry so subsequent triggers can
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
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",
@@ -10,7 +10,6 @@ import {
10
10
  replaceMemory,
11
11
  referenceMemory,
12
12
  MEMORY_MAX_CHARS,
13
- MEMORY_MAX_ROWS,
14
13
  } from "./store/sqlite.js";
15
14
 
16
15
  const baseTmp = mkdtempSync(join(tmpdir(), "mc-memops-"));
@@ -79,31 +78,53 @@ test("S24: replaceMemory also truncates oversized content", () => {
79
78
  });
80
79
 
81
80
  test("S24: addMemory evicts LRU rows past MEMORY_MAX_ROWS per repo", () => {
82
- const dir = join(baseTmp, "lru");
83
- const n = MEMORY_MAX_ROWS;
84
- const seeds = n - 2;
85
- for (let i = 0; i < seeds; i++) addMemory({ content: `seed-${i}`, category: "note" }, null, dir);
86
- const keep1 = addMemory({ content: "keep-recent-1", category: "note" }, null, dir);
87
- const keep2 = addMemory({ content: "keep-recent-2", category: "note" }, null, dir);
88
- // Mark the two as referenced so the LRU eviction spares them (they get a
89
- // higher last_referenced than the un-referenced seeds).
90
- assert.ok(referenceMemory(keep1, dir), "reference keep1");
91
- assert.ok(referenceMemory(keep2, dir), "reference keep2");
92
- // Insert 3 more 3 over the cap across the inserts. The two referenced rows
93
- // must survive; only un-referenced (oldest) seeds should be evicted.
94
- addMemory({ content: "new-1", category: "note" }, null, dir);
95
- addMemory({ content: "new-2", category: "note" }, null, dir);
96
- addMemory({ content: "new-3", category: "note" }, null, dir);
97
- const rows = listMemories(null, 1000, dir);
98
- assert.equal(rows.length, n, "row count clamped to MEMORY_MAX_ROWS");
99
- assert.ok(rows.some((m) => /keep-recent-1/.test(m.content)), "referenced row survived");
100
- assert.ok(rows.some((m) => /keep-recent-2/.test(m.content)), "referenced row survived");
101
- assert.ok(rows.some((m) => /new-3/.test(m.content)), "newest row present");
102
- const seedRows = rows.filter((m) => /seed-/.test(m.content));
103
- // 3 rows were evicted (the inserts pushed 3 past the cap); all evicted rows
104
- // must be un-referenced seeds — the referenced rows survived above.
105
- assert.equal(seedRows.length, seeds - 3, "exactly 3 oldest un-referenced seeds evicted");
106
- assert.ok(!seedRows.some((m) => /seed-0/.test(m.content)), "oldest un-referenced seed evicted");
81
+ // Use a small env cap for a fast, deterministic LRU check (the production
82
+ // default is 500; this exercises the same code path).
83
+ process.env.MEGACOMPACT_MEMORY_MAX_ROWS = "10";
84
+ try {
85
+ const dir = join(baseTmp, "lru");
86
+ const n = 10;
87
+ const seeds = n - 2;
88
+ for (let i = 0; i < seeds; i++) addMemory({ content: `seed-${i}`, category: "note" }, null, dir);
89
+ const keep1 = addMemory({ content: "keep-recent-1", category: "note" }, null, dir);
90
+ const keep2 = addMemory({ content: "keep-recent-2", category: "note" }, null, dir);
91
+ // Mark the two as referenced so the LRU eviction spares them (they get a
92
+ // higher last_referenced than the un-referenced seeds).
93
+ assert.ok(referenceMemory(keep1, dir), "reference keep1");
94
+ assert.ok(referenceMemory(keep2, dir), "reference keep2");
95
+ // Insert 3 more 3 over the cap across the inserts. The two referenced rows
96
+ // must survive; only un-referenced (oldest) seeds should be evicted.
97
+ addMemory({ content: "new-1", category: "note" }, null, dir);
98
+ addMemory({ content: "new-2", category: "note" }, null, dir);
99
+ addMemory({ content: "new-3", category: "note" }, null, dir);
100
+ const rows = listMemories(null, 1000, dir);
101
+ assert.equal(rows.length, n, "row count clamped to MEMORY_MAX_ROWS");
102
+ assert.ok(rows.some((m) => /keep-recent-1/.test(m.content)), "referenced row survived");
103
+ assert.ok(rows.some((m) => /keep-recent-2/.test(m.content)), "referenced row survived");
104
+ assert.ok(rows.some((m) => /new-3/.test(m.content)), "newest row present");
105
+ const seedRows = rows.filter((m) => /seed-/.test(m.content));
106
+ // 3 rows were evicted (the inserts pushed 3 past the cap); all evicted rows
107
+ // must be un-referenced seeds — the referenced rows survived above.
108
+ assert.equal(seedRows.length, seeds - 3, "exactly 3 oldest un-referenced seeds evicted");
109
+ assert.ok(!seedRows.some((m) => /seed-0/.test(m.content)), "oldest un-referenced seed evicted");
110
+ } finally {
111
+ delete process.env.MEGACOMPACT_MEMORY_MAX_ROWS;
112
+ }
113
+ });
114
+
115
+ test("S24: MEGACOMPACT_MEMORY_MAX_CHARS env override truncates content", () => {
116
+ process.env.MEGACOMPACT_MEMORY_MAX_CHARS = "50";
117
+ try {
118
+ const dir = join(baseTmp, "cap-env");
119
+ const id = addMemory({ content: "x".repeat(500), category: "note" }, null, dir);
120
+ const rows = listMemories(null, 50, dir);
121
+ const row = rows.find((m) => m.id === id);
122
+ assert.ok(row, "row present");
123
+ assert.equal(row!.content.length, 50 + "…[truncated]".length, "truncated to env cap + marker");
124
+ assert.ok(row!.content.endsWith("…[truncated]"), "marker appended");
125
+ } finally {
126
+ delete process.env.MEGACOMPACT_MEMORY_MAX_CHARS;
127
+ }
107
128
  });
108
129
 
109
130
  test("cleanup memops", () => {
@@ -718,14 +718,34 @@ export function addLesson(
718
718
  // file-backed memory caps a single entry at ~5k chars). We truncate content at
719
719
  // MEMORY_MAX_CHARS and evict the least-recently-referenced rows past
720
720
  // MEMORY_MAX_ROWS per repo via LRU. Both are SQLite-only (PREVENT-PI-004): no
721
- // file-backed memory is written anywhere.
721
+ // file-backed memory is written anywhere. Defaults are overridable via env
722
+ // (MEGACOMPACT_MEMORY_MAX_CHARS / MEGACOMPACT_MEMORY_MAX_ROWS).
722
723
  export const MEMORY_MAX_CHARS = 4000;
723
- export const MEMORY_MAX_ROWS = 200;
724
+ export const MEMORY_MAX_ROWS = 500;
725
+
726
+ /** Read an env override as a positive int, falling back to `fallback`. */
727
+ function envInt(name: string, fallback: number): number {
728
+ const v = process.env[name];
729
+ if (v == null || v === "") return fallback;
730
+ const n = Number(v);
731
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
732
+ }
733
+
734
+ /** Effective per-entry char cap (env-overridable, default MEMORY_MAX_CHARS). */
735
+ export function memoryMaxChars(): number {
736
+ return envInt("MEGACOMPACT_MEMORY_MAX_CHARS", MEMORY_MAX_CHARS);
737
+ }
738
+
739
+ /** Effective per-repo row cap (env-overridable, default MEMORY_MAX_ROWS). */
740
+ export function memoryMaxRows(): number {
741
+ return envInt("MEGACOMPACT_MEMORY_MAX_ROWS", MEMORY_MAX_ROWS);
742
+ }
724
743
 
725
744
  /** Truncate memory content to the per-entry cap, preserving a trailing marker. */
726
745
  function capMemoryContent(content: string): string {
727
- if (content.length <= MEMORY_MAX_CHARS) return content;
728
- return content.slice(0, MEMORY_MAX_CHARS) + "…[truncated]";
746
+ const cap = memoryMaxChars();
747
+ if (content.length <= cap) return content;
748
+ return content.slice(0, cap) + "…[truncated]";
729
749
  }
730
750
 
731
751
  /**
@@ -736,6 +756,7 @@ function capMemoryContent(content: string): string {
736
756
  */
737
757
  function evictMemoryLru(repo: string | null, stateDir: string): void {
738
758
  const db = openStore(stateDir);
759
+ const maxRows = memoryMaxRows();
739
760
  // SQLite `= NULL` is never true, so the null-repo scope (memories are
740
761
  // stateDir-scoped when repo is null — the applyMemoryOps path) needs `IS NULL`.
741
762
  const where = repo == null ? "repo IS NULL" : "repo = ?";
@@ -743,7 +764,7 @@ function evictMemoryLru(repo: string | null, stateDir: string): void {
743
764
  ? db.prepare(`SELECT COUNT(*) AS n FROM memories WHERE ${where}`).get()
744
765
  : db.prepare(`SELECT COUNT(*) AS n FROM memories WHERE ${where}`).get(repo);
745
766
  const count = (countRow as { n: number }).n;
746
- const over = count - MEMORY_MAX_ROWS;
767
+ const over = count - maxRows;
747
768
  if (over <= 0) return;
748
769
  // Delete the `over` least-recently-used rows. ORDER BY the LRU key ASC, id ASC
749
770
  // (id ASC breaks ties deterministically — oldest created first). The `where`