pi-mega-compact 0.6.2 → 0.6.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.
package/README.md CHANGED
@@ -6,28 +6,39 @@ 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.1` — storage backend is **`node:sqlite`**
9
+ > **Current version:** `v0.6.2` — 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
13
13
  > `.checkpoints.json.gz` snapshots are retained as disaster-recovery fallbacks
14
- > and auto-imported on first run. Cross-repo recall, durable memory, and a
15
- > localhost dashboard round out the continuity story.
14
+ > and auto-imported on first run. The S24 line ties auto-compact, the tier
15
+ > label, trim depth, and durable-memory review to one **unified pressure
16
+ > signal**, adds a **cross-repo memory-RAG index**, and relieves context
17
+ > **during team runs** (not just at the end).
16
18
 
17
19
  ---
18
20
 
19
21
  ## What this is (the 30-second version)
20
22
 
21
- pi's context window is finite. When a session gets long, pi-mega-compact:
22
-
23
- 1. **Watches** context usage and, past a threshold, **compacts** the older part of
24
- the conversation into a short structured summary + key facts ("a checkpoint").
25
- 2. **Stores** each checkpoint in a **local vector database** (SQLite) with an
26
- embedding, so similar regions can be found later and so **duplicate
27
- work is never stored twice**.
28
- 3. **Recalls** the right checkpoints automatically when you resume a session or
29
- invoke a recall command, re-injecting only what's relevant (deduped against
30
- what's already in view).
23
+ pi's context window is finite. When a session gets long — especially a team run
24
+ with sub-agents — pi-mega-compact keeps it going without overflowing:
25
+
26
+ 1. **Watches one signal.** A single live `pressure = currentTokens / thresholdTokens`
27
+ drives everything the tier label, how aggressively the live trim drops
28
+ context, and how often durable memory is reviewed. As context fills, the whole
29
+ system reacts together; as it's relieved, it backs off.
30
+ 2. **Compacts in two layers.** On every LLM call it returns a **live, compacted
31
+ view** (the model sees a summary + recent anchor, non-destructively). And it
32
+ persists a durable **checkpoint** — and, at each agent settle during a team
33
+ run, fires pi's **native durable trim** so the on-disk transcript is actually
34
+ truncated (context relieves mid-run, and resume reloads the trimmed transcript
35
+ instead of a 150k window).
36
+ 3. **Stores** each checkpoint in a **local vector database** (SQLite) with an
37
+ embedding, so similar regions are found later and **duplicate work is never
38
+ stored twice**.
39
+ 4. **Recalls** the right context automatically — same-repo checkpoints on resume,
40
+ plus **cross-repo memory-RAG**: decisions you saved in one repo are inlined as
41
+ context when you start a session in another.
31
42
 
32
43
  Everything lives on **your disk**. No telemetry, no API, no MCP server, no cloud.
33
44
  The only optional network surface is a **user-triggered localhost dashboard** you
@@ -35,11 +46,13 @@ open yourself.
35
46
 
36
47
  ### Why "mega"?
37
48
 
38
- The compaction pipeline is a **Trident** — three deterministic stages that run
39
- over your conversation before anything is persisted. The checkpoint it produces
40
- is small (a summary + key decisions + next steps + files touched), so the same
41
- session that would otherwise overflow its window keeps going on a fraction of the
42
- tokens.
49
+ The compaction pipeline is a **Trident** — three deterministic stages
50
+ (supersede collapse cluster) that run over your conversation before anything
51
+ is persisted. The checkpoint it produces is small (a summary + key decisions +
52
+ next steps + files touched), so the same session that would otherwise overflow
53
+ its window keeps going on a fraction of the tokens. On top of the Trident, a
54
+ single pressure signal orchestrates the live trim, the durable trim, and memory
55
+ review as one coherent system rather than four independent triggers.
43
56
 
44
57
  ---
45
58
 
@@ -52,9 +65,12 @@ Layer 3 Cluster (vectorize) local vector index → semantic dedup + recall
52
65
  Layer 2 Collapse (summarize) summarizeMessages() heuristic + agent summary on /mega-compact
53
66
  Layer 1 Supersede (prune) drop obsolete file-reads / superseded turns (zero cost)
54
67
  ─────────────────────────────────────────────────────────────────────────
55
- Trigger context/turn_end% gate → auto_compact_checkfire
68
+ Trigger context → token fast-gate → autoCompactChecklive trim (per call)
69
+ Durable agent_end (idle + over threshold) → ctx.compact() → session_before_compact
70
+ supplies the summary; pi truncates the transcript (relieves context)
71
+ Live context handler returns { messages:[summary, …recent] } — model sees a
72
+ compacted window every LLM call; the on-disk transcript is untouched
56
73
  Marker insert compact-marker; dedupe so repeated triggers cost ~0 tokens
57
- Cancel session_before_compact → { cancel:true } once persisted (no double-compact)
58
74
  ```
