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.
- package/dist/extensions/dashboard-server.js +60 -9
- package/dist/extensions/dashboard-server.test.js +77 -0
- package/dist/extensions/mega-compact-driver.js +79 -0
- package/dist/extensions/mega-compact.test.js +54 -18
- package/dist/extensions/mega-config.js +10 -0
- package/dist/extensions/mega-dashboard-cmds.js +32 -2
- package/dist/extensions/mega-events.js +45 -23
- package/dist/extensions/mega-pipeline.js +77 -3
- package/dist/src/config/dedup.js +4 -1
- package/dist/src/config.js +21 -0
- package/dist/src/dedup/raptor/index.js +28 -6
- package/dist/src/dedup/raptor/promote.test.js +69 -0
- package/dist/src/engine.js +1 -0
- package/dist/src/recall.js +30 -4
- package/dist/src/recall.test.js +28 -0
- package/dist/src/store/backfill.js +5 -6
- package/dist/src/store/compression.js +47 -7
- package/dist/src/store/compression.test.js +48 -0
- package/dist/src/store/sqlite.js +64 -41
- package/dist/src/store.test.js +19 -0
- package/dist/src/vectorStore.js +56 -1
- package/extensions/DASHBOARD.md +3 -3
- package/extensions/dashboard-server.test.ts +77 -0
- package/extensions/dashboard-server.ts +57 -11
- package/extensions/mega-compact-driver.ts +105 -0
- package/extensions/mega-compact.test.ts +65 -18
- package/extensions/mega-config.ts +25 -0
- package/extensions/mega-dashboard-cmds.ts +23 -2
- package/extensions/mega-events.ts +43 -24
- package/extensions/mega-pipeline.ts +83 -4
- package/package.json +6 -7
- package/src/config/dedup.ts +4 -1
- package/src/config.ts +26 -0
- package/src/dedup/raptor/index.ts +42 -7
- package/src/dedup/raptor/promote.test.ts +82 -0
- package/src/engine.ts +5 -0
- package/src/recall.test.ts +44 -0
- package/src/recall.ts +43 -4
- package/src/store/backfill.ts +10 -11
- package/src/store/compression.test.ts +58 -0
- package/src/store/compression.ts +48 -7
- package/src/store/sqlite.ts +72 -49
- package/src/store.test.ts +22 -0
- package/src/vectorStore.ts +63 -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/dist/src/store/sqlite.js
CHANGED
|
@@ -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 (
|
|
6
|
-
* honors PREVENT-PI-004).
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
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] =
|
|
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
|
|
56
|
-
db.
|
|
57
|
-
db.
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 =
|
|
122
|
+
indexCache = iddb;
|
|
118
123
|
indexCacheDir = indexDir;
|
|
119
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/src/store.test.js
CHANGED
|
@@ -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
|
// ---------------------------------------------------------------------------
|
package/dist/src/vectorStore.js
CHANGED
|
@@ -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
|
package/extensions/DASHBOARD.md
CHANGED
|
@@ -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
|
|
6
|
-
|
|
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 (`
|
|
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
|
|
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:
|
|
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
|
|
61
|
-
db.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
+
}
|