pi-mega-compact 0.4.28 → 0.5.0
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 +47 -2
- package/dist/extensions/dashboard-server.js +58 -2
- package/dist/extensions/dashboard-server.test.js +95 -3
- package/dist/extensions/mega-commands.js +25 -9
- package/dist/extensions/mega-compact.test.js +133 -31
- package/dist/extensions/mega-config.js +5 -0
- package/dist/extensions/mega-conflict-cmds.js +79 -0
- package/dist/extensions/mega-dashboard-cmds.js +6 -4
- package/dist/extensions/mega-events.js +144 -27
- package/dist/extensions/mega-pipeline.js +84 -1
- package/dist/extensions/mega-runtime.js +14 -0
- package/dist/extensions/mega-trim.js +48 -0
- package/dist/extensions/mega-trim.test.js +58 -0
- package/dist/src/config/dedup.js +1 -0
- package/dist/src/driftDetection.js +103 -0
- package/dist/src/driftDetection.test.js +87 -0
- package/dist/src/memory.js +147 -0
- package/dist/src/memory.test.js +41 -0
- package/dist/src/memoryConsolidate.test.js +38 -0
- package/dist/src/memoryOps.js +58 -0
- package/dist/src/memoryOps.test.js +41 -0
- package/dist/src/memoryRecall.js +60 -0
- package/dist/src/memoryRecall.test.js +92 -0
- package/dist/src/recall.js +70 -1
- package/dist/src/recall.test.js +69 -1
- package/dist/src/store/sqlite.js +127 -11
- package/dist/src/vectorStore.js +6 -1
- package/extensions/dashboard-server.test.ts +115 -3
- package/extensions/dashboard-server.ts +63 -2
- package/extensions/mega-commands.ts +24 -9
- package/extensions/mega-compact.test.ts +134 -31
- package/extensions/mega-config.ts +22 -0
- package/extensions/mega-conflict-cmds.ts +81 -0
- package/extensions/mega-dashboard-cmds.ts +6 -4
- package/extensions/mega-events.ts +139 -28
- package/extensions/mega-pipeline.ts +94 -1
- package/extensions/mega-runtime.ts +15 -0
- package/extensions/mega-trim.test.ts +64 -0
- package/extensions/mega-trim.ts +75 -0
- package/extensions/openclaw-mega-compact.ts +24 -9
- package/package.json +2 -2
- package/src/config/dedup.ts +2 -0
- package/src/driftDetection.test.ts +100 -0
- package/src/driftDetection.ts +136 -0
- package/src/memory.test.ts +46 -0
- package/src/memory.ts +164 -0
- package/src/memoryConsolidate.test.ts +47 -0
- package/src/memoryOps.test.ts +53 -0
- package/src/memoryOps.ts +75 -0
- package/src/memoryRecall.test.ts +100 -0
- package/src/memoryRecall.ts +83 -0
- package/src/recall.test.ts +77 -1
- package/src/recall.ts +94 -1
- package/src/store/sqlite.ts +188 -11
- package/src/store.ts +3 -0
- package/src/vectorStore.ts +10 -1
- 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
|
@@ -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 (
|
|
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
|
|
@@ -693,6 +694,11 @@ export async function launchDashboardServer(stateDir) {
|
|
|
693
694
|
}
|
|
694
695
|
}
|
|
695
696
|
catch { /* non-fatal */ }
|
|
697
|
+
// Lazy-loaded via require so the dashboard stays cheap to boot and we don't
|
|
698
|
+
// need a top-level await in the handler.
|
|
699
|
+
const driftReq = createRequire(import.meta.url);
|
|
700
|
+
const detectCrossRepoDrift = (idxDir) => driftReq("../src/driftDetection.js")
|
|
701
|
+
.detectCrossRepoDrift(idxDir);
|
|
696
702
|
const portFile = join(stateDir, "port.pid");
|
|
697
703
|
const snapshotPath = join(stateDir, "dashboard.json");
|
|
698
704
|
const eventsPath = join(stateDir, "events.log");
|
|
@@ -772,6 +778,51 @@ export async function launchDashboardServer(stateDir) {
|
|
|
772
778
|
res.end(JSON.stringify(readIndex() ?? { updatedAt: null, summary: null, repos: [] }));
|
|
773
779
|
return;
|
|
774
780
|
}
|
|
781
|
+
// /api/repos — registry list. Optional `?active=24h` filters to repos
|
|
782
|
+
// seen within the last N hours (default: all). The dashboard uses this to
|
|
783
|
+
// drive its "active vs archived" badge without refetching /api/index.
|
|
784
|
+
if (req.url?.startsWith("/api/repos")) {
|
|
785
|
+
const url = new URL(req.url, "http://x");
|
|
786
|
+
const activeParam = url.searchParams.get("active");
|
|
787
|
+
const idx = readIndex() ?? { updatedAt: null, summary: null, repos: [] };
|
|
788
|
+
let repos = (idx.repos ?? []);
|
|
789
|
+
if (activeParam) {
|
|
790
|
+
const m = /^(\d+)h$/.exec(activeParam);
|
|
791
|
+
if (m) {
|
|
792
|
+
const cutoffSec = Math.floor(Date.now() / 1000) - Number(m[1]) * 3600;
|
|
793
|
+
repos = repos.filter((r) => (r.lastSeen ?? 0) >= cutoffSec);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
797
|
+
res.end(JSON.stringify({ updatedAt: idx.updatedAt, repos, count: repos.length }));
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
// /api/summary — header tiles without the full repo list (keeps payload
|
|
801
|
+
// small for embed scenarios). activeRepos mirrors the /api/repos?active=24h
|
|
802
|
+
// count so the dashboard can render the active badge alongside totals.
|
|
803
|
+
if (req.url?.startsWith("/api/summary")) {
|
|
804
|
+
const idx = readIndex() ?? { updatedAt: null, summary: null, repos: [] };
|
|
805
|
+
const repos = (idx.repos ?? []);
|
|
806
|
+
const cutoffSec = Math.floor(Date.now() / 1000) - 24 * 3600;
|
|
807
|
+
const activeRepos = repos.filter((r) => (r.lastSeen ?? 0) >= cutoffSec).length;
|
|
808
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
809
|
+
res.end(JSON.stringify({
|
|
810
|
+
updatedAt: idx.updatedAt,
|
|
811
|
+
summary: idx.summary,
|
|
812
|
+
activeRepos,
|
|
813
|
+
totalRepos: repos.length,
|
|
814
|
+
}));
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
// /api/drift — R4: cross-repo drift report over repo_registry. Flags stale
|
|
818
|
+
// repos (>30d idle), compaction lag (active but >24h since last
|
|
819
|
+
// compaction), and recent model churn. Read-only.
|
|
820
|
+
if (req.url?.startsWith("/api/drift")) {
|
|
821
|
+
const report = detectCrossRepoDrift(getIndexDir());
|
|
822
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
823
|
+
res.end(JSON.stringify(report));
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
775
826
|
if (req.url === "/api/events") {
|
|
776
827
|
res.writeHead(200, {
|
|
777
828
|
"Content-Type": "text/event-stream",
|
|
@@ -838,8 +889,13 @@ export async function launchDashboardServer(stateDir) {
|
|
|
838
889
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
839
890
|
res.end(dashboardHtml(tier));
|
|
840
891
|
});
|
|
841
|
-
|
|
842
|
-
|
|
892
|
+
// Bind base + range are env-configurable so tests can use a private,
|
|
893
|
+
// non-colliding range (parallel runs / leftover servers from killed runs
|
|
894
|
+
// would otherwise EADDRINUSE on the machine-global 9320 range). Default
|
|
895
|
+
// MEGACOMPACT_DASHBOARD_PORT=9320 (10-port range 9320–9329) preserves the
|
|
896
|
+
// production behavior.
|
|
897
|
+
const TARGET_PORT = Number(process.env.MEGACOMPACT_DASHBOARD_PORT ?? "9320");
|
|
898
|
+
const PORT_RANGE = 10; // TARGET_PORT..TARGET_PORT+9
|
|
843
899
|
return new Promise((resolve, reject) => {
|
|
844
900
|
function tryPort(port) {
|
|
845
901
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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
|
});
|
|
@@ -17,6 +17,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|
|
17
17
|
import { tmpdir } from "node:os";
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
import { createRequire } from "node:module";
|
|
20
|
+
import { closeVectorIndex } from "../src/store/vectorIndex.js";
|
|
20
21
|
const require = createRequire(import.meta.url);
|
|
21
22
|
const baseTmp = mkdtempSync(join(tmpdir(), "mc-ext-"));
|
|
22
23
|
// Isolate the machine-wide repo index so test runs (which call bindRepo ->
|
|
@@ -163,14 +164,18 @@ function harness(opts = {}) {
|
|
|
163
164
|
session,
|
|
164
165
|
};
|
|
165
166
|
}
|
|
166
|
-
test("auto-trigger: past threshold persists a chkpt and starts a durable trim", async () => {
|
|
167
|
+
test("auto-trigger (legacy): past threshold persists a chkpt and starts a durable trim via ctx.compact", async () => {
|
|
167
168
|
const h = harness();
|
|
168
169
|
const messages = h.session;
|
|
169
170
|
// The mock session is tiny (~100 tokens). piCompactWouldNoop() would skip
|
|
170
171
|
// ctx.compact() for a transcript under pi's keepRecentTokens budget — so
|
|
171
172
|
// lower the floor to 0 to simulate a transcript large enough that pi WOULD
|
|
172
173
|
// compact (the positive path this test exercises).
|
|
174
|
+
// S16: this is the LEGACY path — the default no longer calls ctx.compact()
|
|
175
|
+
// (it returns a live-trimmed view instead). Set the legacy flag to exercise
|
|
176
|
+
// the v0.4.28 ctx.compact durable-trim flow this test asserts.
|
|
173
177
|
process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR = "0";
|
|
178
|
+
process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM = "true";
|
|
174
179
|
try {
|
|
175
180
|
const ctx = h.ctx({ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
|
|
176
181
|
const res = await h.fire("context", { type: "context", messages }, ctx);
|
|
@@ -178,34 +183,113 @@ test("auto-trigger: past threshold persists a chkpt and starts a durable trim",
|
|
|
178
183
|
const { listCheckpoints } = await import("../src/store/sqlite.js");
|
|
179
184
|
assert.ok(listCheckpoints("sess_ext_001", h.stateDir).length > 0, "checkpoint persisted to local vector db");
|
|
180
185
|
assert.equal(h.appended.some((a) => a.t === "mega-compact-marker"), true, "marker sentinel appended");
|
|
181
|
-
// The context handler
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
assert.equal(
|
|
185
|
-
assert.equal(h.compactCalls.length, 1, "ctx.compact() called to start durable trim");
|
|
186
|
+
// The legacy context handler triggers pi's compaction flow (ctx.compact),
|
|
187
|
+
// which calls our session_before_compact handler to supply the DURABLE trim.
|
|
188
|
+
assert.equal(res, undefined, "legacy context handler returns nothing (no local drop)");
|
|
189
|
+
assert.equal(h.compactCalls.length, 1, "ctx.compact() called to start durable trim (legacy path)");
|
|
186
190
|
// The durable trim was supplied (summary + firstKeptEntryId from pi's prep).
|
|
187
191
|
assert.ok(h.compactCalls[0] !== undefined, "compaction flow executed");
|
|
188
192
|
}
|
|
189
193
|
finally {
|
|
190
194
|
delete process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
|
|
195
|
+
delete process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM;
|
|
191
196
|
}
|
|
192
197
|
});
|
|
193
|
-
test("auto-trigger: skips ctx.compact() when pi would no-op (session too small)", async () => {
|
|
198
|
+
test("auto-trigger: skips ctx.compact() when pi would no-op (session too small, legacy path)", async () => {
|
|
194
199
|
const h = harness();
|
|
195
200
|
const messages = h.session;
|
|
196
201
|
// Default floor (20000): the tiny mock transcript is below pi's
|
|
197
202
|
// keepRecentTokens budget, so piCompactWouldNoop() must skip ctx.compact()
|
|
198
203
|
// rather than surface pi's "Nothing to compact (session too small)" throw.
|
|
204
|
+
// S16: exercised under the legacy flag (the default path never calls ctx.compact).
|
|
199
205
|
delete process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
|
|
206
|
+
process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM = "true";
|
|
207
|
+
try {
|
|
208
|
+
const ctx = h.ctx({ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
|
|
209
|
+
const res = await h.fire("context", { type: "context", messages }, ctx);
|
|
210
|
+
assert.equal(res, undefined, "legacy context handler returns nothing (no local drop)");
|
|
211
|
+
assert.equal(h.compactCalls.length, 0, "ctx.compact() NOT called — pi would no-op");
|
|
212
|
+
// Our recall checkpoint still persisted (Path A) — the durable trim is the
|
|
213
|
+
// only thing skipped; recall is independent of it.
|
|
214
|
+
const { listCheckpoints } = await import("../src/store/sqlite.js");
|
|
215
|
+
assert.ok(listCheckpoints("sess_ext_001", h.stateDir).length > 0, "recall checkpoint still persisted");
|
|
216
|
+
assert.equal(h.appended.some((a) => a.t === "mega-compact-marker"), true, "marker sentinel still appended");
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
delete process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM;
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
test("auto-trigger (S16): trims the live view and does NOT call ctx.compact()", async () => {
|
|
223
|
+
const h = harness();
|
|
224
|
+
const messages = h.session;
|
|
225
|
+
// S16 default: live context-event trim. No legacy flag. Lower the anchor floor
|
|
226
|
+
// so the trimmed recent window (4 messages, 2 user) clears the anchor check
|
|
227
|
+
// and the live trim actually fires — mirrors how the legacy test lowers the
|
|
228
|
+
// durable floor to exercise its positive path.
|
|
229
|
+
delete process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM;
|
|
230
|
+
delete process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
|
|
231
|
+
process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES = "1";
|
|
232
|
+
try {
|
|
233
|
+
const ctx = h.ctx({ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
|
|
234
|
+
const res = await h.fire("context", { type: "context", messages }, ctx);
|
|
235
|
+
// S16: context handler returns a TRIMMED messages array (live trim), not undefined.
|
|
236
|
+
assert.ok(res && typeof res === "object", "context handler returns a result object (live trim)");
|
|
237
|
+
assert.ok(Array.isArray(res.messages), "result has a trimmed messages array");
|
|
238
|
+
// The trimmed view starts with the compacted summary (user-role) + is shorter.
|
|
239
|
+
assert.ok(res.messages.length < messages.length, "trimmed view is shorter than the full session");
|
|
240
|
+
// S16: ctx.compact() is NEVER called (it would stop the agent).
|
|
241
|
+
assert.equal(h.compactCalls.length, 0, "ctx.compact() NOT called — compact-and-continue");
|
|
242
|
+
// The recall checkpoint is still persisted (the durable value).
|
|
243
|
+
const { listCheckpoints } = await import("../src/store/sqlite.js");
|
|
244
|
+
assert.ok(listCheckpoints("sess_ext_001", h.stateDir).length > 0, "recall checkpoint persisted under live trim");
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
delete process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES;
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
test("auto-trigger (S16): does not trim when below the anchor floor (returns undefined, no ctx.compact)", async () => {
|
|
251
|
+
const h = harness();
|
|
252
|
+
// A session so short that buildLiveTrimmedView's anchor floor can't hold — the
|
|
253
|
+
// live trim skips this call (returns undefined, the next context event retries).
|
|
254
|
+
delete process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM;
|
|
255
|
+
delete process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
|
|
256
|
+
const shortSession = [h.session[0], h.session[1]]; // one user + one assistant
|
|
200
257
|
const ctx = h.ctx({ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
|
|
201
|
-
const res = await h.fire("context", { type: "context", messages }, ctx);
|
|
202
|
-
|
|
203
|
-
assert.equal(h.compactCalls.length, 0, "ctx.compact() NOT called
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
258
|
+
const res = await h.fire("context", { type: "context", messages: shortSession }, ctx);
|
|
259
|
+
// Either it skipped (undefined) or trimmed safely — but it must never call ctx.compact.
|
|
260
|
+
assert.equal(h.compactCalls.length, 0, "ctx.compact() NOT called under live trim (short session)");
|
|
261
|
+
if (res === undefined) {
|
|
262
|
+
// skipped path is fine
|
|
263
|
+
assert.ok(true, "below anchor floor → no trim this call (retries next event)");
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
test("auto-trigger (S16): sendUserMessage resume nudge fires only when idle + queued + not already nudged", async () => {
|
|
267
|
+
const h = harness();
|
|
268
|
+
// No queued messages → the nudge must NOT fire (the guard prevents busy-loops).
|
|
269
|
+
// We assert the extension did not throw and did not push a spurious resume.
|
|
270
|
+
const ctx = h.ctx({ isIdle: () => true, hasPendingMessages: () => false });
|
|
271
|
+
await h.fire("agent_end", { type: "agent_end", messages: [] }, ctx);
|
|
272
|
+
// No throw + no spurious nudge side-effect is the contract; appended stays
|
|
273
|
+
// free of any auto "continue" marker when there is no queued work.
|
|
274
|
+
assert.equal(h.appended.some((a) => a.t && /continue/i.test(String(a.d ?? ""))), false, "no spurious continue when no queued work");
|
|
275
|
+
});
|
|
276
|
+
test("auto-trigger (S16): durable trim still happens via pi native auto-compaction (session_before_compact)", async () => {
|
|
277
|
+
const h = harness();
|
|
278
|
+
// pi's native auto-compaction fires at agent-end with reason "threshold" (the
|
|
279
|
+
// CONTINUING path). Our session_before_compact handler must still supply the
|
|
280
|
+
// durable trim summary — independent of the live context-event trim.
|
|
281
|
+
const prep = {
|
|
282
|
+
firstKeptEntryId: "e2",
|
|
283
|
+
messagesToSummarize: h.session.slice(0, 4),
|
|
284
|
+
tokensBefore: 500,
|
|
285
|
+
};
|
|
286
|
+
const res = await h.fire("session_before_compact", {
|
|
287
|
+
type: "session_before_compact", reason: "threshold", willRetry: false,
|
|
288
|
+
signal: undefined, preparation: prep,
|
|
289
|
+
}, h.ctx());
|
|
290
|
+
assert.ok(res?.compaction, "we supply a durable compaction result to pi's native path");
|
|
291
|
+
assert.ok(res.compaction.firstKeptEntryId === "e2", "reuses pi's boundary (PREVENT-PI-002)");
|
|
292
|
+
assert.ok(res.compaction.summary.length > 0, "summary is non-empty");
|
|
209
293
|
});
|
|
210
294
|
test("session_before_compact supplies our durable trim (not pi's summary)", async () => {
|
|
211
295
|
const h = harness();
|
|
@@ -323,10 +407,18 @@ test("explicit MEGACOMPACT_THRESHOLD_TOKENS overrides the tier", async () => {
|
|
|
323
407
|
});
|
|
324
408
|
// ---- /dashboard commands ----------------------------------------------------
|
|
325
409
|
test("/dashboard-status reports no server when pid file missing", async () => {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
410
|
+
// Private base so this asserts "no server" on a range nothing else uses,
|
|
411
|
+
// not the machine-global 9320 family (which may hold a leftover/production server).
|
|
412
|
+
process.env.MEGACOMPACT_DASHBOARD_PORT = "49320";
|
|
413
|
+
try {
|
|
414
|
+
const h = harness();
|
|
415
|
+
const ctx = h.ctx();
|
|
416
|
+
await h.commands["mega-dashboard-status"].handler("", ctx);
|
|
417
|
+
assert.ok(h.notifies.some((n) => n.includes("not running")), "reports no server running");
|
|
418
|
+
}
|
|
419
|
+
finally {
|
|
420
|
+
delete process.env.MEGACOMPACT_DASHBOARD_PORT;
|
|
421
|
+
}
|
|
330
422
|
});
|
|
331
423
|
test("/dashboard-stop reports no server when pid file missing", async () => {
|
|
332
424
|
const h = harness();
|
|
@@ -335,20 +427,23 @@ test("/dashboard-stop reports no server when pid file missing", async () => {
|
|
|
335
427
|
assert.ok(h.notifies.some((n) => n.includes("no dashboard server running")), "reports no server");
|
|
336
428
|
});
|
|
337
429
|
test("/dashboard skips server spawn when already running", async () => {
|
|
430
|
+
// Use a private dashboard port base for THIS test's harness + fake server so
|
|
431
|
+
// it never races the (parallel, hard-coded-9320) dashboard-server.test.js or
|
|
432
|
+
// a leftover production server. Set BEFORE harness() so registerDashboardCommands
|
|
433
|
+
// reads our base for findLivePort().
|
|
434
|
+
process.env.MEGACOMPACT_DASHBOARD_PORT = "29320";
|
|
338
435
|
const h = harness();
|
|
339
436
|
const confirms = [];
|
|
340
|
-
|
|
341
|
-
// (9320–9329) — isServerRunning() probes those ports, not the port.pid value.
|
|
437
|
+
const livPort = 29320; // inside the harness's private scan range (29320–29329)
|
|
342
438
|
const { createServer } = await import("node:http");
|
|
343
439
|
const server = createServer((_req, res) => {
|
|
344
440
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
345
441
|
res.end(JSON.stringify({ updatedAt: new Date().toISOString(), tier: "test", version: 1, config: {}, session: {}, context: {}, trigger: {}, store: {} }));
|
|
346
442
|
});
|
|
347
|
-
await new Promise((r) => server.listen(
|
|
348
|
-
const addr = server.address();
|
|
443
|
+
await new Promise((r) => server.listen(livPort, "127.0.0.1", r));
|
|
349
444
|
const { join: j } = await import("node:path");
|
|
350
445
|
const { writeFileSync: wf } = await import("node:fs");
|
|
351
|
-
wf(j(h.stateDir, "port.pid"), JSON.stringify({ port:
|
|
446
|
+
wf(j(h.stateDir, "port.pid"), JSON.stringify({ port: livPort, pid: process.pid }));
|
|
352
447
|
const ctx = h.ctx({
|
|
353
448
|
ui: {
|
|
354
449
|
setStatus: () => { },
|
|
@@ -362,11 +457,14 @@ test("/dashboard skips server spawn when already running", async () => {
|
|
|
362
457
|
assert.ok(h.notifies.some((n) => n.includes("already running")), "reports already running");
|
|
363
458
|
assert.ok(confirms.length > 0, "confirm dialog was shown");
|
|
364
459
|
await new Promise((r) => server.close(() => r()));
|
|
460
|
+
delete process.env.MEGACOMPACT_DASHBOARD_PORT;
|
|
365
461
|
});
|
|
366
462
|
test("/dashboard-status reports running after dashboard start", async () => {
|
|
463
|
+
// Private dashboard port base for this harness — never collides with the
|
|
464
|
+
// parallel dashboard-server.test.js (9320 family) or a leftover server.
|
|
465
|
+
process.env.MEGACOMPACT_DASHBOARD_PORT = "39320";
|
|
367
466
|
const h = harness();
|
|
368
|
-
|
|
369
|
-
// (9320–9329) or isServerRunning() won't detect it.
|
|
467
|
+
const livPort = 39320;
|
|
370
468
|
const { createServer } = await import("node:http");
|
|
371
469
|
const { join: j } = await import("node:path");
|
|
372
470
|
const { writeFileSync: wf } = await import("node:fs");
|
|
@@ -374,13 +472,13 @@ test("/dashboard-status reports running after dashboard start", async () => {
|
|
|
374
472
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
375
473
|
res.end(JSON.stringify({ updatedAt: new Date().toISOString(), tier: "test" }));
|
|
376
474
|
});
|
|
377
|
-
await new Promise((r) => server.listen(
|
|
378
|
-
|
|
379
|
-
wf(j(h.stateDir, "port.pid"), JSON.stringify({ port: addr.port, pid: process.pid }));
|
|
475
|
+
await new Promise((r) => server.listen(livPort, "127.0.0.1", r));
|
|
476
|
+
wf(j(h.stateDir, "port.pid"), JSON.stringify({ port: livPort, pid: process.pid }));
|
|
380
477
|
const ctx = h.ctx();
|
|
381
478
|
await h.commands["mega-dashboard-status"].handler("", ctx);
|
|
382
|
-
assert.ok(h.notifies.some((n) => n.includes("running") && n.includes(String(
|
|
479
|
+
assert.ok(h.notifies.some((n) => n.includes("running") && n.includes(String(livPort))), "reports running with port");
|
|
383
480
|
await new Promise((r) => server.close(() => r()));
|
|
481
|
+
delete process.env.MEGACOMPACT_DASHBOARD_PORT;
|
|
384
482
|
});
|
|
385
483
|
test("state snapshot writes dashboard.json after compaction", async () => {
|
|
386
484
|
const h = harness();
|
|
@@ -422,6 +520,10 @@ test("events.log receives compaction events", async () => {
|
|
|
422
520
|
assert.ok(ex(j(h.stateDir, "dashboard.json")), "dashboard.json proves post-compact ran");
|
|
423
521
|
}
|
|
424
522
|
});
|
|
425
|
-
test("cleanup", () => {
|
|
523
|
+
test("cleanup", async () => {
|
|
524
|
+
// Terminate the global PGlite cross-repo index (WASM worker thread) so the
|
|
525
|
+
// test process can exit. Without this, node --test never returns even though
|
|
526
|
+
// every test passed — the leaked worker keeps the event loop alive.
|
|
527
|
+
await closeVectorIndex();
|
|
426
528
|
rmSync(baseTmp, { recursive: true, force: true });
|
|
427
529
|
});
|
|
@@ -70,6 +70,11 @@ export function loadConfig() {
|
|
|
70
70
|
autoInlineK: envFlag("MEGACOMPACT_AUTO_INLINE_K", 3),
|
|
71
71
|
dedupSim: Number(process.env.MEGACOMPACT_DEDUP_SIM ?? "0.9"),
|
|
72
72
|
raptorEnabled: envBool("MEGACOMPACT_RAPTOR_ENABLED", true),
|
|
73
|
+
legacyDurableTrim: envBool("MEGACOMPACT_LEGACY_DURABLE_TRIM", false),
|
|
74
|
+
crossRepoEnabled: envBool("MEGACOMPACT_CROSSREPO_ENABLED", true),
|
|
75
|
+
crossRepoCosine: Number(process.env.MEGACOMPACT_CROSSREPO_COSINE ?? "0.90"),
|
|
76
|
+
memoryAutoReview: envBool("MEGACOMPACT_MEMORY_AUTO_REVIEW", true),
|
|
77
|
+
memoryReviewInterval: envFlag("MEGACOMPACT_MEMORY_REVIEW_INTERVAL", 10),
|
|
73
78
|
recallMaxTokens: envFlag("MEGACOMPACT_RECALL_MAX_TOKENS", 1500),
|
|
74
79
|
windowDedupe: envBool("MEGACOMPACT_WINDOW_DEDUPE", true),
|
|
75
80
|
debug: envBool("MEGACOMPACT_DEBUG", false),
|