pi-mega-compact 0.4.28 → 0.5.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.
Files changed (56) hide show
  1. package/README.md +47 -2
  2. package/dist/extensions/dashboard-server.js +66 -3
  3. package/dist/extensions/dashboard-server.test.js +95 -3
  4. package/dist/extensions/mega-commands.js +25 -9
  5. package/dist/extensions/mega-compact.test.js +133 -31
  6. package/dist/extensions/mega-config.js +5 -0
  7. package/dist/extensions/mega-conflict-cmds.js +79 -0
  8. package/dist/extensions/mega-dashboard-cmds.js +6 -4
  9. package/dist/extensions/mega-events.js +144 -27
  10. package/dist/extensions/mega-pipeline.js +84 -1
  11. package/dist/extensions/mega-runtime.js +35 -2
  12. package/dist/extensions/mega-trim.js +48 -0
  13. package/dist/extensions/mega-trim.test.js +58 -0
  14. package/dist/src/config/dedup.js +1 -0
  15. package/dist/src/driftDetection.js +103 -0
  16. package/dist/src/driftDetection.test.js +87 -0
  17. package/dist/src/memory.js +147 -0
  18. package/dist/src/memory.test.js +41 -0
  19. package/dist/src/memoryConsolidate.test.js +38 -0
  20. package/dist/src/memoryOps.js +58 -0
  21. package/dist/src/memoryOps.test.js +41 -0
  22. package/dist/src/memoryRecall.js +60 -0
  23. package/dist/src/memoryRecall.test.js +92 -0
  24. package/dist/src/recall.js +70 -1
  25. package/dist/src/recall.test.js +69 -1
  26. package/dist/src/store/sqlite.js +127 -11
  27. package/dist/src/vectorStore.js +6 -1
  28. package/extensions/dashboard-server.test.ts +115 -3
  29. package/extensions/dashboard-server.ts +69 -4
  30. package/extensions/mega-commands.ts +24 -9
  31. package/extensions/mega-compact.test.ts +134 -31
  32. package/extensions/mega-config.ts +22 -0
  33. package/extensions/mega-conflict-cmds.ts +81 -0
  34. package/extensions/mega-dashboard-cmds.ts +6 -4
  35. package/extensions/mega-events.ts +139 -28
  36. package/extensions/mega-pipeline.ts +94 -1
  37. package/extensions/mega-runtime.ts +35 -2
  38. package/extensions/mega-trim.test.ts +64 -0
  39. package/extensions/mega-trim.ts +75 -0
  40. package/extensions/openclaw-mega-compact.ts +24 -9
  41. package/package.json +2 -2
  42. package/src/config/dedup.ts +2 -0
  43. package/src/driftDetection.test.ts +100 -0
  44. package/src/driftDetection.ts +136 -0
  45. package/src/memory.test.ts +46 -0
  46. package/src/memory.ts +164 -0
  47. package/src/memoryConsolidate.test.ts +47 -0
  48. package/src/memoryOps.test.ts +53 -0
  49. package/src/memoryOps.ts +75 -0
  50. package/src/memoryRecall.test.ts +100 -0
  51. package/src/memoryRecall.ts +83 -0
  52. package/src/recall.test.ts +77 -1
  53. package/src/recall.ts +94 -1
  54. package/src/store/sqlite.ts +188 -11
  55. package/src/store.ts +3 -0
  56. package/src/vectorStore.ts +10 -1
package/README.md CHANGED
@@ -151,11 +151,36 @@ building.
151
151
  > npm install && npm run build
152
152
  > ```
153
153
 
154
+ > **No tarballs — ever.** Distribution and updates go through `npm publish` +
155
+ > `pi update --extensions` **only**. Never build or rely on a `.tgz` (`npm pack`):
156
+ > a tarball bypasses pi's package manager and does not propagate to other devices.
157
+ > To validate a real install, bump the version, `npm publish`, then
158
+ > `pi update --extensions` on the device. (`.gitignore` rejects `*.tgz` so one can't
159
+ > be committed by accident.)
160
+
161
+ ### Storage backend (v0.5.0+)
162
+
163
+ pi-mega-compact uses a dual local backend — **zero network, no native build step**:
164
+
165
+ - **`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.
166
+ - **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.
167
+
168
+ Kill-switch: `MEGACOMPACT_PGLITE_DISABLED=1` fully disables the PGlite index (falls back to sync scan). Requires Node ≥22.13 (`engines.node`).
169
+
170
+ ### Cross-repo recall (v0.5.0+)
171
+
172
+ On resume, recall augments from other repos' checkpoints when this repo's store is thin; `/mega-recall --cross-repo` searches all repos via the HNSW index. Cross-repo hits use a stricter cosine floor (`MEGACOMPACT_CROSSREPO_COSINE`, default 0.90) and are labeled with their source repo. A machine-wide injected-set (`~/.mega-compact-index/index.sqlite`) prevents re-injecting the same foreign checkpoint.
173
+
174
+ ### Memory (v0.5.0+)
175
+
176
+ 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`.
177
+
154
178
  ### Verify
155
179
 
156
180
  ```bash