59
75
 
60
76
  **One store, three ways to read it back — one dedup engine:**
@@ -168,7 +184,7 @@ building.
168
184
  pi-mega-compact uses a dual local backend — **zero network, no native build step**:
169
185
 
170
186
  - **`node:sqlite`** (`DatabaseSync`, Node ≥22.13 built-in) — the synchronous source of truth for checkpoints, session state, and the dedup index. No dependency, no install script, survives pi's `install-scripts` block.
171
- - **PGlite + `@electric-sql/pglite-pgvector`** (WASM Postgres + HNSW `vector_cosine_ops`) — an optional, best-effort async vector index for **cross-repo recall** at `~/.pi/mega-compact-vector`. The sync store stays authoritative; the index degrades to the sync per-session scan on any failure.
187
+ - **PGlite + `@electric-sql/pglite-pgvector`** (WASM Postgres + HNSW `vector_cosine_ops`) — an optional, best-effort async vector index for **cross-repo recall** at `~/.pi/mega-compact-vector`. It holds both checkpoint embeddings and durable-memory embeddings, so decisions saved in one repo are findable from another. The sync store stays authoritative; the index degrades to the sync per-session scan on any failure.
172
188
 
173
189
  Kill-switch: `MEGACOMPACT_PGLITE_DISABLED=1` fully disables the PGlite index (falls back to sync scan). Requires Node ≥22.13 (`engines.node`).
174
190
 
@@ -178,7 +194,7 @@ On resume, recall augments from other repos' checkpoints when this repo's store
178
194
 
179
195
  ### Memory
180
196
 
