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 +39 -23
- package/dist/extensions/conflict-scan.js +133 -60
- package/dist/extensions/conflict-scan.test.js +115 -0
- package/dist/extensions/dashboard-server.js +101 -35
- package/dist/extensions/dashboard-server.test.js +2 -2
- package/dist/extensions/mega-runtime.js +47 -20
- package/dist/src/store/memoryIndex.js +32 -7
- package/dist/src/store/vectorIndex.js +32 -7
- package/extensions/conflict-scan.test.ts +129 -0
- package/extensions/conflict-scan.ts +243 -158
- package/extensions/dashboard-server.test.ts +2 -2
- package/extensions/dashboard-server.ts +106 -36
- package/extensions/mega-dashboard.ts +16 -0
- package/extensions/mega-runtime.ts +48 -20
- package/package.json +1 -1
- package/src/store/memoryIndex.ts +40 -6
- package/src/store/vectorIndex.ts +40 -6
- package/dist/extensions/openclaw-mega-compact.js +0 -291
- package/dist/src/minilm.js +0 -92
- package/dist/src/wordpiece.js +0 -129
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.
|
|
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.
|
|
15
|
-
>
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
39
|
-
|
|
40
|
-
is small (a summary + key decisions +
|
|
41
|
-
|
|
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
|
|
68
|
+
Trigger context → token fast-gate → autoCompactCheck → live 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 (
|
|
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
|
|
174
|
+
const roots = collectScanRoots();
|
|
135
175
|
const scanned = [];
|
|
136
176
|
const conflicts = [];
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
180
|
+
let entries;
|
|
154
181
|
try {
|
|
155
|
-
|
|
182
|
+
entries = readdirSync(root);
|
|
156
183
|
}
|
|
157
184
|
catch {
|
|
158
185
|
continue;
|
|
159
186
|
}
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
+
});
|