pi-mega-compact 0.4.21 → 0.4.24

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 (47) hide show
  1. package/dist/extensions/dashboard-server.js +60 -9
  2. package/dist/extensions/dashboard-server.test.js +77 -0
  3. package/dist/extensions/mega-compact-driver.js +79 -0
  4. package/dist/extensions/mega-compact.test.js +54 -18
  5. package/dist/extensions/mega-config.js +10 -0
  6. package/dist/extensions/mega-dashboard-cmds.js +32 -2
  7. package/dist/extensions/mega-events.js +45 -23
  8. package/dist/extensions/mega-pipeline.js +77 -3
  9. package/dist/src/config/dedup.js +4 -1
  10. package/dist/src/config.js +21 -0
  11. package/dist/src/dedup/raptor/index.js +28 -6
  12. package/dist/src/dedup/raptor/promote.test.js +69 -0
  13. package/dist/src/engine.js +1 -0
  14. package/dist/src/recall.js +30 -4
  15. package/dist/src/recall.test.js +28 -0
  16. package/dist/src/store/backfill.js +5 -6
  17. package/dist/src/store/compression.js +47 -7
  18. package/dist/src/store/compression.test.js +48 -0
  19. package/dist/src/store/sqlite.js +64 -41
  20. package/dist/src/store.test.js +19 -0
  21. package/dist/src/vectorStore.js +56 -1
  22. package/extensions/DASHBOARD.md +3 -3
  23. package/extensions/dashboard-server.test.ts +77 -0
  24. package/extensions/dashboard-server.ts +57 -11
  25. package/extensions/mega-compact-driver.ts +105 -0
  26. package/extensions/mega-compact.test.ts +65 -18
  27. package/extensions/mega-config.ts +25 -0
  28. package/extensions/mega-dashboard-cmds.ts +23 -2
  29. package/extensions/mega-events.ts +43 -24
  30. package/extensions/mega-pipeline.ts +83 -4
  31. package/package.json +6 -7
  32. package/src/config/dedup.ts +4 -1
  33. package/src/config.ts +26 -0
  34. package/src/dedup/raptor/index.ts +42 -7
  35. package/src/dedup/raptor/promote.test.ts +82 -0
  36. package/src/engine.ts +5 -0
  37. package/src/recall.test.ts +44 -0
  38. package/src/recall.ts +43 -4
  39. package/src/store/backfill.ts +10 -11
  40. package/src/store/compression.test.ts +58 -0
  41. package/src/store/compression.ts +48 -7
  42. package/src/store/sqlite.ts +72 -49
  43. package/src/store.test.ts +22 -0
  44. package/src/vectorStore.ts +63 -1
  45. package/dist/extensions/openclaw-mega-compact.js +0 -291
  46. package/dist/src/minilm.js +0 -92
  47. package/dist/src/wordpiece.js +0 -129
@@ -2,11 +2,13 @@
2
2
  * sqlite.ts — Sprint 8 storage backbone (the "one store").
3
3
  *
4
4
  * Replaces the per-session gzipped-JSON checkpoint files with a single local
5
- * SQLite database (better-sqlite3, in-process, FS-backed, ZERO network calls —
6
- * honors PREVENT-PI-004). Chosen over PGlite because PGlite is async-only in
7
- * every published version, and VectorStore (engine.ts / recall.ts / the
8
- * extension) is fully synchronous adopting PGlite would have cascaded async
9
- * through the whole call chain. SQLite keeps every VectorStore signature sync.
5
+ * SQLite database (node:sqlite — the Node built-in, in-process, FS-backed,
6
+ * ZERO network calls — honors PREVENT-PI-004). No native build and no install
7
+ * scripts, so it survives pi's npm blocked-install-scripts gate (better-sqlite3's
8
+ * native binary could not be built under pi, which crashed every `pi update
9
+ * --extensions`). node:sqlite is synchronous, so every VectorStore signature
10
+ * stays sync. PGlite + pgvector (async) is layered on in vectorIndex.ts for
11
+ * real HNSW indexing (Slice 2 of the dual-backend plan).
10
12
  *
11
13
  * FTS5 `trigram` tokenizer is created for the Sprint 9+ dedup tiers (MinHash/LSH
12
14
  * / pg_trgm-equivalent verification). The default cosine path stays a linear
@@ -14,7 +16,7 @@
14
16
  *
15
17
  * All queries are parameterized (PREVENT-002) — never string-concatenated.
16
18
  */
17
- import Database from "better-sqlite3";
19
+ import { DatabaseSync } from "node:sqlite";
18
20
  import { existsSync, mkdirSync } from "node:fs";
19
21
  import { homedir, tmpdir } from "node:os";
20
22
  import { join } from "node:path";