181
- pi-mega-compact auto-reviews the conversation every 10 turns and writes durable `decision`/`fact`/`preference` memories to SQLite (local, hallucination-guarded). Relevant memories are injected as RAG context on recall (capped, deduped). Manual: `/mega-memory save|list|forget`.
197
+ pi-mega-compact auto-reviews the conversation every 10 turns (the cadence shortens as pressure climbs) and writes durable `decision`/`fact`/`preference` memories to SQLite (local, hallucination-guarded). Relevant memories are injected as RAG context on recall (capped, deduped). **Cross-repo memory-RAG (S24):** every memory write is mirrored into the PGlite/HNSW index, so when same-repo recall is thin the system augments with the nearest memories from *other* repos (stricter `MEGACOMPACT_CROSSREPO_COSINE` floor, deduped against what's already in view). Manual: `/mega-memory save|list|forget` (or `/m`).
182
198
 
183
199
  ### Uninstall
184
200
 
@@ -352,7 +368,7 @@ The extension entry adapts between the engine and pi's runtime types.
352
368
 
353
369
  ```bash
354
370
  npm run build # tsc
355
- npm test # build + node --test on dist/**/*.test.js (346 tests)
371
+ npm test # build + node --test on dist/**/*.test.js (353 tests)
356
372
  npm run lint # tsc --noEmit + guardrails-scan
357
373
  npm run guardrails # regression_check + guardrails-scan
358
374
  ```
@@ -12,10 +12,18 @@
12
12
  *
13
13
  * Pi-agnostic: reads package.json + greps source. No pi runtime types, so it is
14
14
  * unit-testable against a fixture node_modules tree.
15
+ *
16
+ * SCAN-SCOPE FIX (S24 follow-up): the original scanner only walked the npm
17
+ * `node_modules` tree, so user-level extensions installed outside npm (e.g.
18
+ * `pi-hermes-memory`, a data-only `MEMORY.md` + `sessions.db` memory store)
19
+ * were never inspected — that gap let the 5000-char file-buffer error slip
20
+ * through undetected. We now also scan the user-level extension dir and detect
21
+ * memory stores that ship with no package.json / source to grep.
15
22
  */
16
23
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
17
24
  import { join, dirname } from "node:path";
18
25
  import { fileURLToPath } from "node:url";
26
+ import { homedir } from "node:os";
19
27
  // Marker sets. A package is flagged when its source matches a marker in a
20
28
  // category. File-grep (not AST) keeps this dependency-free and fast.
21
29
  const MARKERS = {
@@ -38,10 +46,7 @@ const MARKERS = {
38
46
  "memoryTool",
39
47
  ],
40
48
  // Tool-output shaping (compact/summarize tool results) — overlap, not a rival.
41
- toolOutput: [
42
- "tool_result",
43
- "ToolResult",
44
- ],
49
+ toolOutput: ["tool_result", "ToolResult"],
45
50
  };
46
51
  /** Resolve the node_modules dir that contains this package (or env override). */
47
52
  export function resolveExtensionRoot(selfDir = dirname(fileURLToPath(import.meta.url))) {
@@ -62,6 +67,41 @@ export function resolveExtensionRoot(selfDir = dirname(fileURLToPath(import.meta
62
67
  }
63
68
  return null;
64
69
  }
70
+ /**
71
+ * Resolve every directory that may hold pi extensions to scan.
72
+ *
73
+ * - `MEGACOMPACT_EXT_SCAN_DIR` (if set) replaces the whole list — a single
74
+ * fixture/override root for tests or custom layouts.
75
+ * - Otherwise: the node_modules that holds this package (classic npm layout) AND
76
+ * the user-level extension dir (`~/.pi/agent`), which is where extensions
77
+ * installed outside npm actually live. The original scanner only walked
78
+ * node_modules, so user-level memory extensions were never flagged — that is
79
+ * the gap that let the 5000-char `MEMORY.md` buffer error slip through.
80
+ */
81
+ export function collectScanRoots() {
82
+ const override = process.env.MEGACOMPACT_EXT_SCAN_DIR;
83
+ if (override && override.trim() !== "")
84
+ return [override];
85
+ const roots = [];
86
+ const nm = resolveExtensionRoot();
87
+ if (nm && existsSync(nm))
88
+ roots.push(nm);
89
+ const userDir = process.env.MEGACOMPACT_EXT_USER_DIR?.trim() ||
90
+ join(homedir(), ".pi", "agent");
91
+ if (existsSync(userDir))
92
+ roots.push(userDir);
93
+ return roots;
94
+ }
95
+ /**
96
+ * True when a directory is a pi memory-store container rather than a normal code
97
+ * extension. `pi-hermes-memory` ships as exactly this: no package.json, no
98
+ * source — just `MEMORY.md` + `sessions.db`. The marker-grep path misses it,
99
+ * so we also detect the on-disk memory signature.
100
+ */
101
+ function isMemoryStoreDir(pkgDir) {
102
+ return (existsSync(join(pkgDir, "sessions.db")) ||
103
+ existsSync(join(pkgDir, "MEMORY.md")));
104
+ }
65
105
  /** Recursively collect source-ish files under a package, capped to avoid scans. */
66
106
  function collectFiles(root, max = 400) {
67
107
  const out = [];
@@ -131,70 +171,103 @@ function matchMarkers(pkgDir, keys) {
131
171
  * @param selfName package name to skip (defaults to this package's name).
132
172
  */
133
173
  export function detectConflicts(selfName = "pi-mega-compact") {
134
- const root = resolveExtensionRoot();
174
+ const roots = collectScanRoots();
135
175
  const scanned = [];
136
176
  const conflicts = [];
137
- if (!root || !existsSync(root))
138
- return { scanned, conflicts };
139
- let entries;
140
- try {
141
- entries = readdirSync(root);
142
- }
143
- catch {
144
- return { scanned, conflicts };
145
- }
146
- for (const name of entries) {
147
- const pkgDir = join(root, name);
148
- if (!statSync(pkgDir).isDirectory())
149
- continue;
150
- const pkgJson = join(pkgDir, "package.json");
151
- if (!existsSync(pkgJson))
177
+ for (const root of roots) {
178
+ if (!existsSync(root))
152
179
  continue;
153
- let pkg;
180
+ let entries;
154
181
  try {
155
- pkg = JSON.parse(readFileSync(pkgJson, "utf-8"));
182
+ entries = readdirSync(root);
156
183
  }
157
184
  catch {
158
185
  continue;
159
186
  }
160
- const pkgName = pkg.name ?? name;
161
- if (pkgName === selfName)
162
- continue;
163
- // Only consider packages that declare pi extensions.
164
- if (!pkg.pi || !Array.isArray(pkg.pi.extensions) || pkg.pi.extensions.length === 0)
165
- continue;
166
- scanned.push(pkgName);
167
- const memHits = matchMarkers(pkgDir, MARKERS.memory);
168
- const compHits = matchMarkers(pkgDir, MARKERS.compaction);
169
- const toolHits = matchMarkers(pkgDir, MARKERS.toolOutput);
170
- if (compHits.length > 0) {
171
- conflicts.push({
172
- package: pkgName,
173
- severity: "high",
174
- kind: "compaction",
175
- evidence: compHits,
176
- recommendation: "Disabling recommended — competes with pi-mega-compact's conversation compaction.",
177
- });
178
- continue; // compaction is the dominant conflict; don't double-flag.
179
- }
180
- if (memHits.length > 0) {
181
- conflicts.push({
182
- package: pkgName,
183
- severity: "high",
184
- kind: "memory",
185
- evidence: memHits,
186
- recommendation: "pi-mega-compact now owns save-to-memory (/mega-memory, its own SQLite). Disable this to avoid duplicate memory stores.",
187
- });
188
- continue;
189
- }
190
- if (toolHits.length > 0) {
191
- conflicts.push({
192
- package: pkgName,
193
- severity: "info",
194
- kind: "tool-output",
195
- evidence: toolHits,
196
- recommendation: "Shapes tool output (summarize/compact tool results). Generally compatible; no action needed.",
197
- });
187
+ for (const name of entries) {
188
+ const pkgDir = join(root, name);
189
+ let st;
190
+ try {
191
+ st = statSync(pkgDir);
192
+ }
193
+ catch {
194
+ continue;
195
+ }
196
+ if (!st.isDirectory())
197
+ continue;
198
+ // A candidate is either a real code extension (declares pi.extensions) or a
199
+ // data-only memory store (MEMORY.md / sessions.db at its root).
200
+ const pkgJson = join(pkgDir, "package.json");
201
+ let pkg = null;
202
+ if (existsSync(pkgJson)) {
203
+ try {
204
+ pkg = JSON.parse(readFileSync(pkgJson, "utf-8"));
205
+ }
206
+ catch {
207
+ pkg = null;
208
+ }
209
+ }
210
+ const isCodeExt = !!pkg &&
211
+ !!pkg.pi &&
212
+ Array.isArray(pkg.pi.extensions) &&
213
+ pkg.pi.extensions.length > 0;
214
+ const isMemoryStore = isMemoryStoreDir(pkgDir);
215
+ if (!isCodeExt && !isMemoryStore)
216
+ continue;
217
+ const pkgName = pkg?.name ?? name;
218
+ if (pkgName === selfName)
219
+ continue;
220
+ scanned.push(`${pkgName} (${name})`);
221
+ if (isCodeExt) {
222
+ const memHits = matchMarkers(pkgDir, MARKERS.memory);
223
+ const compHits = matchMarkers(pkgDir, MARKERS.compaction);
224
+ const toolHits = matchMarkers(pkgDir, MARKERS.toolOutput);
225
+ if (compHits.length > 0) {
226
+ conflicts.push({
227
+ package: pkgName,
228
+ severity: "high",
229
+ kind: "compaction",
230
+ evidence: compHits,
231
+ recommendation: "Disabling recommended — competes with pi-mega-compact's conversation compaction.",
232
+ });
233
+ continue; // compaction is the dominant conflict; don't double-flag.
234
+ }
235
+ if (memHits.length > 0) {
236
+ conflicts.push({
237
+ package: pkgName,
238
+ severity: "high",
239
+ kind: "memory",
240
+ evidence: memHits,
241
+ recommendation: "pi-mega-compact now owns save-to-memory (/mega-memory, its own SQLite). Disable this to avoid duplicate memory stores.",
242
+ });
243
+ continue;
244
+ }
245
+ if (toolHits.length > 0) {
246
+ conflicts.push({
247
+ package: pkgName,
248
+ severity: "info",
249
+ kind: "tool-output",
250
+ evidence: toolHits,
251
+ recommendation: "Shapes tool output (summarize/compact tool results). Generally compatible; no action needed.",
252
+ });
253
+ }
254
+ }
255
+ // Data-only memory store: no source to grep, but the on-disk signature
256
+ // (sessions.db / MEMORY.md) is a conflict with our SQLite memory store.
257
+ if (isMemoryStore) {
258
+ const evidence = [];
259
+ if (existsSync(join(pkgDir, "sessions.db")))
260
+ evidence.push("sessions.db");
261
+ if (existsSync(join(pkgDir, "MEMORY.md")))
262
+ evidence.push("MEMORY.md");
263
+ conflicts.push({
264
+ package: pkgName,
265
+ severity: "high",
266
+ kind: "memory",
267
+ evidence,
268
+ recommendation: "pi-mega-compact now owns save-to-memory (its own SQLite). This data-only memory store competes with it — disable to avoid a duplicate / capped memory buffer.",
269
+ });
270
+ }
198
271
  }
199
272
  }
200
273
  return { scanned, conflicts };
@@ -0,0 +1,115 @@
1
+ /**
2
+ * conflict-scan.test.ts — unit tests for the extension-conflict scanner.
3
+ *
4
+ * Fixture trees are written under a temp dir and scanned via
5
+ * MEGACOMPACT_EXT_SCAN_DIR (which makes collectScanRoots() return that
6
+ * single root). This covers the S24 follow-up fix:
7
+ *
8
+ * 1. node_modules-style code extensions (package.json + pi.extensions) are
9
+ * still detected by source-marker grep (regression).
10
+ * 2. USER-LEVEL extensions installed outside npm (e.g. pi-hermes-memory)
11
+ * now get scanned too — previously only `node_modules` was walked, so a
12
+ * data-only memory store (MEMORY.md + sessions.db, no package.json)
13
+ * was never flagged (the 5000-char file-buffer error slipped through).
14
+ * 3. The data-only memory-store signature is detected even with no source.
15
+ * 4. pi-mega-compact (selfName) is always skipped.
16
+ */
17
+ import { test, after } from "node:test";
18
+ import assert from "node:assert/strict";
19
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+ import { detectConflicts, collectScanRoots } from "./conflict-scan.js";
23
+ const base = mkdtempSync(join(tmpdir(), "mc-scan-"));
24
+ let n = 0;
25
+ /** Make a fixture root containing one or more fake extensions, return its path. */
26
+ function fixture(build) {
27
+ const root = join(base, `case-${n++}`);
28
+ mkdirSync(root, { recursive: true });
29
+ build(root);
30
+ return root;
31
+ }
32
+ after(() => {
33
+ rmSync(base, { recursive: true, force: true });
34
+ });
35
+ test("scans a user-level, data-only memory store (no package.json)", () => {
36
+ const root = fixture((r) => {
37
+ const ext = join(r, "pi-hermes-memory");
38
+ mkdirSync(ext, { recursive: true });
39
+ // No package.json, no source — just pi's memory-store signature.
40
+ writeFileSync(join(ext, "MEMORY.md"), "# memory\n");
41
+ writeFileSync(join(ext, "sessions.db"), "");
42
+ });
43
+ process.env.MEGACOMPACT_EXT_SCAN_DIR = root;
44
+ try {
45
+ const { conflicts } = detectConflicts();
46
+ assert.ok(conflicts.length >= 1, "expected a memory conflict");
47
+ const hit = conflicts.find((c) => c.kind === "memory");
48
+ assert.ok(hit, "expected a memory-kind conflict");
49
+ assert.equal(hit.severity, "high");
50
+ assert.ok(hit.evidence.includes("MEMORY.md") ||
51
+ hit.evidence.includes("sessions.db"), "evidence should name the on-disk memory signature");
52
+ }
53
+ finally {
54
+ delete process.env.MEGACOMPACT_EXT_SCAN_DIR;
55
+ }
56
+ });
57
+ test("still detects a code extension by source marker (regression)", () => {
58
+ const root = fixture((r) => {
59
+ // A code extension is a DIRECT child of the scan root (mirrors the
60
+ // node_modules layout: packages live one level under the root).
61
+ const ext = join(r, "some-memory-ext");
62
+ mkdirSync(ext, { recursive: true });
63
+ writeFileSync(join(ext, "package.json"), JSON.stringify({ name: "some-memory-ext", pi: { extensions: ["x.ts"] } }));
64
+ writeFileSync(join(ext, "index.ts"), "export const MEMORY_TOOL = true;");
65
+ });
66
+ process.env.MEGACOMPACT_EXT_SCAN_DIR = root;
67
+ try {
68
+ const { conflicts } = detectConflicts();
69
+ const hit = conflicts.find((c) => c.package === "some-memory-ext");
70
+ assert.ok(hit, "expected some-memory-ext to be flagged");
71
+ assert.equal(hit.kind, "memory");
72
+ }
73
+ finally {
74
+ delete process.env.MEGACOMPACT_EXT_SCAN_DIR;
75
+ }
76
+ });
77
+ test("skips pi-mega-compact (selfName) and non-extension dirs", () => {
78
+ const root = fixture((r) => {
79
+ // selfName dir with a memory signature — must be ignored.
80
+ const me = join(r, "node_modules", "pi-mega-compact");
81
+ mkdirSync(me, { recursive: true });
82
+ writeFileSync(join(me, "sessions.db"), "");
83
+ // unrelated dir with no pi.extensions and no memory signature.
84
+ mkdirSync(join(r, "node_modules", "totally-fine"), { recursive: true });
85
+ });
86
+ process.env.MEGACOMPACT_EXT_SCAN_DIR = root;
87
+ try {
88
+ const { scanned, conflicts } = detectConflicts();
89
+ assert.equal(conflicts.length, 0, "no conflicts expected");
90
+ assert.ok(!scanned.some((s) => s.includes("pi-mega-compact")), "selfName should not appear in scanned");
91
+ }
92
+ finally {
93
+ delete process.env.MEGACOMPACT_EXT_SCAN_DIR;
94
+ }
95
+ });
96
+ test("collectScanRoots honors MEGACOMPACT_EXT_SCAN_DIR override", () => {
97
+ const root = fixture(() => { });
98
+ process.env.MEGACOMPACT_EXT_SCAN_DIR = root;
99
+ try {
100
+ const roots = collectScanRoots();
101
+ assert.deepEqual(roots, [root], "override replaces the whole root list");
102
+ }
103
+ finally {
104
+ delete process.env.MEGACOMPACT_EXT_SCAN_DIR;
105
+ }
106
+ });
107
+ test("collectScanRoots falls back to node_modules + user dir when no override", () => {
108
+ delete process.env.MEGACOMPACT_EXT_SCAN_DIR;
109
+ delete process.env.MEGACOMPACT_EXT_USER_DIR;
110
+ // No override set and this test file lives under extensions/, so node_modules
111
+ // resolution walks up from here; the user dir (~/.pi/agent) may or may
112
+ // not exist in CI. We only assert the call returns a non-throwing array.
113
+ const roots = collectScanRoots();
114
+ assert.ok(Array.isArray(roots), "collectScanRoots must return an array");
115
+ });