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.
@@ -12,128 +12,173 @@
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
 
17
24
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
18
25
  import { join, dirname } from "node:path";
19
26
  import { fileURLToPath } from "node:url";
27
+ import { homedir } from "node:os";
20
28
 
21
29
  export type ConflictKind = "compaction" | "memory" | "tool-output";
22
30
  export type ConflictSeverity = "high" | "info";
23
31
 
24
32
  export interface ConflictHit {
25
- package: string;
26
- severity: ConflictSeverity;
27
- kind: ConflictKind;
28
- evidence: string[];
29
- /** One-line recommended action for the user. */
30
- recommendation: string;
33
+ package: string;
34
+ severity: ConflictSeverity;
35
+ kind: ConflictKind;
36
+ evidence: string[];
37
+ /** One-line recommended action for the user. */
38
+ recommendation: string;
31
39
  }
32
40
 
33
41
  export interface ConflictReport {
34
- scanned: string[];
35
- conflicts: ConflictHit[];
42
+ scanned: string[];
43
+ conflicts: ConflictHit[];
36
44
  }
37
45
 
38
46
  // Marker sets. A package is flagged when its source matches a marker in a
39
47
  // category. File-grep (not AST) keeps this dependency-free and fast.
40
48
  const MARKERS = {
41
- // Directly competes with our conversation compaction.
42
- compaction: [
43
- "session_before_compact",
44
- "session_compact",
45
- "compactSession",
46
- "autoCompact",
47
- "auto_compact",
48
- ],
49
- // Saves durable memory to its own store — the takeover target.
50
- memory: [
51
- "MEMORY_TOOL",
52
- "learn-memory",
53
- "saveMemory",
54
- "memoryPolicy",
55
- "wal_checkpoint",
56
- "store/db.ts",
57
- "memoryTool",
58
- ],
59
- // Tool-output shaping (compact/summarize tool results) — overlap, not a rival.
60
- toolOutput: [
61
- "tool_result",
62
- "ToolResult",
63
- ],
49
+ // Directly competes with our conversation compaction.
50
+ compaction: [
51
+ "session_before_compact",
52
+ "session_compact",
53
+ "compactSession",
54
+ "autoCompact",
55
+ "auto_compact",
56
+ ],
57
+ // Saves durable memory to its own store — the takeover target.
58
+ memory: [
59
+ "MEMORY_TOOL",
60
+ "learn-memory",
61
+ "saveMemory",
62
+ "memoryPolicy",
63
+ "wal_checkpoint",
64
+ "store/db.ts",
65
+ "memoryTool",
66
+ ],
67
+ // Tool-output shaping (compact/summarize tool results) — overlap, not a rival.
68
+ toolOutput: ["tool_result", "ToolResult"],
64
69
  } as const;
65
70
 
66
71
  /** Resolve the node_modules dir that contains this package (or env override). */