@@ -28,14 +30,17 @@ function encodeEmbedding(v) {
28
30
  buf.writeFloatLE(v[i] ?? 0, i * 4);
29
31
  return buf;
30
32
  }
31
- /** Decode a Float32 BLOB back to a number[]. */
33
+ /** Decode a Float32 BLOB back to a number[]. node:sqlite returns BLOBs as
34
+ * Uint8Array, so decode via DataView (Buffer is a Uint8Array subclass — both
35
+ * work). */
32
36
  function decodeEmbedding(buf) {
33
37
  if (!buf || buf.length === 0)
34
38
  return [];
39
+ const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
35
40
  const n = buf.length / 4;
36
41
  const out = new Array(n);
37
42
  for (let i = 0; i < n; i++)
38
- out[i] = buf.readFloatLE(i * 4);
43
+ out[i] = dv.getFloat32(i * 4, true);
39
44
  return out;
40
45
  }
41
46
  function jsonText(v) {
@@ -52,9 +57,9 @@ export function openStore(stateDir = getStateDir()) {
52
57
  return existing;
53
58
  if (!existsSync(stateDir))
54
59
  mkdirSync(stateDir, { recursive: true });
55
- const db = new Database(join(stateDir, "sqlite.db"));
56
- db.pragma("journal_mode = WAL");
57
- db.pragma("foreign_keys = ON");
60
+ const db = new DatabaseSync(join(stateDir, "sqlite.db"));
61
+ db.exec("PRAGMA journal_mode = WAL");
62
+ db.exec("PRAGMA foreign_keys = ON");
58
63
  initSchema(db);
59
64
  cache.set(stateDir, db);
60
65
  return db;
@@ -91,10 +96,10 @@ export function openIndexStore(indexDir = getIndexDir()) {
91
96
  return indexCache;
92
97
  if (!existsSync(indexDir))
93
98
  mkdirSync(indexDir, { recursive: true });
94
- const db = new Database(join(indexDir, "index.sqlite"));
95
- db.pragma("journal_mode = WAL");
96
- db.pragma("busy_timeout = 3000"); // tolerate brief cross-process write contention
97
- db.exec(`
99
+ const iddb = new DatabaseSync(join(indexDir, "index.sqlite"));
100
+ iddb.exec("PRAGMA journal_mode = WAL");
101
+ iddb.exec("PRAGMA busy_timeout = 3000"); // tolerate brief cross-process write contention
102
+ iddb.exec(`
98
103
  CREATE TABLE IF NOT EXISTS repo_registry (
99
104
  repo_root TEXT PRIMARY KEY,
100
105
  display_name TEXT,
@@ -114,9 +119,9 @@ export function openIndexStore(indexDir = getIndexDir()) {
114
119
  );
115
120
  CREATE INDEX IF NOT EXISTS idx_registry_last_seen ON repo_registry(last_seen DESC);
116
121
  `);
117
- indexCache = db;
122
+ indexCache = iddb;
118
123
  indexCacheDir = indexDir;
119
- return db;
124
+ return iddb;
120
125
  }
121
126
  /**
122
127
  * Upsert a repo's aggregate stats into the global index. Called on repo-switch
@@ -535,6 +540,24 @@ function mapMemoryRow(row) {
535
540
  lastRecalledAt: row.last_recalled_at ?? null,
536
541
  };
537
542
  }
543
+ /**
544
+ * Run `fn` atomically. Uses SAVEPOINT so it nests safely under an outer
545
+ * transaction (unlike `BEGIN`, which SQLite rejects when one is already open).
546
+ * Mirrors better-sqlite3's `db.transaction(fn)` semantics — callers that wrap a
547
+ * batch in withTx (e.g. backfill) can still call helpers that also use withTx.
548
+ */
549
+ export function withTx(db, fn) {
550
+ db.exec("SAVEPOINT mc_tx");
551
+ try {
552
+ fn();
553
+ db.exec("RELEASE mc_tx");
554
+ }
555
+ catch (e) {
556
+ db.exec("ROLLBACK TO mc_tx");
557
+ db.exec("RELEASE mc_tx");
558
+ throw e;
559
+ }
560
+ }
538
561
  /** Map a DB row to the public StoredCheckpoint shape. */
539
562
  function rowToCheckpoint(row) {
540
563
  return {
@@ -553,7 +576,9 @@ function rowToCheckpoint(row) {
553
576
  contentHash2: row.content_hash2 ?? undefined,
554
577
  contentHashVersion: row.content_hash_version ?? undefined,
555
578
  normalizedText: row.normalized_text ?? undefined,
556
- compressedOriginal: row.compressed_original ?? undefined,
579
+ // node:sqlite returns BLOBs as Uint8Array; normalize to Buffer so callers
580
+ // (e.g. decompressSmart → Buffer.toString) behave as under better-sqlite3.
581
+ compressedOriginal: row.compressed_original ? Buffer.from(row.compressed_original) : undefined,
557
582
  embedding: decodeEmbedding(row.embedding_blob),
558
583
  timestamp: Number(row.timestamp ?? 0),
559
584
  dedupStatus: row.dedup_status ?? undefined,
@@ -563,7 +588,7 @@ function rowToCheckpoint(row) {
563
588
  export function upsertCheckpoint(cp, stateDir = getStateDir()) {
564
589
  const db = openStore(stateDir);
565
590
  const sid = normalizeSessionId(cp.sessionId);
566
- const tx = db.transaction(() => {
591
+ withTx(db, () => {
567
592
  db.prepare(`INSERT INTO context_chunks
568
593
  (id, session_id, region_hash, content_hash, content_hash2, content_hash_version,
569
594
  normalized_text, summary, topic_summary, summary_hash,
@@ -586,25 +611,25 @@ export function upsertCheckpoint(cp, stateDir = getStateDir()) {
586
611
  timestamp=excluded.timestamp,
587
612
  dedup_status=excluded.dedup_status,
588
613
  compressed_original=excluded.compressed_original`).run({
589
- id: cp.checkpointId,
590
- sid,
591
- region_hash: cp.regionHash ?? null,
592
- content_hash: cp.contentHash ?? null,
593
- content_hash2: cp.contentHash2 ?? null,
594
- content_hash_version: cp.contentHashVersion ?? null,
595
- normalized_text: cp.normalizedText ?? null,
596
- summary: cp.summary ?? "",
597
- topic_summary: cp.topicSummary ?? null,
598
- summary_hash: cp.summaryHash ?? null,
599
- key_decisions: jsonText(cp.keyDecisions),
600
- next_steps: jsonText(cp.nextSteps),
601
- files_modified: jsonText(cp.filesModified),
602
- embedding_blob: encodeEmbedding(cp.embedding ?? []),
603
- token_estimate: cp.tokenEstimate ?? 0,
604
- original_token_estimate: cp.originalTokenEstimate ?? null,
605
- timestamp: cp.timestamp ?? 0,
606
- dedup_status: "active",
607
- compressed_original: cp.compressedOriginal ?? null,
614
+ "@id": cp.checkpointId,
615
+ "@sid": sid,
616
+ "@region_hash": cp.regionHash ?? null,
617
+ "@content_hash": cp.contentHash ?? null,
618
+ "@content_hash2": cp.contentHash2 ?? null,
619
+ "@content_hash_version": cp.contentHashVersion ?? null,
620
+ "@normalized_text": cp.normalizedText ?? null,
621
+ "@summary": cp.summary ?? "",
622
+ "@topic_summary": cp.topicSummary ?? null,
623
+ "@summary_hash": cp.summaryHash ?? null,
624
+ "@key_decisions": jsonText(cp.keyDecisions),
625
+ "@next_steps": jsonText(cp.nextSteps),
626
+ "@files_modified": jsonText(cp.filesModified),
627
+ "@embedding_blob": encodeEmbedding(cp.embedding ?? []),
628
+ "@token_estimate": cp.tokenEstimate ?? 0,
629
+ "@original_token_estimate": cp.originalTokenEstimate ?? null,
630
+ "@timestamp": cp.timestamp ?? 0,
631
+ "@dedup_status": "active",
632
+ "@compressed_original": cp.compressedOriginal ?? null,
608
633
  });
609
634
  // FTS5 virtual tables don't support UPSERT — delete any prior row, reinsert.
610
635
  // Store normalized_text (the L1 verify key); fall back to summary for rows
@@ -612,7 +637,6 @@ export function upsertCheckpoint(cp, stateDir = getStateDir()) {
612
637
  db.prepare("DELETE FROM context_chunks_trgm WHERE id = ?").run(cp.checkpointId);
613
638
  db.prepare("INSERT INTO context_chunks_trgm(id, normalized_text) VALUES(?, ?)").run(cp.checkpointId, cp.normalizedText ?? cp.summary ?? "");
614
639
  });
615
- tx();
616
640
  }
617
641
  // --- Sprint 11: MinHash signatures + LSH buckets --------------------------
618
642
  /** Persist a checkpoint's MinHash signature (idempotent by chunk_id + version). */
@@ -630,12 +654,11 @@ export function insertLshBuckets(chunkId, sessionId, signatureVersion, bucketKey
630
654
  const sid = normalizeSessionId(sessionId);
631
655
  const del = db.prepare("DELETE FROM dedup_lsh_buckets WHERE chunk_id = ?");
632
656
  const ins = db.prepare("INSERT OR IGNORE INTO dedup_lsh_buckets(bucket_key, chunk_id, session_id, signature_version) VALUES(?, ?, ?, ?)");
633
- const tx = db.transaction(() => {
657
+ withTx(db, () => {
634
658
  del.run(chunkId);
635
659
  for (const key of bucketKeys)
636
660
  ins.run(key, chunkId, sid, signatureVersion);
637
661
  });
638
- tx();
639
662
  }
640
663
  /**
641
664
  * Candidate chunk_ids sharing any LSH bucket with `bucketKeys`, scoped to the
@@ -109,6 +109,25 @@ test("compression tier: GZIP-1 and GZIP-6 tiers produce valid, smaller output",
109
109
  assert.ok(big.length < Buffer.from("compress me ".repeat(1800)).length, "GZIP-6 output smaller than input");
110
110
  assert.deepEqual(decompressSmart(big), Buffer.from("compress me ".repeat(1800)));
111
111
  });
112
+ test("Fix E: pressure escalates gzip tier strength (sync, no zstd)", () => {
113
+ const small = Buffer.from("compress me ".repeat(200)); // 512B–4KB band
114
+ const medium = Buffer.from("compress me ".repeat(1800)); // 4KB–32KB band
115
+ // Low pressure → cheap levels (gzip-1 / gzip-6).
116
+ const lowSmall = compressSmart(small, 0);
117
+ const lowMedium = compressSmart(medium, 0);
118
+ assert.equal(lowSmall[3], 0x01, "small tier tag");
119
+ assert.equal(lowMedium[3], 0x02, "medium tier tag");
120
+ assert.ok(decompressSmart(lowSmall).equals(small), "low-pressure small roundtrips");
121
+ assert.ok(decompressSmart(lowMedium).equals(medium), "low-pressure medium roundtrips");
122
+ // High pressure → stronger levels (gzip-9 / gzip-9); tag unchanged, output
123
+ // must still decode to the exact original (versioned header preserved).
124
+ const highSmall = compressSmart(small, 1);
125
+ const highMedium = compressSmart(medium, 1);
126
+ assert.equal(highSmall[3], 0x01, "tag unchanged under pressure");
127
+ assert.equal(highMedium[3], 0x02, "tag unchanged under pressure");
128
+ assert.ok(decompressSmart(highSmall).equals(small), "high-pressure small roundtrips");
129
+ assert.ok(decompressSmart(highMedium).equals(medium), "high-pressure medium roundtrips");
130
+ });
112
131
  // ---------------------------------------------------------------------------
113
132
  // normalizeSessionId
114
133
  // ---------------------------------------------------------------------------
@@ -20,6 +20,8 @@ import { mmrRerank } from "./dedup/mmr.js";
20
20
  import { topK } from "./dedup/topk.js";
21
21
  import { openBloom, saveBloom } from "./store/bloom.js";
22
22
  import { listCheckpoints, nextCheckpointId, upsertCheckpoint, loadSessionState, saveSessionState, upsertMinhashSignature, insertLshBuckets, lshCandidateChunks, setDedupStatus, addTokensSaved, getDedupStats, bumpDedupStats, repoStats as repoStatsFromStore, dataInvariantStats, } from "./store/sqlite.js";
23
+ import { rehydrateRaptorTree } from "./dedup/raptor/index.js";
24
+ import { stagedExpansion } from "./dedup/raptor/retrieval.js";
23
25
  import { migrateJsonToSqlite } from "./store/migrate.js";
24
26
  /** Default L2 semantic-dedup enable flag (trigram embedder is local, zero-network). */
25
27
  export const L2_ENABLED = true;
@@ -242,7 +244,7 @@ export class VectorStore {
242
244
  contentHash2: digest.contentHash2,
243
245
  contentHashVersion: digest.contentHashVersion,
244
246
  normalizedText: digest.normalizedText,
245
- compressedOriginal: compressSmart(Buffer.from(input.regionText, "utf-8")),
247
+ compressedOriginal: compressSmart(Buffer.from(input.regionText, "utf-8"), input.compressionPressure),
246
248
  embedding,
247
249
  timestamp: input.timestamp,
248
250
  };
@@ -339,6 +341,27 @@ export class VectorStore {
339
341
  // (Sprint 14 flag), returning the plain relevance-ranked window instead.
340
342
  if (!this.cfg.L2_ENABLED)
341
343
  return window.slice(0, k);
344
+ // Fix D: when RAPTOR is promoted, ALSO recall high-level tree summaries and
345
+ // merge them with the flat hits via MMR so RAPTOR + flat don't double-cover.
346
+ // RAPTOR returns fewer, broader hits (O(log n) high-level nodes) than the
347
+ // O(n) flat leaves, tightening the block at read time.
348
+ if (this.cfg.RAPTOR_ENABLED) {
349
+ const raptorHits = this.raptorSearchHits(sid, query, k);
350
+ if (raptorHits.length > 0) {
351
+ const merged = [...window];
352
+ for (const rh of raptorHits) {
353
+ if (!merged.some((m) => m.checkpoint.checkpointId === rh.checkpoint.checkpointId)) {
354
+ merged.push(rh);
355
+ }
356
+ }
357
+ const mmrItems = merged.map((h) => ({
358
+ item: h,
359
+ vector: h.checkpoint.embedding,
360
+ relevance: h.score,
361
+ }));
362
+ return mmrRerank(mmrItems, k, this.cfg.MMR_LAMBDA);
363
+ }
364
+ }
342
365
  const mmrItems = window.map((h) => ({
343
366
  item: h,
344
367
  vector: h.checkpoint.embedding,
@@ -347,6 +370,38 @@ export class VectorStore {
347
370
  const ranked = mmrRerank(mmrItems, k, this.cfg.MMR_LAMBDA);
348
371
  return ranked;
349
372
  }
373
+ /**
374
+ * Serve the RAPTOR tree for a query (Fix D): rehydrate the persisted tree and
375
+ * return its staged-expansion leaf hits as SearchHits. Returns [] when no tree
376
+ * exists (small sessions — flat search remains the path). Best-effort/non-fatal.
377
+ */
378
+ raptorSearchHits(sid, query, k) {
379
+ try {
380
+ const tree = rehydrateRaptorTree(sid, this.stateDir);
381
+ if (!tree || !tree.rootId)
382
+ return [];
383
+ const leafIds = stagedExpansion(query, tree, {
384
+ embedder: this.embedder,
385
+ k,
386
+ topM: this.cfg.RAPTOR_CLUSTERS_PER_LEVEL,
387
+ mmrLambda: this.cfg.MMR_LAMBDA,
388
+ });
389
+ if (leafIds.length === 0)
390
+ return [];
391
+ const all = listCheckpoints(sid, this.stateDir).filter((cp) => cp.dedupStatus !== "removed");
392
+ const qv = this.embedder.embed(query);
393
+ const hits = [];
394
+ for (const id of leafIds) {
395
+ const cp = all.find((c) => c.checkpointId === id);
396
+ if (cp)
397
+ hits.push({ checkpoint: cp, score: cosineSimilarity(qv, cp.embedding) });
398
+ }
399
+ return hits;
400
+ }
401
+ catch {
402
+ return [];
403
+ }
404
+ }
350
405
  /**
351
406
  * SemDeDup offline cleanup (Sprint 12, QA #17): within a session, mark the
352
407
  * lower-quality row of any pair scoring cosine > `threshold` as
@@ -2,8 +2,8 @@
2
2
 
3
3
  A lightweight local web dashboard for monitoring mega-compact's live state — compactions, context usage, checkpoints, and recall hits.
4
4
 
5
- Uses Node built-in modules (`http`, `fs`, `path`) plus `better-sqlite3` (the
6
- project's one-store DB backend) to read the machine-wide multi-repo index.
5
+ Uses Node built-in modules (`http`, `fs`, `path`, `node:sqlite` the project's
6
+ one-store DB backend) to read the machine-wide multi-repo index.
7
7
 
8
8
  ## Quick Start
9
9
 
@@ -74,7 +74,7 @@ The server runs as a detached child process, independent of the pi session. It:
74
74
  - Auto-discovers the state directory from the `port.pid` file
75
75
  - Cleans up stale `port.pid` files from dead processes
76
76
  - Supports `SIGTERM`/`SIGINT` for graceful shutdown
77
- - Serves static HTML; reads the multi-repo index from SQLite (`better-sqlite3`)
77
+ - Serves static HTML; reads the multi-repo index from SQLite (`node:sqlite`)
78
78
 
79
79
  ## Browser UI
80
80
 
@@ -10,6 +10,7 @@ import assert from "node:assert/strict";
10
10
  import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
11
11
  import { tmpdir } from "node:os";
12
12
  import { join } from "node:path";
13
+ import { spawn } from "node:child_process";
13
14
 
14
15
  // ---------------------------------------------------------------------------
15
16
  // helpers
@@ -122,3 +123,79 @@ describe("port.pid file", () => {
122
123
  rmSync(dir, { recursive: true });
123
124
  });
124
125
  });
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Lifecycle integration — launch the compiled server as a real subprocess
129
+ // (the same way the /dashboard command spawns it) and assert the two failure
130
+ // modes that historically produced a silent "failed to start":
131
+ // 1. a stale port.pid pointing at a dead port is dropped, and the server
132
+ // binds fresh (instead of returning the dead port);
133
+ // 2. a module-load crash is captured to the launch log instead of going
134
+ // silent under stdio:"ignore".
135
+ // ---------------------------------------------------------------------------
136
+
137
+ const SERVER_ENTRY = new URL("./dashboard-server.js", import.meta.url).pathname;
138
+
139
+ function waitFor(cond: () => boolean | Promise<boolean>, timeoutMs = 6000): Promise<void> {
140
+ const start = Date.now();
141
+ return new Promise((resolve, reject) => {
142
+ const tick = async () => {
143
+ if (await cond()) return resolve();
144
+ if (Date.now() - start > timeoutMs) return reject(new Error("timeout"));
145
+ setTimeout(tick, 50);
146
+ };
147
+ tick();
148
+ });
149
+ }
150
+
151
+ describe("server lifecycle", () => {
152
+ test("drops a stale port.pid and binds a fresh port", async () => {
153
+ const dir = mkdtempSync(join(tmpdir(), "dash-stale-"));
154
+ // A marker claiming a port where nothing is listening.
155
+ writeFileSync(join(dir, "port.pid"), JSON.stringify({ port: 9325, pid: 999999 }));
156
+
157
+ const child = spawn(process.execPath, [SERVER_ENTRY, dir], { stdio: "ignore" });
158
+ try {
159
+ // Wait for the server to actually be live (not just any port.pid — the
160
+ // stale marker already exists at t=0 and would pass a naive check).
161
+ await waitFor(async () => {
162
+ try {
163
+ const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
164
+ const res = await fetch(`http://localhost:${raw.port}/api/version`);
165
+ return res.ok;
166
+ } catch {
167
+ return false;
168
+ }
169
+ });
170
+ const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
171
+ assert.equal(typeof raw.port, "number");
172
+ assert.notEqual(raw.port, 9325, "should not reuse the dead port from the stale marker");
173
+ // And a real server must answer on it.
174
+ const res = await fetch(`http://localhost:${raw.port}/api/version`);
175
+ assert.equal(res.ok, true);
176
+ } finally {
177
+ child.kill("SIGTERM");
178
+ rmSync(dir, { recursive: true, force: true });
179
+ }
180
+ });
181
+
182
+ test("writes a dashboard.log with startup lines", async () => {
183
+ const dir = mkdtempSync(join(tmpdir(), "dash-log-"));
184
+ const child = spawn(process.execPath, [SERVER_ENTRY, dir], { stdio: "ignore" });
185
+ try {
186
+ await waitFor(() => {
187
+ try {
188
+ return /server running/.test(readFileSync(join(dir, "dashboard.log"), "utf-8"));
189
+ } catch {
190
+ return false;
191
+ }
192
+ });
193
+ const log = readFileSync(join(dir, "dashboard.log"), "utf-8");
194
+ assert.match(log, /\[mega-compact\]\[dashboard\]/);
195
+ assert.match(log, /server running/);
196
+ } finally {
197
+ child.kill("SIGTERM");
198
+ rmSync(dir, { recursive: true, force: true });
199
+ }
200
+ });
201
+ });
@@ -12,11 +12,31 @@
12
12
  */