157
- npm test # all unit/integration tests pass (278 as of v0.4.0)
181
+ npm test # all unit/integration tests pass (346 as of v0.5.0)
158
182
  npm run lint # tsc --noEmit + guardrails scan clean
183
+ python3 scripts/regression_check.py --all # spec/plan regression gate
159
184
  ```
160
185
 
161
186
  ### Uninstall
@@ -187,7 +212,8 @@ The commands (slash commands inside pi):
187
212
  | `/mega-compact [summary...]` | Manually compact the current session. A summary arg is used verbatim; otherwise the COLLAPSE heuristics build one. Persists a `chkpt_xxx`. |
188
213
  | `/mega-compact off` | Disable auto-compaction for this session. |
189
214
  | `/mega-status` | Show config + current context usage + store stats (checkpoint count, dedup rate, tokens saved). |
190
- | `/mega-recall [query]` | Semantic-search the local store, dedupe against the current window, and inline the top-K relevant checkpoints. No query → uses your latest message. |
215
+ | `/mega-recall [query]` | Semantic-search the local store, dedupe against the current window, and inline the top-K relevant checkpoints. No query → uses your latest message. `--cross-repo` searches all repos. |
216
+ | `/mega-memory save <text>` / `save <category> <text>` / `list` / `search <query>` / `forget <text>` / `consolidate` | Manage durable memories (decisions, facts, preferences) written by auto-review and recalled as RAG context. Also `/m` shortform. |
191
217
  | `/mega-tier [name]` | Set the compaction tier (`low` / `medium` / `high` / `ultra` / `mega`). Shows current tier with no arg. |
192
218
  | `/mega-dashboard` | Start the **localhost-only** live dashboard and open it in a browser (token gauge, store stats, live event stream). |
193
219
  | `/mega-dashboard-status` | Report dashboard server status. |
@@ -259,6 +285,25 @@ See `docs/DEDUP_RUNBOOK.md` for incident response (SEV tiers, first-15-min
259
285
  checklist, MARK_ONLY degrade) and `docs/RETENTION_POLICY.md` for TTL / soft-delete
260
286
  / VACUUM.
261
287
 
288
+ #### Continuity + memory knobs (v0.5.0)
289
+
290
+ | Variable | Default | Meaning |
291
+ |---|---|---|
292
+ | `MEGACOMPACT_LEGACY_DURABLE_TRIM` | `false` | Restore the v0.4.28 auto-trigger (`ctx.compact()` stops the agent). One-release rollback; default uses live context-event trim + pi native auto-compaction (compact-and-continue). |
293
+ | `MEGACOMPACT_CROSSREPO_ENABLED` | `true` | Cross-repo recall on resume + `/mega-recall --cross-repo` (HNSW index over every repo). |
294
+ | `MEGACOMPACT_CROSSREPO_COSINE` | `0.90` | Stricter cosine floor for cross-repo hits (vs `0.85` same-repo). |
295
+ | `MEGACOMPACT_MEMORY_AUTO_REVIEW` | `true` | Auto-review the conversation every `MEGACOMPACT_MEMORY_REVIEW_INTERVAL` turns → durable memories. |
296
+ | `MEGACOMPACT_MEMORY_REVIEW_INTERVAL` | `10` | Turns between auto-review cycles. |
297
+ | `MEGACOMPACT_PGLITE_DISABLED` | `1` | Kill-switch for the PGlite/HNSW cross-repo index (falls back to sync per-session scan). |
298
+
299
+ #### Dashboard (v0.5.0)
300
+
301
+ The localhost-only dashboard adds a **Summary** + **All-repos** view over the
302
+ machine-wide `repo_registry`, plus a **cross-repo drift** report (`GET /api/drift`)
303
+ flagging stale repos (>30d idle), compaction lag (an active repo >24h behind the
304
+ most-recently-active repo's last compaction), and recent model churn (within 7d).
305
+ All read-only — the report never writes the index.
306
+
262
307
  ---
263
308
 
264
309
  ## Reporting for testers (what to capture)
@@ -15,6 +15,7 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync,
15
15
  import { homedir } from "node:os";
16
16
  import { join, dirname } from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
+ import { createRequire } from "node:module";
18
19
  import { DatabaseSync } from "node:sqlite";
19
20
  // ---------------------------------------------------------------------------
20
21
  // Local runtime log
@@ -118,6 +119,11 @@ function readIndex() {
118
119
  }
119
120
  }
120
121
  // ---------------------------------------------------------------------------
122
+ // Types
123
+ // ---------------------------------------------------------------------------
124
+ /** Package version of this extension, surfaced in the dashboard header. */
125
+ let dashboardServerVersion = "0.0.0";
126
+ // ---------------------------------------------------------------------------
121
127
  // Helpers
122
128
  // ---------------------------------------------------------------------------
123
129
  function readSnapshot(snapshotPath) {
@@ -168,6 +174,7 @@ function dashboardHtml(tierName) {
168
174
  body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #0d1117; color: #c9d1d9; padding: 24px; line-height: 1.5; }
169
175
  h1 { font-size: 20px; font-weight: 600; margin-bottom: 20px; display: flex; align-items: center; gap: 10px; color: #f0f6fc; }
170
176
  h1 .tier { background: #1f6feb; color: #fff; font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 10px; text-transform: uppercase; letter-spacing: .5px; }
177
+ h1 .version-pill { background: #30363d; color: #8b949e; font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
171
178
  .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
172
179
  .card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
173
180
  .card.safe { border-color: #238636; }
@@ -254,7 +261,7 @@ function dashboardHtml(tierName) {
254
261
 
255
262
  <div class="offline-banner" id="offline-banner">Dashboard data unavailable — waiting for a pi session to write snapshot...</div>
256
263
 
257
- <h1><span>mega-compact</span><span class="tier">${tierName}</span><span class="model-pill" id="hdr-model">—</span></h1>
264
+ <h1><span>mega-compact</span><span class="tier">${tierName}</span><span class="version-pill">v${dashboardServerVersion}</span><span class="model-pill" id="hdr-model">—</span></h1>
258
265
 
259
266
  <nav class="tabs">
260
267
  <button class="tab active" data-tab="current">Current repo</button>
@@ -688,11 +695,17 @@ export async function launchDashboardServer(stateDir) {
688
695
  const pkg = JSON.parse(readFileSync(p, "utf-8"));
689
696
  if (pkg.version) {
690
697
  SERVER_VERSION = pkg.version;
698
+ dashboardServerVersion = pkg.version;
691
699
  break;
692
700
  }
693
701
  }
694
702
  }
695
703
  catch { /* non-fatal */ }
704
+ // Lazy-loaded via require so the dashboard stays cheap to boot and we don't
705
+ // need a top-level await in the handler.
706
+ const driftReq = createRequire(import.meta.url);
707
+ const detectCrossRepoDrift = (idxDir) => driftReq("../src/driftDetection.js")
708
+ .detectCrossRepoDrift(idxDir);
696
709
  const portFile = join(stateDir, "port.pid");
697
710
  const snapshotPath = join(stateDir, "dashboard.json");
698
711
  const eventsPath = join(stateDir, "events.log");
@@ -772,6 +785,51 @@ export async function launchDashboardServer(stateDir) {
772
785
  res.end(JSON.stringify(readIndex() ?? { updatedAt: null, summary: null, repos: [] }));
773
786
  return;
774
787
  }
788
+ // /api/repos — registry list. Optional `?active=24h` filters to repos
789
+ // seen within the last N hours (default: all). The dashboard uses this to
790
+ // drive its "active vs archived" badge without refetching /api/index.
791
+ if (req.url?.startsWith("/api/repos")) {
792
+ const url = new URL(req.url, "http://x");
793
+ const activeParam = url.searchParams.get("active");
794
+ const idx = readIndex() ?? { updatedAt: null, summary: null, repos: [] };
795
+ let repos = (idx.repos ?? []);
796
+ if (activeParam) {
797
+ const m = /^(\d+)h$/.exec(activeParam);
798
+ if (m) {
799
+ const cutoffSec = Math.floor(Date.now() / 1000) - Number(m[1]) * 3600;
800
+ repos = repos.filter((r) => (r.lastSeen ?? 0) >= cutoffSec);
801
+ }
802
+ }
803
+ res.writeHead(200, { "Content-Type": "application/json" });
804
+ res.end(JSON.stringify({ updatedAt: idx.updatedAt, repos, count: repos.length }));
805
+ return;
806
+ }
807
+ // /api/summary — header tiles without the full repo list (keeps payload
808
+ // small for embed scenarios). activeRepos mirrors the /api/repos?active=24h
809
+ // count so the dashboard can render the active badge alongside totals.
810
+ if (req.url?.startsWith("/api/summary")) {
811
+ const idx = readIndex() ?? { updatedAt: null, summary: null, repos: [] };
812
+ const repos = (idx.repos ?? []);
813
+ const cutoffSec = Math.floor(Date.now() / 1000) - 24 * 3600;
814
+ const activeRepos = repos.filter((r) => (r.lastSeen ?? 0) >= cutoffSec).length;
815
+ res.writeHead(200, { "Content-Type": "application/json" });
816
+ res.end(JSON.stringify({
817
+ updatedAt: idx.updatedAt,
818
+ summary: idx.summary,
819
+ activeRepos,
820
+ totalRepos: repos.length,
821
+ }));
822
+ return;
823
+ }
824
+ // /api/drift — R4: cross-repo drift report over repo_registry. Flags stale
825
+ // repos (>30d idle), compaction lag (active but >24h since last
826
+ // compaction), and recent model churn. Read-only.
827
+ if (req.url?.startsWith("/api/drift")) {
828
+ const report = detectCrossRepoDrift(getIndexDir());
829
+ res.writeHead(200, { "Content-Type": "application/json" });
830
+ res.end(JSON.stringify(report));
831
+ return;
832
+ }
775
833
  if (req.url === "/api/events") {
776
834
  res.writeHead(200, {
777
835
  "Content-Type": "text/event-stream",
@@ -838,8 +896,13 @@ export async function launchDashboardServer(stateDir) {
838
896
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
839
897
  res.end(dashboardHtml(tier));
840
898
  });
841
- const TARGET_PORT = 9320;
842
- const PORT_RANGE = 10; // 9320–9329
899
+ // Bind base + range are env-configurable so tests can use a private,
900
+ // non-colliding range (parallel runs / leftover servers from killed runs
901
+ // would otherwise EADDRINUSE on the machine-global 9320 range). Default
902
+ // MEGACOMPACT_DASHBOARD_PORT=9320 (10-port range 9320–9329) preserves the
903
+ // production behavior.
904
+ const TARGET_PORT = Number(process.env.MEGACOMPACT_DASHBOARD_PORT ?? "9320");
905
+ const PORT_RANGE = 10; // TARGET_PORT..TARGET_PORT+9
843
906
  return new Promise((resolve, reject) => {
844
907
  function tryPort(port) {
845
908
  server.once("error", (err) => {
@@ -111,6 +111,91 @@ describe("port.pid file", () => {
111
111
  });
112
112
  });
113
113
  // ---------------------------------------------------------------------------
114
+ // Multi-repo dashboard (S19 / Phase 5b) — launch the real server subprocess,
115
+ // seed the machine-wide repo_registry, and assert /api/index returns every repo
116
+ // plus the aggregate summary the Summary + All-repos tabs render.
117
+ // ---------------------------------------------------------------------------
118
+ describe("multi-repo /api/index (S19)", () => {
119
+ test("lists all repos from the global index with an aggregate summary", async () => {
120
+ const dir = mkdtempSync(join(tmpdir(), "dash-index-"));
121
+ const indexDir = mkdtempSync(join(tmpdir(), "index-"));
122
+ // The server reads MEGACOMPACT_INDEX_DIR for the machine-wide registry.
123
+ process.env.MEGACOMPACT_INDEX_DIR = indexDir;
124
+ process.env.MEGACOMPACT_DASHBOARD_PORT = "19321"; // private base, non-colliding
125
+ const { upsertRepoRegistry } = await import("../src/store/sqlite.js");
126
+ upsertRepoRegistry({ repoRoot: "/home/u/repoA", displayName: "repoA", stateDir: dir, checkpointCount: 3, tokensSaved: 1000, compressedOriginalBytes: 0 }, indexDir);
127
+ upsertRepoRegistry({ repoRoot: "/home/u/repoB", displayName: "repoB", stateDir: dir, checkpointCount: 5, tokensSaved: 2000, compressedOriginalBytes: 0 }, indexDir);
128
+ const child = spawn(process.execPath, [SERVER_ENTRY, dir], { stdio: "ignore" });
129
+ try {
130
+ await waitFor(async () => {
131
+ try {
132
+ const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
133
+ const res = await fetch(`http://localhost:${raw.port}/api/version`);
134
+ return res.ok;
135
+ }
136
+ catch {
137
+ return false;
138
+ }
139
+ });
140
+ const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
141
+ const idx = (await fetch(`http://localhost:${raw.port}/api/index`).then((r) => r.json()));
142
+ const names = idx.repos.map((r) => r.displayName).sort();
143
+ assert.deepEqual(names, ["repoA", "repoB"], "both repos from the global index");
144
+ assert.equal(idx.summary.totalRepos, 2, "repo count");
145
+ assert.equal(idx.summary.totalCheckpoints, 8, "3 + 5 checkpoints");
146
+ assert.equal(idx.summary.totalTokensSaved, 3000, "1000 + 2000 tokens saved");
147
+ }
148
+ finally {
149
+ child.kill("SIGTERM");
150
+ delete process.env.MEGACOMPACT_INDEX_DIR;
151
+ delete process.env.MEGACOMPACT_DASHBOARD_PORT;
152
+ rmSync(dir, { recursive: true, force: true });
153
+ rmSync(indexDir, { recursive: true, force: true });
154
+ }
155
+ });
156
+ test("/api/repos filters by ?active=Nh and /api/summary counts activeRepos", async () => {
157
+ const dir = mkdtempSync(join(tmpdir(), "dash-active-"));
158
+ const indexDir = mkdtempSync(join(tmpdir(), "index-active-"));
159
+ process.env.MEGACOMPACT_INDEX_DIR = indexDir;
160
+ process.env.MEGACOMPACT_DASHBOARD_PORT = "19322";
161
+ const { upsertRepoRegistry } = await import("../src/store/sqlite.js");
162
+ // Fresh repo, last_seen = now
163
+ upsertRepoRegistry({ repoRoot: "/home/u/fresh", displayName: "fresh", stateDir: dir, checkpointCount: 1, tokensSaved: 100, compressedOriginalBytes: 0, lastSeen: Math.floor(Date.now() / 1000) }, indexDir);
164
+ // Stale repo, last_seen = 90 days ago — must be filtered out by ?active=24h.
165
+ const longAgo = Math.floor(Date.now() / 1000) - 90 * 86_400;
166
+ upsertRepoRegistry({ repoRoot: "/home/u/stale", displayName: "stale", stateDir: dir, checkpointCount: 2, tokensSaved: 200, compressedOriginalBytes: 0, lastSeen: longAgo }, indexDir);
167
+ const child = spawn(process.execPath, [SERVER_ENTRY, dir], { stdio: "ignore" });
168
+ try {
169
+ await waitFor(async () => {
170
+ try {
171
+ const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
172
+ const res = await fetch(`http://localhost:${raw.port}/api/version`);
173
+ return res.ok;
174
+ }
175
+ catch {
176
+ return false;
177
+ }
178
+ });
179
+ const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
180
+ const allRepos = (await fetch(`http://localhost:${raw.port}/api/repos`).then((r) => r.json()));
181
+ assert.equal(allRepos.count, 2, "unfiltered list has both repos");
182
+ const activeRepos = (await fetch(`http://localhost:${raw.port}/api/repos?active=24h`).then((r) => r.json()));
183
+ assert.equal(activeRepos.count, 1, "active=24h drops the 90-day-old repo");
184
+ assert.equal(activeRepos.repos[0].displayName, "fresh");
185
+ const summary = (await fetch(`http://localhost:${raw.port}/api/summary`).then((r) => r.json()));
186
+ assert.equal(summary.activeRepos, 1, "summary counts only fresh repo as active");
187
+ assert.equal(summary.totalRepos, 2, "summary counts both repos total");
188
+ }
189
+ finally {
190
+ child.kill("SIGTERM");
191
+ delete process.env.MEGACOMPACT_INDEX_DIR;
192
+ delete process.env.MEGACOMPACT_DASHBOARD_PORT;
193
+ rmSync(dir, { recursive: true, force: true });
194
+ rmSync(indexDir, { recursive: true, force: true });
195
+ }
196
+ });
197
+ });
198
+ // ---------------------------------------------------------------------------
114
199
  // Lifecycle integration — launch the compiled server as a real subprocess