67
- export function resolveExtensionRoot(selfDir: string = dirname(fileURLToPath(import.meta.url))): string | null {
68
- const override = process.env.MEGACOMPACT_EXT_SCAN_DIR;
69
- if (override && override.trim() !== "") return override;
70
- // selfDir is <root>/extensions or <root>/dist/extensions. Walk up to the
71
- // node_modules that holds pi-mega-compact.
72
- let dir = selfDir;
73
- for (let i = 0; i < 6; i++) {
74
- const candidate = join(dir, "node_modules");
75
- if (existsSync(candidate) && existsSync(join(candidate, "pi-mega-compact"))) return candidate;
76
- const parent = dirname(dir);
77
- if (parent === dir) break;
78
- dir = parent;
79
- }
80
- return null;
72
+ export function resolveExtensionRoot(
73
+ selfDir: string = dirname(fileURLToPath(import.meta.url)),
74
+ ): string | null {
75
+ const override = process.env.MEGACOMPACT_EXT_SCAN_DIR;
76
+ if (override && override.trim() !== "") return override;
77
+ // selfDir is <root>/extensions or <root>/dist/extensions. Walk up to the
78
+ // node_modules that holds pi-mega-compact.
79
+ let dir = selfDir;
80
+ for (let i = 0; i < 6; i++) {
81
+ const candidate = join(dir, "node_modules");
82
+ if (existsSync(candidate) && existsSync(join(candidate, "pi-mega-compact")))
83
+ return candidate;
84
+ const parent = dirname(dir);
85
+ if (parent === dir) break;
86
+ dir = parent;
87
+ }
88
+ return null;
89
+ }
90
+
91
+ /**
92
+ * Resolve every directory that may hold pi extensions to scan.
93
+ *
94
+ * - `MEGACOMPACT_EXT_SCAN_DIR` (if set) replaces the whole list — a single
95
+ * fixture/override root for tests or custom layouts.
96
+ * - Otherwise: the node_modules that holds this package (classic npm layout) AND
97
+ * the user-level extension dir (`~/.pi/agent`), which is where extensions
98
+ * installed outside npm actually live. The original scanner only walked
99
+ * node_modules, so user-level memory extensions were never flagged — that is
100
+ * the gap that let the 5000-char `MEMORY.md` buffer error slip through.
101
+ */
102
+ export function collectScanRoots(): string[] {
103
+ const override = process.env.MEGACOMPACT_EXT_SCAN_DIR;
104
+ if (override && override.trim() !== "") return [override];
105
+ const roots: string[] = [];
106
+ const nm = resolveExtensionRoot();
107
+ if (nm && existsSync(nm)) roots.push(nm);
108
+ const userDir =
109
+ process.env.MEGACOMPACT_EXT_USER_DIR?.trim() ||
110
+ join(homedir(), ".pi", "agent");
111
+ if (existsSync(userDir)) roots.push(userDir);
112
+ return roots;
113
+ }
114
+
115
+ /**
116
+ * True when a directory is a pi memory-store container rather than a normal code
117
+ * extension. `pi-hermes-memory` ships as exactly this: no package.json, no
118
+ * source — just `MEMORY.md` + `sessions.db`. The marker-grep path misses it,
119
+ * so we also detect the on-disk memory signature.
120
+ */
121
+ function isMemoryStoreDir(pkgDir: string): boolean {
122
+ return (
123
+ existsSync(join(pkgDir, "sessions.db")) ||
124
+ existsSync(join(pkgDir, "MEMORY.md"))
125
+ );
81
126
  }
82
127
 
83
128
  /** Recursively collect source-ish files under a package, capped to avoid scans. */
84
129
  function collectFiles(root: string, max = 400): string[] {
85
- const out: string[] = [];
86
- const walk = (dir: string): void => {
87
- if (out.length >= max) return;
88
- let entries: string[];
89
- try {
90
- entries = readdirSync(dir);
91
- } catch {
92
- return;
93
- }
94
- for (const e of entries) {
95
- if (out.length >= max) return;
96
- const full = join(dir, e);
97
- let st;
98
- try {
99
- st = statSync(full);
100
- } catch {
101
- continue;
102
- }
103
- if (st.isDirectory()) {
104
- if (e === "node_modules" || e === ".git") continue;
105
- walk(full);
106
- } else if (/\.(ts|js|mjs|cjs|json|md)$/.test(e)) {
107
- out.push(full);
108
- }
109
- }
110
- };
111
- walk(root);
112
- return out;
130
+ const out: string[] = [];
131
+ const walk = (dir: string): void => {
132
+ if (out.length >= max) return;
133
+ let entries: string[];
134
+ try {
135
+ entries = readdirSync(dir);
136
+ } catch {
137
+ return;
138
+ }
139
+ for (const e of entries) {
140
+ if (out.length >= max) return;
141
+ const full = join(dir, e);
142
+ let st;
143
+ try {
144
+ st = statSync(full);
145
+ } catch {
146
+ continue;
147
+ }
148
+ if (st.isDirectory()) {
149
+ if (e === "node_modules" || e === ".git") continue;
150
+ walk(full);
151
+ } else if (/\.(ts|js|mjs|cjs|json|md)$/.test(e)) {
152
+ out.push(full);
153
+ }
154
+ }
155
+ };
156
+ walk(root);
157
+ return out;
113
158
  }
114
159
 
115
160
  /** Grep a package's source for any marker in `keys`; return matched markers. */
116
161
  function matchMarkers(pkgDir: string, keys: readonly string[]): string[] {
117
- const found = new Set<string>();
118
- let files: string[];
119
- try {
120
- files = collectFiles(pkgDir);
121
- } catch {
122
- return [];
123
- }
124
- for (const f of files) {
125
- let text: string;
126
- try {
127
- text = readFileSync(f, "utf-8");
128
- } catch {
129
- continue;
130
- }
131
- for (const m of keys) {
132
- if (text.includes(m)) found.add(m);
133
- }
134
- if (found.size === keys.length) break;
135
- }
136
- return [...found];
162
+ const found = new Set<string>();
163
+ let files: string[];
164
+ try {
165
+ files = collectFiles(pkgDir);
166
+ } catch {
167
+ return [];
168
+ }
169
+ for (const f of files) {
170
+ let text: string;
171
+ try {
172
+ text = readFileSync(f, "utf-8");
173
+ } catch {
174
+ continue;
175
+ }
176
+ for (const m of keys) {
177
+ if (text.includes(m)) found.add(m);
178
+ }
179
+ if (found.size === keys.length) break;
180
+ }
181
+ return [...found];
137
182
  }
138
183
 
139
184
  /**
@@ -141,69 +186,109 @@ function matchMarkers(pkgDir: string, keys: readonly string[]): string[] {
141
186
  * @param selfName package name to skip (defaults to this package's name).
142
187
  */
143
188
  export function detectConflicts(selfName = "pi-mega-compact"): ConflictReport {
144
- const root = resolveExtensionRoot();
145
- const scanned: string[] = [];
146
- const conflicts: ConflictHit[] = [];
147
- if (!root || !existsSync(root)) return { scanned, conflicts };
148
-
149
- let entries: string[];
150
- try {
151
- entries = readdirSync(root);
152
- } catch {
153
- return { scanned, conflicts };
154
- }
155
-
156
- for (const name of entries) {
157
- const pkgDir = join(root, name);
158
- if (!statSync(pkgDir).isDirectory()) continue;
159
- const pkgJson = join(pkgDir, "package.json");
160
- if (!existsSync(pkgJson)) continue;
161
- let pkg: { name?: string; pi?: { extensions?: string[] } };
162
- try {
163
- pkg = JSON.parse(readFileSync(pkgJson, "utf-8"));
164
- } catch {
165
- continue;
166
- }
167
- const pkgName = pkg.name ?? name;
168
- if (pkgName === selfName) continue;
169
- // Only consider packages that declare pi extensions.
170
- if (!pkg.pi || !Array.isArray(pkg.pi.extensions) || pkg.pi.extensions.length === 0) continue;
171
- scanned.push(pkgName);
172
-
173
- const memHits = matchMarkers(pkgDir, MARKERS.memory);
174
- const compHits = matchMarkers(pkgDir, MARKERS.compaction);
175
- const toolHits = matchMarkers(pkgDir, MARKERS.toolOutput);
176
-
177
- if (compHits.length > 0) {
178
- conflicts.push({
179
- package: pkgName,
180
- severity: "high",
181
- kind: "compaction",
182
- evidence: compHits,
183
- recommendation: "Disabling recommended — competes with pi-mega-compact's conversation compaction.",
184
- });
185
- continue; // compaction is the dominant conflict; don't double-flag.
186
- }
187
- if (memHits.length > 0) {
188
- conflicts.push({
189
- package: pkgName,
190
- severity: "high",
191
- kind: "memory",
192
- evidence: memHits,
193
- recommendation: "pi-mega-compact now owns save-to-memory (/mega-memory, its own SQLite). Disable this to avoid duplicate memory stores.",
194
- });
195
- continue;
196
- }
197
- if (toolHits.length > 0) {
198
- conflicts.push({
199
- package: pkgName,
200
- severity: "info",
201
- kind: "tool-output",
202
- evidence: toolHits,
203
- recommendation: "Shapes tool output (summarize/compact tool results). Generally compatible; no action needed.",
204
- });
205
- }
206
- }
207
-
208
- return { scanned, conflicts };
189
+ const roots = collectScanRoots();
190
+ const scanned: string[] = [];
191
+ const conflicts: ConflictHit[] = [];
192
+
193
+ for (const root of roots) {
194
+ if (!existsSync(root)) continue;
195
+ let entries: string[];
196
+ try {
197
+ entries = readdirSync(root);
198
+ } catch {
199
+ continue;
200
+ }
201
+
202
+ for (const name of entries) {
203
+ const pkgDir = join(root, name);
204
+ let st;
205
+ try {
206
+ st = statSync(pkgDir);
207
+ } catch {
208
+ continue;
209
+ }
210
+ if (!st.isDirectory()) continue;
211
+
212
+ // A candidate is either a real code extension (declares pi.extensions) or a
213
+ // data-only memory store (MEMORY.md / sessions.db at its root).
214
+ const pkgJson = join(pkgDir, "package.json");
215
+ let pkg: { name?: string; pi?: { extensions?: string[] } } | null = null;
216
+ if (existsSync(pkgJson)) {
217
+ try {
218
+ pkg = JSON.parse(readFileSync(pkgJson, "utf-8"));
219
+ } catch {
220
+ pkg = null;
221
+ }
222
+ }
223
+ const isCodeExt =
224
+ !!pkg &&
225
+ !!pkg.pi &&
226
+ Array.isArray(pkg.pi.extensions) &&
227
+ pkg.pi.extensions.length > 0;
228
+ const isMemoryStore = isMemoryStoreDir(pkgDir);
229
+ if (!isCodeExt && !isMemoryStore) continue;
230
+
231
+ const pkgName = pkg?.name ?? name;
232
+ if (pkgName === selfName) continue;
233
+ scanned.push(`${pkgName} (${name})`);
234
+
235
+ if (isCodeExt) {
236
+ const memHits = matchMarkers(pkgDir, MARKERS.memory);
237
+ const compHits = matchMarkers(pkgDir, MARKERS.compaction);
238
+ const toolHits = matchMarkers(pkgDir, MARKERS.toolOutput);
239
+
240
+ if (compHits.length > 0) {
241
+ conflicts.push({
242
+ package: pkgName,
243
+ severity: "high",
244
+ kind: "compaction",
245
+ evidence: compHits,
246
+ recommendation:
247
+ "Disabling recommended — competes with pi-mega-compact's conversation compaction.",
248
+ });
249
+ continue; // compaction is the dominant conflict; don't double-flag.
250
+ }
251
+ if (memHits.length > 0) {
252
+ conflicts.push({
253
+ package: pkgName,
254
+ severity: "high",
255
+ kind: "memory",
256
+ evidence: memHits,
257
+ recommendation:
258
+ "pi-mega-compact now owns save-to-memory (/mega-memory, its own SQLite). Disable this to avoid duplicate memory stores.",
259
+ });
260
+ continue;
261
+ }
262
+ if (toolHits.length > 0) {
263
+ conflicts.push({
264
+ package: pkgName,
265
+ severity: "info",
266
+ kind: "tool-output",
267
+ evidence: toolHits,
268
+ recommendation:
269
+ "Shapes tool output (summarize/compact tool results). Generally compatible; no action needed.",
270
+ });
271
+ }
272
+ }
273
+
274
+ // Data-only memory store: no source to grep, but the on-disk signature
275
+ // (sessions.db / MEMORY.md) is a conflict with our SQLite memory store.
276
+ if (isMemoryStore) {
277
+ const evidence: string[] = [];
278
+ if (existsSync(join(pkgDir, "sessions.db")))
279
+ evidence.push("sessions.db");
280
+ if (existsSync(join(pkgDir, "MEMORY.md"))) evidence.push("MEMORY.md");
281
+ conflicts.push({
282
+ package: pkgName,
283
+ severity: "high",
284
+ kind: "memory",
285
+ evidence,
286
+ recommendation:
287
+ "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.",
288
+ });
289
+ }
290
+ }
291
+ }
292
+
293
+ return { scanned, conflicts };
209
294
  }
@@ -140,11 +140,11 @@ describe("multi-repo /api/index (S19)", () => {
140
140
 
141
141
  const { upsertRepoRegistry } = await import("../src/store/sqlite.js");
142
142
  upsertRepoRegistry(
143
- { repoRoot: "/home/u/repoA", displayName: "repoA", stateDir: dir, checkpointCount: 3, tokensSaved: 1000, compressedOriginalBytes: 0 },
143
+ { repoRoot: "/home/u/repoA", displayName: "repoA", stateDir: join(dir, "a"), checkpointCount: 3, tokensSaved: 1000, compressedOriginalBytes: 0 },
144
144
  indexDir,
145
145
  );
146
146
  upsertRepoRegistry(
147
- { repoRoot: "/home/u/repoB", displayName: "repoB", stateDir: dir, checkpointCount: 5, tokensSaved: 2000, compressedOriginalBytes: 0 },
147
+ { repoRoot: "/home/u/repoB", displayName: "repoB", stateDir: join(dir, "b"), checkpointCount: 5, tokensSaved: 2000, compressedOriginalBytes: 0 },
148
148
  indexDir,
149
149
  );
150
150