13
13
 
14
14
  import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
15
- import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync } from "node:fs";
15
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync, appendFileSync } from "node:fs";
16
16
  import { homedir } from "node:os";
17
17
  import { join, dirname } from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
- import Database from "better-sqlite3";
19
+ import { DatabaseSync } from "node:sqlite";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Local runtime log
23
+ //
24
+ // The dashboard server is spawned as a DETACHED child. When it is launched with
25
+ // `stdio: "ignore"` (the old default) any crash before the first console.log is
26
+ // invisible — there is no log to "check". We therefore mirror every lifecycle
27
+ // line to a file in the state dir so a failed start is always diagnosable. The
28
+ // launcher also captures stderr, so this doubles as defense-in-depth.
29
+ // ---------------------------------------------------------------------------
30
+
31
+ let LOG_PATH: string | null = null;
32
+ function log(...parts: unknown[]): void {
33
+ const line = `[mega-compact][dashboard] ${parts.map((p) => (typeof p === "string" ? p : JSON.stringify(p))).join(" ")}`;
34
+ // eslint-disable-next-line no-console
35
+ console.error(line); // stderr — captured by the launcher pipe
36
+ if (LOG_PATH) {
37
+ try { appendFileSync(LOG_PATH, new Date().toISOString() + " " + line + "\n"); } catch { /* non-fatal */ }
38
+ }
39
+ }
20
40
 
