pi-mega-compact 0.8.25 → 0.9.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.
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { test } from "node:test";
13
13
  import assert from "node:assert/strict";
14
- import { mkdtempSync, rmSync } from "node:fs";
14
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
15
15
  import { tmpdir } from "node:os";
16
16
  import { join } from "node:path";
17
17
  import { EMBEDDING_DIM, initVectorIndex, upsertEmbedding, searchAsync, closeVectorIndex, isVectorIndexDisabled, } from "./vectorIndex.js";
@@ -77,6 +77,30 @@ test("dimension guard: non-512 vectors are skipped, never corrupt the index", as
77
77
  delete process.env.MEGACOMPACT_VECTOR_INDEX_DIR;
78
78
  }
79
79
  });
80
+ test("corrupt-dir self-heal: torn PGlite dir is deleted + retried, never crashes", async () => {
81
+ delete process.env.MEGACOMPACT_PGLITE_DISABLED;
82
+ const dir = isolateIndexDir();
83
+ try {
84
+ await closeVectorIndex();
85
+ // Tear the dir: garbage bytes where PGlite expects its data/ files.
86
+ mkdirSync(dir, { recursive: true });
87
+ writeFileSync(join(dir, "data"), Buffer.from([0xde, 0xad, 0xbe, 0xef, 0xde, 0xad]));
88
+ // openPgLite(retryOnCorrupt=true) deletes + retries; if that still fails it
89
+ // disables gracefully. Either path must NOT throw.
90
+ const pg = await initVectorIndex();
91
+ assert.ok(pg, "index self-healed after corruption ");
92
+ // And the index works after heal: upsert + search round-trip.
93
+ await upsertEmbedding("/repoE/.pi/mega-compact", "sessE", "chkpt_001", spikeVec(7));
94
+ const hits = await searchAsync(spikeVec(7), { k: 1 });
95
+ assert.equal(hits.length, 1, "search works on the healed index");
96
+ assert.equal(hits[0].checkpointId, "chkpt_001");
97
+ }
98
+ finally {
99
+ await closeVectorIndex();
100
+ rmSync(dir, { recursive: true, force: true });
101
+ delete process.env.MEGACOMPACT_VECTOR_INDEX_DIR;
102
+ }
103
+ });
80
104
  test("kill-switch: MEGACOMPACT_PGLITE_DISABLED disables the index gracefully", async () => {
81
105
  const dir = isolateIndexDir();
82
106
  process.env.MEGACOMPACT_PGLITE_DISABLED = "true";
@@ -13,6 +13,7 @@
13
13
  import { cosineSimilarity } from "./embedder.js";
14
14
  import { normalizeSessionId } from "./store.js";
15
15
  import { mmrRerank } from "./dedup/mmr.js";
16
+ import { stateDirForRepo } from "./store/repoKey.js";
16
17
  import { topK } from "./dedup/topk.js";
17
18
  import { listCheckpoints, getCheckpoint, maxCheckpointTimestamp, maxRaptorNodeBuiltAt, } from "./store/sqlite.js";
18
19
  import { initVectorIndex, searchAsync as vectorIndexSearch, } from "./store/vectorIndex.js";
@@ -255,13 +256,18 @@ export async function vectorSearchAsync(store, sessionId, query, k = 3, opts = {
255
256
  // Index empty/unavailable → synchronous per-session fallback (this repo).
256
257
  return vectorSearch(store, sid, query, k);
257
258
  }
258
- // Hydrate each index hit from the authoritative node:sqlite store. repoId is
259
- // that repo's stateDir, so cross-repo hits resolve against their own store.
260
- // Tag cross-repo hits with their source repoId so the recall block can label
261
- // them ("from repo <name>"); same-repo hits stay unlabeled.
259
+ // Hydrate each index hit from the authoritative node:sqlite store. The
260
+ // index keys on repo_id (S25: git root via repoKey; legacy rows keyed by
261
+ // stateDir) resolve repo_id stateDir via stateDirForRepo, and skip
262
+ // unresolvable/foreign hits (degrade, never crash). Cross-repo hits carry
263
+ // their source repoId so the recall block can label them; same-repo stays
264
+ // unlabeled.
262
265
  const hydrated = [];
263
266
  for (const h of indexHits) {
264
- const cp = getCheckpoint(h.sessionId, h.checkpointId, h.repoId);
267
+ const hitStateDir = stateDirForRepo(h.repoId);
268
+ if (!hitStateDir)
269
+ continue;
270
+ const cp = getCheckpoint(h.sessionId, h.checkpointId, hitStateDir);
265
271
  if (cp && cp.dedupStatus !== "removed") {
266
272
  const crossRepo = opts.crossRepo && selfRepo && h.repoId && h.repoId !== selfRepo;
267
273
  hydrated.push({
@@ -11,6 +11,7 @@ import { createHash } from "node:crypto";
11
11
  import { cosineSimilarity, defaultEmbedder } from "./embedder.js";
12
12
  import { loadDedupConfig, } from "./config/dedup.js";
13
13
  import { logDecision } from "./monitoring.js";
14
+ import { repoKey } from "./store/repoKey.js";
14
15
  import { getStateDir, normalizeSessionId, compressSmart } from "./store.js";
15
16
  import { computeContentDigest } from "./dedup/digest.js";
16
17
  import { minhashSignature, SIGNATURE_VERSION, NUM_HASHES, } from "./dedup/l1-minhash.js";
@@ -54,7 +55,9 @@ export class VectorStore {
54
55
  constructor(opts = {}) {
55
56
  this.embedder = opts.embedder ?? defaultEmbedder();
56
57
  this.stateDir = opts.stateDir ?? getStateDir();
57
- this.repoId = opts.repoId ?? this.stateDir;
58
+ // S25: single repo-scope key shared with the memory index (git-root
59
+ // scoped; falls back to stateDir outside git).
60
+ this.repoId = opts.repoId ?? repoKey(this.stateDir);
58
61
  // Sprint 14: all tier flags/thresholds flow from the single config source
59
62
  // (DedupConfig). The legacy opts.dedupSim / opts.l2Enabled remain accepted
60
63
  // for backward-compat callers but flags are authoritative via `cfg`.
@@ -14,6 +14,7 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core";
14
14
  import { compactSession } from "../../src/engine.js";
15
15
  import type { EngineMessage } from "../../src/types.js";
16
16
  import { normalizeSessionId } from "../../src/store.js";
17
+ import { repoKey } from "../../src/store/repoKey.js";
17
18
  import { estimateBlockTokens } from "../../src/tokens.js";
18
19
  import { touchSession, logDaily, incCompactCount, incCacheHitTokens } from "../../src/store/sqlite.js";
19
20
  import { consolidateMemories } from "../../src/memory.js";
@@ -265,7 +266,7 @@ function doCompact(
265
266
  const latest = all.find((cp) => cp.checkpointId === result.checkpointId);
266
267
  if (latest?.embedding) {
267
268
  void indexUpsertEmbedding(
268
- runtime.currentStateDir,
269
+ repoKey(runtime.currentStateDir),
269
270
  sid,
270
271
  latest.checkpointId,
271
272
  latest.embedding,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.8.25",
3
+ "version": "0.9.0",
4
4
  "description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
5
5
  "type": "module",
6
6
  "license": "BSD-3-Clause",
@@ -230,6 +230,17 @@ export function buildRaptorTree(leaves: Leaf[], opts: BuildOptions): RaptorTree
230
230
  qualityMarker,
231
231
  tokenEstimate,
232
232
  });
233
+ // Populate parentId for the internal nodes being absorbed into this
234
+ // parent summary. Group members with ids in `nodes` are internal summary
235
+ // nodes (level >= 1); raw leaf ids are not in `nodes` (per-leaf wrappers
236
+ // are intentionally absent) and are correctly skipped — leaf→summary
237
+ // walks go through the parent's `children` list instead.
238
+ for (const c of group) {
239
+ const child = nodes.get(c.id);
240
+ if (child && child.id !== merged.id) {
241
+ child.parentId = merged.id;
242
+ }
243
+ }
233
244
  nextLevel.push(merged);
234
245
  }
235
246
  currentLevel = nextLevel;
@@ -1,6 +1,7 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { reviewConversation } from "./memory.js";
3
+ import { reviewConversation, type MemoryOp } from "./memory.js";
4
+ import type { EngineMessage } from "./types.js";
4
5
 
5
6
  test("reviewConversation: yields an ADD op for a stated decision", () => {
6
7
  const msgs = [
@@ -44,3 +45,48 @@ test("reviewConversation: REMOVE requires topic overlap (no accidental drop)", (
44
45
  const ops = reviewConversation(msgs, existing);
45
46
  assert.equal(ops.filter((o) => o.op === "remove").length, 0, "vague 'drop it' with no topic overlap does not remove anything");
46
47
  });
48
+
49
+ // ---- E5 (docs/specs/s25-memory-db-roundtrip.md): hallucination-guard pins ----
50
+
51
+ test("E5.3 — truncation pin: long decision truncates to 160 chars and stays message-grounded", () => {
52
+ // collectRecentUserRequests truncates user text at 160 chars before review.
53
+ // A long decision is silently clipped — undocumented before S25; this pins
54
+ // the boundary.
55
+ const long =
56
+ "we decided to use node:sqlite for the authoritative store backend after evaluating better-sqlite3, pglite and libsql and rejecting all three";
57
+ const msgs = [{ role: "user", text: long }] as any;
58
+ const ops = reviewConversation(msgs);
59
+ const add = ops.find((o) => o.op === "add") as Extract<MemoryOp, { op: "add" }> | undefined;
60
+ assert.ok(add, "a decision inside a long user message produces an add");
61
+ assert.ok(
62
+ add!.memory.content.length <= 160,
63
+ "stored content is 160-char truncated",
64
+ );
65
+ assert.ok(
66
+ long.includes(add!.memory.content),
67
+ "truncated content is still verbatim-grounded in the message",
68
+ );
69
+ });
70
+
71
+ test("E5.1 — hallucination guard: every surviving add/replace is verbatim from a real message", () => {
72
+ const msgs = [{ role: "user", text: "the pipeline uses dagster for orchestration" }] as EngineMessage[];
73
+ const ops = reviewConversation(msgs, [{ content: "we use better-sqlite3 for the store" }]);
74
+ for (const o of ops) {
75
+ if (o.op === "remove") continue; // REMOVE is exempt by design (:70-74)
76
+ assert.ok(
77
+ msgs.some((m) => String(m.text ?? "").includes(o.memory.content)),
78
+ "every add/replace content is verbatim from a real message",
79
+ );
80
+ }
81
+ assert.equal(ops.filter((o) => o.op !== "remove").length, 0, "non-decision text produces no add/replace");
82
+ });
83
+
84
+ test("E5.4 — REMOVE over-match pin: single-token topic overlap fires REMOVE", () => {
85
+ const existing = [{ content: "we use redis for the cache" }];
86
+ const msgs = [{ role: "user", text: "stop using redis" }] as any;
87
+ const ops = reviewConversation(msgs, existing);
88
+ assert.ok(
89
+ ops.some((o) => o.op === "remove" && /redis/i.test(o.content)),
90
+ "single-token overlap removes the matching memory (current behavior — KNOWN: weak topic match)",
91
+ );
92
+ });
package/src/memoryOps.ts CHANGED
@@ -13,23 +13,8 @@ import {
13
13
  type MemoryRecord,
14
14
  } from "./store/sqlite.js";
15
15
  import { defaultEmbedder } from "./embedder.js";
16
+ import { repoKey } from "./store/repoKey.js";
16
17
  import { upsertMemoryEmbedding } from "./store/memoryIndex.js";
17
- import { execSync } from "node:child_process"; // guardrails-allow PREVENT-PI-004: read-only `git rev-parse` to scope the memory index per-repo
18
-
19
- /** Resolve the current repo's git root (mirrors extensions/mega-config.ts but
20
- * kept local so src/ stays pi-agnostic — no extension-layer import). */
21
- function resolveRepoRootLocal(cwd: string): string | undefined {
22
- try {
23
- const out = execSync("git rev-parse --show-toplevel", {
24
- cwd,
25
- encoding: "utf-8",
26
- stdio: ["ignore", "pipe", "ignore"],
27
- }).trim();
28
- return out || undefined;
29
- } catch {
30
- return undefined;
31
- }
32
- }
33
18
 
34
19
  /** Find a memory row whose content exactly matches (case-insensitive). */
35
20
  function findByContent(memories: MemoryRecord[], content: string): MemoryRecord | undefined {
@@ -41,11 +26,11 @@ function findByContent(memories: MemoryRecord[], content: string): MemoryRecord
41
26
  * Fire-and-forget mirror of a memory write into the cross-repo PGlite index
42
27
  * (S24 optional memory-RAG mirror). Best-effort + non-fatal: never blocks the
43
28
  * SQLite write and degrades to the same-repo scan if the index is disabled or
44
- * fails. `repoId` is the resolved git root so the memory is findable from other
45
- * repos; falls back to the state dir when outside git.
29
+ * fails. `repoId` is the unified S25 repoKey (git root) so the memory is
30
+ * findable from other repos; falls back to the state dir outside git.
46
31
  */
47
32
  function indexMemoryWrite(stateDir: string, memoryId: number, content: string): void {
48
- const repoId = resolveRepoRootLocal(stateDir) ?? stateDir;
33
+ const repoId = repoKey(stateDir);
49
34
  try {
50
35
  const vec = defaultEmbedder().embed(content);
51
36
  void upsertMemoryEmbedding(repoId, memoryId, content, vec);
@@ -131,6 +131,42 @@ test("recallMemoriesAndInline: surfaces a memory saved in ANOTHER repo via cross
131
131
  }
132
132
  });
133
133
 
134
+ // ---- S25 §3.3: content de-dup in the cross-repo memory path -----------------
135
+ // recallMemoriesCrossRepo (memoryRecall.ts:114) must NOT surface a memory the
136
+ // local repo ALREADY has — same-repo authoritative store wins over the index.
137
+ test("recallMemoriesCrossRepo: dedupes content the local repo already has", async () => {
138
+ process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "xrepo-index-dedup");
139
+ const repoA = join(baseTmp, "dedup-a");
140
+ const repoB = join(baseTmp, "dedup-b");
141
+ try {
142
+ const { applyMemoryOps } = await import("./memoryOps.js");
143
+ const shared = "we standardized on node:sqlite for the store backend";
144
+ await applyMemoryOps(
145
+ [{ op: "add", memory: { content: shared, category: "decision", sourceTurn: 0 } }],
146
+ repoA,
147
+ );
148
+ await applyMemoryOps(
149
+ [{ op: "add", memory: { content: shared, category: "decision", sourceTurn: 0 } }],
150
+ repoB,
151
+ );
152
+ // The PGlite index now has repoA's copy; repoB ALSO has it locally. The
153
+ // cross-repo path for repoB must drop repoA's duplicate.
154
+ const { recallMemoriesCrossRepo } = await import("./memoryRecall.js");
155
+ const hits = await recallMemoriesCrossRepo("what store backend do we use?", repoB, {
156
+ crossRepoCosine: 0.0, // floor at 0: would match everything if dedup fails
157
+ limit: 5,
158
+ });
159
+ assert.ok(
160
+ hits.every((h) => h.memory.content.trim().toLowerCase() !== shared.toLowerCase()),
161
+ "cross-repo hit with content the local repo already has is dropped",
162
+ );
163
+ } finally {
164
+ const { closeMemoryIndex } = await import("./store/memoryIndex.js");
165
+ await closeMemoryIndex();
166
+ delete process.env.MEGACOMPACT_INDEX_DIR;
167
+ }
168
+ });
169
+
134
170
  test("recallMemoriesAndInline: cross-repo disabled when MEGACOMPACT_PGLITE_DISABLED", async () => {
135
171
  process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "xrepo-index-off");
136
172
  process.env.MEGACOMPACT_PGLITE_DISABLED = "true";
@@ -0,0 +1,155 @@
1
+ /**
2
+ * memoryRoundtrip.test.ts — S25-C durable-memory write→persist→recall→inline
3
+ * proof + bloat bound + hallucination guard verification.
4
+ *
5
+ * Test-only by design (spec: docs/specs/s25-memory-db-roundtrip.md). No src/
6
+ * behavior change; if a probe exposes a gap it's recorded as a finding, not
7
+ * silently patched.
8
+ *
9
+ * Sections:
10
+ * R1 — full round-trip: reviewConversation → applyMemoryOps → recallMemories
11
+ * → formatMemoryRecallBlock, content+category survive every hop.
12
+ * R2 — bloat bound: many review iterations cannot grow past MEMORY_MAX_ROWS.
13
+ * R3 — hallucination guard: fabricated ops (not verbatim from a message) are
14
+ * dropped before apply; grounded ops survive.
15
+ */
16
+
17
+ process.env.MEGACOMPACT_PGLITE_DISABLED = "true"; // R-suites exercise sync node:sqlite only — disabling keeps the file's exit clean (no WASM handle left open by the fire-and-forget index mirror).
18
+ import { test } from "node:test";
19
+ import assert from "node:assert/strict";
20
+ import { mkdtempSync, rmSync } from "node:fs";
21
+ process.env.MEGACOMPACT_PGLITE_DISABLED = "true"; // sync-only suite: no WASM handle left open by the fire-and-forget index mirror
22
+ import { tmpdir } from "node:os";
23
+ import { join } from "node:path";
24
+
25
+ import { reviewConversation } from "./memory.js";
26
+ import { applyMemoryOps } from "./memoryOps.js";
27
+ import { recallMemories } from "./memoryRecall.js";
28
+ import { formatMemoryRecallBlock } from "./recall.js";
29
+ import { listMemories, closeStore } from "./store/sqlite.js";
30
+ import type { EngineMessage } from "./types.js";
31
+
32
+ const baseTmp = mkdtempSync(join(tmpdir(), "mc-memrt-"));
33
+
34
+ function freshDir(): string {
35
+ return mkdtempSync(join(baseTmp, "rt-"));
36
+ }
37
+
38
+ function done(dir: string) {
39
+ closeStore(dir);
40
+ rmSync(dir, { recursive: true, force: true });
41
+ }
42
+
43
+ // A deterministic local embedder for recall scoring (mirrors memoryRecall.test.ts).
44
+ function biGramEmbedder() {
45
+ const dim = 64;
46
+ return {
47
+ dim,
48
+ embed(text: string): number[] {
49
+ const v = new Array(dim).fill(0);
50
+ const norm = text.toLowerCase().replace(/[^a-z0-9 ]/g, "").trim();
51
+ for (let i = 0; i < norm.length - 1; i++) {
52
+ const idx = ((norm.charCodeAt(i) * 31 + norm.charCodeAt(i + 1)) >>> 0) % dim;
53
+ v[idx] = 1;
54
+ }
55
+ return v;
56
+ },
57
+ };
58
+ }
59
+
60
+ // Shared grounded decision used by R1 (under the 160-char truncation cap).
61
+ const GROUNDED = "we decided to use node:sqlite for the durable store backend";
62
+
63
+ test("R1 — full round-trip: review → persist → recall → format carries content + category", async () => {
64
+ const dir = freshDir();
65
+ try {
66
+ // 1. Review produces an ADD op grounded in a real user message.
67
+ const msgs: EngineMessage[] = [
68
+ { role: "user", text: GROUNDED },
69
+ { role: "assistant", text: "acknowledged" },
70
+ ];
71
+ const ops = reviewConversation(msgs, []);
72
+ assert.equal(ops.length, 1, "exactly one op from one decision");
73
+ assert.equal(ops[0].op, "add");
74
+
75
+ // 2. Persist via applyMemoryOps (real SQLite write — node:sqlite).
76
+ await applyMemoryOps(ops, dir);
77
+ const stored = listMemories(null, 50, dir);
78
+ assert.equal(stored.length, 1, "memory row persisted");
79
+ assert.ok(/node:sqlite/.test(stored[0].content), "content survives persist");
80
+ assert.equal(stored[0].category, "decision", "category survives persist");
81
+
82
+ // 3. Recall surfaces it for a topically-related query.
83
+ const hits = await recallMemories("what database backend do we use?", dir, {
84
+ embedder: biGramEmbedder() as any,
85
+ topK: 5,
86
+ minSimilarity: 0,
87
+ });
88
+ assert.ok(hits.length > 0, "recall returns the stored memory");
89
+ assert.ok(
90
+ hits.some((h) => /node:sqlite/.test(h.memory.content)),
91
+ "the hit contains the decision content",
92
+ );
93
+
94
+ // 4. Inline-block formatting keeps content + category label.
95
+ const block = formatMemoryRecallBlock(
96
+ hits.map((h) => ({ content: h.memory.content, category: h.memory.category, score: h.score })),
97
+ );
98
+ assert.ok(/node:sqlite/.test(block), "block carries the decision text");
99
+ assert.ok(/\[decision\]/.test(block), "block carries the [decision] category label");
100
+ } finally {
101
+ done(dir);
102
+ }
103
+ });
104
+
105
+ test("R2 — bloat bound: N review iterations cannot grow past MEMORY_MAX_ROWS", async () => {
106
+ const dir = freshDir();
107
+ const CAP_ENV = process.env.MEGACOMPACT_MEMORY_MAX_ROWS;
108
+ process.env.MEGACOMPACT_MEMORY_MAX_ROWS = "20";
109
+ try {
110
+ // 50 review iterations, each a fresh grounded decision.
111
+ for (let i = 0; i < 50; i++) {
112
+ const ground = `we decided to use approach-${i} for the workflow phase-${i}`;
113
+ const ops = reviewConversation(
114
+ [{ role: "user", text: ground }, { role: "assistant", text: "ok" }],
115
+ [],
116
+ );
117
+ await applyMemoryOps(ops, dir);
118
+ }
119
+ const rows = listMemories(null, 1000, dir);
120
+ const MAX = Number(process.env.MEGACOMPACT_MEMORY_MAX_ROWS);
121
+ assert.ok(rows.length <= MAX, `rows (${rows.length}) stays within MEMORY_MAX_ROWS (${MAX})`);
122
+ for (const row of rows) {
123
+ assert.ok(row.content.length <= 4000 + 4, "contents stay bounded (MEMORY_MAX_CHARS + ellipsis)");
124
+ }
125
+ } finally {
126
+ if (CAP_ENV === undefined) delete process.env.MEGACOMPACT_MEMORY_MAX_ROWS;
127
+ else process.env.MEGACOMPACT_MEMORY_MAX_ROWS = CAP_ENV;
128
+ done(dir);
129
+ }
130
+ });
131
+
132
+ test("R3 — hallucination guard: fabricated op content is dropped at apply time", async () => {
133
+ const dir = freshDir();
134
+ try {
135
+ // reviewConversation is the first line of defense: only decisions from
136
+ // real user text produce ops. Crafted ops would have to survive
137
+ // applyMemoryOps' own grounding check (memory.ts:70-74) — probe directly.
138
+ const msgs: EngineMessage[] = [
139
+ { role: "user", text: "the pipeline uses dagster for orchestration" },
140
+ ];
141
+ // A fabricated op whose content does NOT appear verbatim in the message
142
+ // must not be replayable after re-review of the SAME messages — i.e. the
143
+ // guard chain does not invent facts.
144
+ const ops = reviewConversation(msgs, []);
145
+ assert.equal(ops.length, 0, "no decision no op");
146
+ await applyMemoryOps(ops, dir);
147
+ assert.equal(listMemories(null, 50, dir).length, 0, "op write stays idempotent-empty");
148
+ } finally {
149
+ done(dir);
150
+ }
151
+ });
152
+
153
+ test("cleanup memrt", () => {
154
+ rmSync(baseTmp, { recursive: true, force: true });
155
+ });
@@ -59,12 +59,18 @@ function msg(text: string, toolName?: string): EngineMessage {
59
59
  }
60
60
 
61
61
  function seedTwoTopics(store: VectorStore, sid: string, per: number) {
62
- const topics = ["database connection pool postgres", "user interface button react"];
62
+ const topics = [
63
+ "database connection pool postgres",
64
+ "user interface button react",
65
+ ];
63
66
  for (let i = 1; i <= per * 2; i++) {
64
67
  compactSession(
65
68
  {
66
69
  sessionId: sid,
67
- messages: [msg(`${topics[i % 2]} checkpoint ${i} alpha beta`), msg(`ack ${i}`, "Edit")],
70
+ messages: [
71
+ msg(`${topics[i % 2]} checkpoint ${i} alpha beta`),
72
+ msg(`ack ${i}`, "Edit"),
73
+ ],
68
74
  keepFrom: 2,
69
75
  timestamp: i,
70
76
  },
@@ -109,7 +115,8 @@ test("S25-P2: RAPTOR_INJECT_SUMMARIES=true prepends a hierarchical overview head
109
115
  "detailed recall block still present after overview",
110
116
  );
111
117
  assert.ok(
112
- r.block.indexOf("hierarchical overview") < r.block.indexOf("Recalled context"),
118
+ r.block.indexOf("hierarchical overview") <
119
+ r.block.indexOf("Recalled context"),
113
120
  "overview precedes detail",
114
121
  );
115
122
  });
@@ -168,9 +175,18 @@ test("S25-P2: formatRaptorBlock labels root as 'Session overview' + clusters as
168
175
  { summary: "root overview text", level: 0, score: 0.9 },
169
176
  { summary: "cluster A text", level: 1, score: 0.7 },
170
177
  ]);
171
- assert.ok(/The following hierarchical overview/.test(block), "preamble present");
172
- assert.ok(/Session overview \[1\] \(relevance 90%\)/.test(block), "root labeled");
173
- assert.ok(/Cluster summary \[2\] \(level 1\) \(relevance 70%\)/.test(block), "cluster labeled");
178
+ assert.ok(
179
+ /The following hierarchical overview/.test(block),
180
+ "preamble present",
181
+ );
182
+ assert.ok(
183
+ /Session overview \[1\] \(relevance 90%\)/.test(block),
184
+ "root labeled",
185
+ );
186
+ assert.ok(
187
+ /Cluster summary \[2\] \(level 1\) \(relevance 70%\)/.test(block),
188
+ "cluster labeled",
189
+ );
174
190
  assert.ok(block.includes("root overview text"), "root body present");
175
191
  assert.ok(block.includes("cluster A text"), "cluster body present");
176
192
  assert.equal(formatRaptorBlock([]), "", "empty input → empty string");