115
200
  // (the same way the /dashboard command spawns it) and assert the two failure
116
201
  // modes that historically produced a silent "failed to start":
@@ -120,6 +205,11 @@ describe("port.pid file", () => {
120
205
  // silent under stdio:"ignore".
121
206
  // ---------------------------------------------------------------------------
122
207
  const SERVER_ENTRY = new URL("./dashboard-server.js", import.meta.url).pathname;
208
+ // Tests run in parallel across files and a killed run can leave a server bound
209
+ // to 9320. Use a private, non-colliding base so this file never races the
210
+ // mega-compact.test.js dashboard tests (which scan a DIFFERENT base) and never
211
+ // collides with a leftover production server on 9320.
212
+ process.env.MEGACOMPACT_DASHBOARD_PORT = "19320";
123
213
  function waitFor(cond, timeoutMs = 6000) {
124
214
  const start = Date.now();
125
215
  return new Promise((resolve, reject) => {
@@ -136,8 +226,10 @@ function waitFor(cond, timeoutMs = 6000) {
136
226
  describe("server lifecycle", () => {
137
227
  test("drops a stale port.pid and binds a fresh port", async () => {
138
228
  const dir = mkdtempSync(join(tmpdir(), "dash-stale-"));
139
- // A marker claiming a port where nothing is listening.
140
- writeFileSync(join(dir, "port.pid"), JSON.stringify({ port: 9325, pid: 999999 }));
229
+ // A marker claiming a port where nothing is listening — use the test's own
230
+ // private base + 5 so the dead port is inside the server's scan range.
231
+ const deadPort = 19325;
232
+ writeFileSync(join(dir, "port.pid"), JSON.stringify({ port: deadPort, pid: 999999 }));
141
233
  const child = spawn(process.execPath, [SERVER_ENTRY, dir], { stdio: "ignore" });
142
234
  try {
143
235
  // Wait for the server to actually be live (not just any port.pid — the
@@ -154,7 +246,7 @@ describe("server lifecycle", () => {
154
246
  });
155
247
  const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
156
248
  assert.equal(typeof raw.port, "number");
157
- assert.notEqual(raw.port, 9325, "should not reuse the dead port from the stale marker");
249
+ assert.notEqual(raw.port, deadPort, "should not reuse the dead port from the stale marker");
158
250
  // And a real server must answer on it.
159
251
  const res = await fetch(`http://localhost:${raw.port}/api/version`);
160
252
  assert.equal(res.ok, true);
@@ -7,11 +7,11 @@
7
7
  */
8
8
  import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
9
9
  import { normalizeSessionId } from "../src/store.js";
10
- import { listCheckpoints, latestModelSnapshot } from "../src/store/sqlite.js";
10
+ import { listCheckpoints, latestModelSnapshot, countInjectedGlobal, listRepoRegistry } from "../src/store/sqlite.js";
11
11
  import { decompressSmart } from "../src/store/compression.js";
12
12
  import { loadMetrics, fpRate, p95 } from "../src/monitoring.js";
13
13
  import { C, recentUserQuery } from "./mega-runtime.js";
14
- import { runCompact, doRecall } from "./mega-pipeline.js";
14
+ import { runCompact, doRecall, doRecallAsync } from "./mega-pipeline.js";
15
15
  import { setTier, COMPACT_TIERS } from "./mega-config.js";
16
16
  /** Resolve a checkpoint by id (or "recent"/"last") from this session's store. */
17
17
  export function findCheckpoint(runtime, sid, ref) {
@@ -42,16 +42,21 @@ export function registerCommands(pi, runtime, config) {
42
42
  },
43
43
  });
44
44
  pi.registerCommand("mega-recall", {
45
- description: "Recall relevant compacted context from the vector store and inline it.",
45
+ description: "Recall relevant compacted context from the vector store and inline it. Use --cross-repo to search all repos.",
46
46
  handler: async (args, ctx) => {
47
- const query = args.trim() || recentUserQuery(ctx);
47
+ // S17: --cross-repo (or --cross repo) runs the async path over every repo's
48
+ // PGlite HNSW index (stricter cosine floor + source labels).
49
+ const crossRepo = /\-\-cross[\- ]repo\b/.test(args);
50
+ const query = args.replace(/--cross[\- ]repo\b/, "").trim() || recentUserQuery(ctx);
48
51
  if (!query) {
49
52
  ctx.ui.notify("[mega-compact] /mega-recall needs a query or a prior user message.");
50
53
  return;
51
54
  }
52
- const r = doRecall(runtime, config, ctx, query, "command");
55
+ const r = crossRepo
56
+ ? await doRecallAsync(runtime, config, ctx, query, "command", { crossRepo: true })
57
+ : doRecall(runtime, config, ctx, query, "command");
53
58
  if (r.empty) {
54
- runtime.logger.info("recall-empty", { query });
59
+ runtime.logger.info("recall-empty", { query, crossRepo });
55
60
  ctx.ui.notify(`[mega-compact] recall found nothing new for "${query}".`);
56
61
  return;
57
62
  }
@@ -59,9 +64,9 @@ export function registerCommands(pi, runtime, config) {
59
64
  // injection). Report what was selected now for immediate feedback.
60
65
  runtime.pendingRecallBlock = r.block;
61
66
  const list = r.report.map((l) => l).join("\n");
62
- runtime.logger.info("recall", { query, injected: r.toInject.map((h) => h.checkpoint.checkpointId) });
63
- runtime.setStatus(ctx, `mega-compact: recalled ${r.toInject.length} chkpt`);
64
- ctx.ui.notify(`[mega-compact] recall staged ${r.toInject.length} checkpoint(s) for "${query}":\n${list}\n` +
67
+ runtime.logger.info("recall", { query, crossRepo, injected: r.toInject.map((h) => h.checkpoint.checkpointId) });
68
+ runtime.setStatus(ctx, `mega-compact: recalled ${r.toInject.length} chkpt${crossRepo ? " (cross-repo)" : ""}`);
69
+ ctx.ui.notify(`[mega-compact] recall staged ${r.toInject.length} checkpoint(s) for "${query}"${crossRepo ? " (cross-repo)" : ""}:\n${list}\n` +
65
70
  `(injected at the next turn via system prompt)`);
66
71
  },
67
72
  });
@@ -102,6 +107,16 @@ export function registerCommands(pi, runtime, config) {
102
107
  const p95L2 = p95(m.latency.L2 ?? []);
103
108
  const relPct = (st.dedupHitRate * 100).toFixed(0);
104
109
  const qualityStr = `recall ${relPct}% relevant · FP ${(fp * 100).toFixed(1)}% · L2 p95 ${p95L2.toFixed(0)}ms`;
110
+ // S18: cross-repo stats from the machine-wide index (best-effort; the
111
+ // index dir may be unset → 0/empty, never throws).
112
+ let crossRepoInjections = 0;
113
+ let repoCount = 0;
114
+ try {
115
+ crossRepoInjections = countInjectedGlobal(process.env.MEGACOMPACT_INDEX_DIR);
116
+ repoCount = listRepoRegistry(process.env.MEGACOMPACT_INDEX_DIR).length;
117
+ }
118
+ catch { /* non-fatal */ }
119
+ const crossRepoStr = `${crossRepoInjections} cross-repo injections recorded · ${repoCount} repos indexed`;
105
120
  ctx.ui.notify(`[mega-compact] pct=${pct} tokens=${tokens} tier=${config.tier} fastGate=${config.fastGatePct}% ` +
106
121
  `threshold=${config.thresholdTokens} auto=${config.auto} autoInline=${config.autoInline}\n` +
107
122
  `[mega-compact] store: ${st.checkpointCount} chkpt · ` +
@@ -116,6 +131,7 @@ export function registerCommands(pi, runtime, config) {
116
131
  `[mega-compact] 💰 ${costStr}\n` +
117
132
  `[mega-compact] 🤖 model: ${modelStr}\n` +
118
133
  `[mega-compact] 🎯 ${qualityStr}\n` +
134
+ `[mega-compact] 🌐 ${crossRepoStr}\n` +
119
135
  `[mega-compact] stateDir=${runtime.currentStateDir}`);
120
136
  },
121
137
  });