21
41
  // --- Multi-repo index (Phase 5b) ------------------------------------------------
22
42
  // The extension writes a machine-wide repo registry into a single SQLite DB
@@ -54,11 +74,11 @@ interface IndexRepo {
54
74
  function readIndex(): { updatedAt: string; summary: unknown; repos: unknown[] } | null {
55
75
  const indexPath = join(getIndexDir(), "index.sqlite");
56
76
  if (!existsSync(indexPath)) return null;
57
- let db: Database.Database | undefined;
77
+ let db: DatabaseSync | undefined;
58
78
  try {
59
79
  // Read-only + immutable WAL so a concurrent writer's WAL never blocks us.
60
- db = new Database(indexPath, { readonly: true, fileMustExist: true });
61
- db.pragma("journal_mode = WAL");
80
+ db = new DatabaseSync(indexPath, { readOnly: true });
81
+ db.exec("PRAGMA journal_mode = WAL");
62
82
  const rows = db
63
83
  .prepare("SELECT * FROM repo_registry ORDER BY last_seen DESC")
64
84
  .all() as Record<string, unknown>[];
@@ -738,7 +758,7 @@ function dashboardHtml(tierName: string): string {
738
758
  // Server
739
759
  // ---------------------------------------------------------------------------
740
760
 
741
- export function launchDashboardServer(stateDir: string): Promise<{ port: number; url: string }> {
761
+ export async function launchDashboardServer(stateDir: string): Promise<{ port: number; url: string }> {
742
762
  // Our own package version — exposed at /api/version so the launcher can
743
763
  // detect a stale server (started by an older build) and replace it on
744
764
  // upgrade instead of reuse it.
@@ -757,17 +777,37 @@ export function launchDashboardServer(stateDir: string): Promise<{ port: number;
757
777
  const portFile = join(stateDir, "port.pid");
758
778
  const snapshotPath = join(stateDir, "dashboard.json");
759
779
  const eventsPath = join(stateDir, "events.log");
760
-
761
- // ── Existing server? ──────────────────────────────────────────────────────
780
+ LOG_PATH = join(stateDir, "dashboard.log");
781
+ log("launch invoked", { stateDir });
782
+
783
+ // ── Existing server? ───────────────────────────────────────────────────────
784
+ // A stale port.pid pointing at a dead/competing process is the classic cause
785
+ // of "dashboard failed to start" — we return a port that is NOT actually
786
+ // serving. Probe for a live server on that port first; only reuse the marker
787
+ // when something real answers /api/version. Otherwise drop it and start fresh.
762
788
  if (existsSync(portFile)) {
763
789
  try {
764
790
  const info = JSON.parse(readFileSync(portFile, "utf-8"));
765
791
  if (info && info.port) {
766
- return Promise.resolve({ port: info.port, url: `http://localhost:${info.port}` });
792
+ let live = false;
793
+ try {
794
+ const probe = await fetch(`http://localhost:${info.port}/api/version`, { signal: AbortSignal.timeout(800) });
795
+ live = probe.ok;
796
+ } catch {
797
+ live = false;
798
+ }
799
+ if (live) {
800
+ log("reusing live server from port.pid", { port: info.port });
801
+ return { port: info.port, url: `http://localhost:${info.port}` };
802
+ }
803
+ log("port.pid present but no live server — treating as stale", { port: info.port });
767
804
  }
768
805
  } catch {
769
- // stale file, overwrite
806
+ log("port.pid unparseable treating as stale");
770
807
  }
808
+ // stale file, remove so the fresh bind does not collide with a lingering
809
+ // process that still holds the port
810
+ try { unlinkSync(portFile); } catch { /* ignore */ }
771
811
  }
772
812
 
773
813
  // ── New server ────────────────────────────────────────────────────────────
@@ -891,20 +931,26 @@ export function launchDashboardServer(stateDir: string): Promise<{ port: number;
891
931
  function tryPort(port: number) {
892
932
  server.once("error", (err: NodeJS.ErrnoException) => {
893
933
  if (err.code === "EADDRINUSE" && port < TARGET_PORT + PORT_RANGE - 1) {
934
+ log("port in use, trying next", { port });
894
935
  tryPort(port + 1);
895
936
  } else {
937
+ log("listen failed", { port, code: err.code, message: err.message });
896
938
  reject(err);
897
939
  }
898
940
  });
899
941
 
900
942
  server.listen(port, "127.0.0.1", () => {
901
943
  const url = `http://localhost:${port}`;
944
+ log("server running", { url });
945
+ // eslint-disable-next-line no-console
902
946
  console.log(`[mega-compact] dashboard server running: ${url}`);
903
947
 
904
948
  // Write port.pid
905
949
  try {
906
950
  writeFileSync(portFile, JSON.stringify({ port, pid: process.pid }));
907
- } catch { /* non-fatal */ }
951
+ } catch (e) {
952
+ log("could not write port.pid", { error: String(e) });
953
+ }
908
954
 
909
955
  // Graceful cleanup
910
956
  const cleanup = () => {
@@ -0,0 +1,105 @@
1
+ /**
2
+ * mega-compact-driver.ts — the durable-trim driver (Fix B).
3
+ *
4
+ * The read-path token-growth bug: the old design cancelled pi's native
5
+ * compaction (`{ cancel: true }`) and did its own ephemeral `context`-hook
6
+ * drop. That drop only affected the outgoing request — the on-disk transcript
7
+ * was never trimmed (the session manager is read-only for extensions). So on
8
+ * resume pi reloaded the FULL transcript and we ADDED a recall block on top →
9
+ * more tokens than before compaction.
10
+ *
11
+ * The fix: on `session_before_compact` we RUN the Trident pipeline to produce a
12
+ * genuinely compressed summary, then RETURN it as a `CompactionResult`. pi
13
+ * durably writes our summary into a `compactionSummary` entry AND truncates the
14
+ * transcript from `firstKeptEntryId`. After that, resume reloads the already-
15
+ * trimmed transcript (summary baked in) — no additive re-injection, no token
16
+ * growth.
17
+ *
18
+ * We reuse pi's `preparation.firstKeptEntryId` (pi already computed the cut
19
+ * honoring the anchor-floor + tool-pair guards — PREVENT-PI-002) rather than
20
+ * recomputing it, so we cannot hand pi a boundary that splits a tool pair.
21
+ */
22
+
23
+ import type { SessionBeforeCompactEvent } from "@earendil-works/pi-coding-agent";
24
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
25
+ import { compactSession } from "../src/engine.js";
26
+ import { toEngineMessages } from "../src/adapt.js";
27
+ import { estimateBlockTokens, estimateSessionTokens } from "../src/tokens.js";
28
+ import type { MegaRuntime } from "./mega-runtime.js";
29
+ import type { MegaConfig } from "./mega-config.js";
30
+ import { recallRaptorRootSummary } from "../src/dedup/raptor/index.js";
31
+
32
+ export interface NativeCompactionResult {
33
+ /** Our trimmed summary + the pi entry to keep from (durable trim). */
34
+ compaction: {
35
+ summary: string;
36
+ firstKeptEntryId: string;
37
+ tokensBefore: number;
38
+ estimatedTokensAfter: number;
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Build our durable compaction result from pi's pre-computed preparation.
44
+ *
45
+ * Returns undefined when there is nothing to summarize (pi will then run its
46
+ * own native compaction, or skip). Never throws for "empty" — best-effort.
47
+ */
48
+ export function driveNativeCompaction(
49
+ event: SessionBeforeCompactEvent,
50
+ runtime: MegaRuntime,
51
+ config: MegaConfig,
52
+ ): NativeCompactionResult | undefined {
53
+ const prep = event.preparation;
54
+ if (!prep) return undefined;
55
+
56
+ const sid = runtime.rt.sessionId;
57
+ const messagesToSummarize: AgentMessage[] = prep.messagesToSummarize ?? [];
58
+ if (messagesToSummarize.length === 0) return undefined;
59
+
60
+ const engineView = toEngineMessages(messagesToSummarize);
61
+ // We don't drop anything here — pi keeps from prep.firstKeptEntryId. We only
62
+ // summarize the region pi is about to discard.
63
+ const keepFrom = engineView.length;
64
+
65
+ const result = compactSession(
66
+ {
67
+ sessionId: sid,
68
+ messages: engineView,
69
+ keepFrom,
70
+ timestamp: Date.now(),
71
+ useExtractiveSummary: true,
72
+ },
73
+ runtime.store,
74
+ );
75
+ if (result.skipped) return undefined;
76
+
77
+ // Prefer the RAPTOR root summary when the tree is built + enabled (Fix D):
78
+ // it is a session-level compressed summary, broader than one slice's. Fall
79
+ // back to the extractive topicSummary of this slice.
80
+ let summary = result.summary;
81
+ if (config.raptorEnabled) {
82
+ const root = recallRaptorRootSummary(sid, runtime.currentStateDir);
83
+ if (root) summary = root;
84
+ }
85
+
86
+ const tokensBefore = prep.tokensBefore ?? estimateSessionTokens(engineView);
87
+ const summaryTokens = estimateBlockTokens(summary);
88
+ // pi keeps the tail from firstKeptEntryId; our summary replaces the discarded
89
+ // region. Honest saved = discarded-region tokens − our summary tokens.
90
+ const savedTokens = Math.max(0, tokensBefore - summaryTokens);
91
+
92
+ runtime.rt.lastCompactedFrom = keepFrom;
93
+ runtime.rt.lastCompactedTokens = tokensBefore;
94
+ runtime.rt.tokensSaved += savedTokens;
95
+ runtime.rt.persistedThisSession = true;
96
+
97
+ return {
98
+ compaction: {
99
+ summary,
100
+ firstKeptEntryId: prep.firstKeptEntryId,
101
+ tokensBefore,
102
+ estimatedTokensAfter: summaryTokens,
103
+ },
104
+ };
105
+ }