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 +1 -1
- package/dist/extensions/mega-compact.test.js +48 -0
- package/dist/extensions/mega-events.js +8 -18
- package/dist/extensions/mega-pipeline.js +33 -18
- package/dist/src/memoryOps.test.js +50 -27
- package/dist/src/store/sqlite.js +24 -5
- package/extensions/mega-compact.test.ts +50 -0
- package/extensions/mega-events.ts +8 -17
- package/extensions/mega-pipeline.ts +37 -17
- package/package.json +1 -1
- package/src/memoryOps.test.ts +47 -26
- package/src/store/sqlite.ts +26 -5
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.
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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.
|
|
139
|
-
//
|
|
140
|
-
// above the `high` band so low-pressure compactions don't pay the
|
|
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 (
|
|
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,
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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 });
|
package/dist/src/store/sqlite.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
606
|
+
const cap = memoryMaxChars();
|
|
607
|
+
if (content.length <= cap)
|
|
590
608
|
return content;
|
|
591
|
-
return content.slice(0,
|
|
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 -
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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.
|
|
181
|
-
//
|
|
182
|
-
// above the `high` band so low-pressure compactions don't pay the
|
|
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 (
|
|
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
package/src/memoryOps.test.ts
CHANGED
|
@@ -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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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", () => {
|
package/src/store/sqlite.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
728
|
-
|
|
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 -
|
|
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`
|