pi-mega-compact 0.6.1 → 0.6.2

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.
@@ -0,0 +1,164 @@
1
+ /**
2
+ * mega-teamrun.test.ts — regression test for the "auto-compact runs but context
3
+ * never relieves during a team run (sub-agents)" bug.
4
+ *
5
+ * Loads the REAL compiled extension (extensions/mega-compact.js) through a
6
+ * faithful mock pi (mirrors mega-compact.test.ts's harness) and drives the
7
+ * exact event sequence a long team run produces:
8
+ *
9
+ * agent_start -> context (over threshold) xN -> agent_end (repeat x3)
10
+ *
11
+ * Asserts the TWO fixes:
12
+ * 1. live trim FIRES per-call (computeLiveTrimCut no longer returns null on
13
+ * the anchor floor — was `cutNull`, liveTrimFires===0 before the fix).
14
+ * 2. the DURABLE trim fires at agent_end while idle + over threshold
15
+ * (mid-run durable trigger), not only at parent settle.
16
+ *
17
+ * The mock ctx.compact() drives session_before_compact so we observe the
18
+ * durable truncation. Counters come from MegaRuntime.diag* (set behind the
19
+ * real handler code, inert in production).
20
+ *
21
+ * MEGACOMPACT_PGLITE_DISABLED keeps the run fast (no WASM index init).
22
+ */
23
+
24
+ import { test } from "node:test";
25
+ import assert from "node:assert/strict";
26
+ import { mkdtempSync, rmSync } from "node:fs";
27
+ import { tmpdir } from "node:os";
28
+ import { join } from "node:path";
29
+ import { createRequire } from "node:module";
30
+ import { closeVectorIndex } from "../src/store/vectorIndex.js";
31
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
32
+
33
+ const require = createRequire(import.meta.url);
34
+ const baseTmp = mkdtempSync(join(tmpdir(), "mc-team-"));
35
+ process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "index");
36
+ process.env.MEGACOMPACT_PGLITE_DISABLED = "true"; // fast: skip WASM index
37
+ let counter = 0;
38
+
39
+ function harness() {
40
+ const stateDir = join(baseTmp, `run-${counter++}`);
41
+ process.env.MEGACOMPACT_STATE_DIR = stateDir;
42
+ process.env.MEGACOMPACT_DEBUG = "true";
43
+ process.env.MEGACOMPACT_THRESHOLD_TOKENS = "50";
44
+ process.env.MEGACOMPACT_FAST_GATE_PCT = "1";
45
+ process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES = "1";
46
+ process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR = "0"; // piCompactWouldNoop must not skip
47
+ process.env.MEGACOMPACT_MEMORY_AUTO_REVIEW = "false";
48
+ process.env.MEGACOMPACT_RAPTOR_ENABLED = "false";
49
+ delete process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM;
50
+
51
+ const handlers: Record<string, Function> = {};
52
+ const compactCalls: any[] = [];
53
+
54
+ function msg(role: string, text: string, toolName?: string): AgentMessage {
55
+ if (role === "assistant" && toolName) {
56
+ return { role: "assistant", content: [{ type: "toolCall", name: toolName, id: "c1", arguments: {} }], api: "anthropic-messages", provider: "anthropic", model: "m", usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, stopReason: "tool_use", timestamp: 0 } as unknown as AgentMessage;
57
+ }
58
+ if (role === "toolResult" && toolName) {
59
+ return { role: "toolResult", content: [{ type: "text", text }], toolCallId: "c1", toolName, isError: false, timestamp: 0 } as unknown as AgentMessage;
60
+ }
61
+ return { role: "user", content: text, timestamp: 0 } as unknown as AgentMessage;
62
+ }
63
+
64
+ const session: AgentMessage[] = [];
65
+ for (let i = 0; i < 14; i++) {
66
+ session.push(msg("user", `actually we decided to use approach ${i} for module ${i}`));
67
+ session.push(msg("assistant", `edited module ${i}`, "Edit"));
68
+ session.push(msg("toolResult", `edited module ${i}`, "Edit"));
69
+ }
70
+
71
+ const toEntry = (m: AgentMessage, i: number): any => ({ type: "message", id: `e${i}`, parentId: null, timestamp: String(i), message: m });
72
+ const sessionManager = {
73
+ getSessionId: () => "sess_team_001",
74
+ getEntries: () => session.map(toEntry),
75
+ getBranch: () => session.map(toEntry),
76
+ };
77
+
78
+ function makeCtx(over: Partial<any> = {}) {
79
+ return {
80
+ ui: { setStatus: () => {}, notify: () => {}, select: () => {}, confirm: async () => true, input: async () => "", setWidget: () => {} },
81
+ mode: "tui" as any, hasUI: true, cwd: stateDir, sessionManager,
82
+ modelRegistry: {} as any, model: undefined, isIdle: () => true, isProjectTrusted: () => true,
83
+ signal: undefined, abort: () => {}, hasPendingMessages: () => false, shutdown: () => {},
84
+ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }),
85
+ // Mock ctx.compact() runs pi's flow and fires session_before_compact.
86
+ compact: (opts?: any) => {
87
+ compactCalls.push(opts);
88
+ if (handlers["session_before_compact"]) {
89
+ return handlers["session_before_compact"](
90
+ { type: "session_before_compact", reason: "threshold", willRetry: false, signal: undefined, preparation: { firstKeptEntryId: "e2", messagesToSummarize: session.slice(0, 2), tokensBefore: 500 } } as any,
91
+ makeCtx(),
92
+ );
93
+ }
94
+ return undefined;
95
+ },
96
+ getSystemPrompt: () => "system base",
97
+ ...over,
98
+ } as any;
99
+ }
100
+
101
+ const pi = {
102
+ on: (ev: string, h: Function) => { handlers[ev] = h; },
103
+ registerCommand: () => {}, registerTool: () => {}, registerShortcut: () => {},
104
+ registerFlag: () => {}, getFlag: () => undefined, registerMessageRenderer: () => {},
105
+ registerEntryRenderer: () => {}, sendMessage: () => {}, sendUserMessage: () => {},
106
+ appendEntry: () => {}, setSessionName: () => {}, getSessionName: () => undefined,
107
+ setLabel: () => {}, exec: async () => ({ stdout: "", stderr: "", code: 0 }),
108
+ getActiveTools: () => [], getAllTools: () => [], setActiveTools: () => {},
109
+ getCommands: () => [], setModel: async () => false, getThinkingLevel: () => "off" as any,
110
+ setThinkingLevel: () => {},
111
+ } as any;
112
+
113
+ const mod = require("./mega-compact.js") as { default: (p: any) => void };
114
+ mod.default(pi);
115
+ const { lastRuntime } = require("./mega-events.js") as { lastRuntime: any };
116
+
117
+ const fire = (ev: string, event: any, ctx: any) => handlers[ev](event, ctx);
118
+ return {
119
+ stateDir, handlers, compactCalls, fire, ctx: makeCtx, session,
120
+ runtime: lastRuntime, // MegaRuntime with diag* counters
121
+ // Advance the debounce so agent_end (same instant) can trigger durable trim.
122
+ clearDebounce: () => { if (lastRuntime) lastRuntime.debounceUntil = 0; },
123
+ };
124
+ }
125
+
126
+ test("team run: live trim fires AND durable trim fires per sub-agent (relieves context)", async () => {
127
+ const h = harness();
128
+ const ctx = h.ctx();
129
+ for (let a = 0; a < 3; a++) {
130
+ await h.fire("agent_start", { type: "agent_start", messages: [] }, ctx);
131
+ for (let i = 0; i < 4; i++) {
132
+ await h.fire("context", { type: "context", messages: h.session }, ctx);
133
+ }
134
+ // Real team runs settle seconds after the last context event; mimic that
135
+ // so the 2s debounce has elapsed and the durable trigger can fire.
136
+ await new Promise((r) => setTimeout(r, 2100));
137
+ h.clearDebounce();
138
+ await h.fire("agent_end", { type: "agent_end", messages: [] }, ctx);
139
+ }
140
+ const rt = h.runtime;
141
+ // FIX 1: live trim must actually fire (was 0 — computeLiveTrimCut returned null).
142
+ assert.ok(rt.diagLiveTrimFires > 0, "live trim fires during the team run (anchor-floor fix)");
143
+ assert.equal(rt.diagCtxCutNull, 0, "no live-trim cut skipped on anchor floor");
144
+ // FIX 2: durable trim must fire at each agent_end (was 0 — only at parent settle).
145
+ assert.equal(rt.diagAgentEndDurable, 3, "mid-run durable trigger fired at each agent_end");
146
+ assert.equal(rt.diagBeforeCompactSupplied, 3, "our durable trim supplied 3x (context relieved)");
147
+ assert.ok(h.compactCalls.length >= 3, "ctx.compact() invoked for durable trim between sub-agents");
148
+ });
149
+
150
+ test("control: session_before_compact supplies a durable compaction (parent settles)", async () => {
151
+ const h = harness();
152
+ const res = await h.fire(
153
+ "session_before_compact",
154
+ { type: "session_before_compact", reason: "threshold", willRetry: false, signal: undefined, preparation: { firstKeptEntryId: "e2", messagesToSummarize: h.session.slice(0, 4), tokensBefore: 500 } } as any,
155
+ h.ctx(),
156
+ );
157
+ assert.ok(res?.compaction, "compaction result returned to pi");
158
+ assert.equal(res.compaction.firstKeptEntryId, "e2", "reuses pi's boundary (PREVENT-PI-002)");
159
+ });
160
+
161
+ test("cleanup", async () => {
162
+ await closeVectorIndex();
163
+ rmSync(baseTmp, { recursive: true, force: true });
164
+ });
@@ -46,7 +46,34 @@ export function computeLiveTrimCut(view: EngineMessage[], opts: BuildLiveTrimVie
46
46
  if (cut <= 0) return null; // nothing safe to cut — keep everything this call
47
47
  const recent = view.slice(cut);
48
48
  const userCount = recent.filter((m) => m.role === "user").length;
49
- if (userCount < opts.anchorUserMessages) return null;
49
+ // ANCHOR FLOOR (PREVENT-PI-001): the recent window must keep at least
50
+ // `anchorUserMessages` user messages. The original compactedFrom can land on a
51
+ // run that starts with fewer than that (e.g. the preserved region begins on a
52
+ // tool pair, or the session's tail is tool-heavy). Instead of bailing out and
53
+ // skipping the live trim entirely this call (which left the model fed a
54
+ // 150k-context window during long team runs), walk `cut` backward until the
55
+ // preserved run contains enough user messages — bounded by the boundary-safe
56
+ // constraint so we never split a tool pair. Falls back to null only when the
57
+ // whole view can't satisfy the floor (tiny sessions) — the next context event
58
+ // retries.
59
+ if (userCount < opts.anchorUserMessages) {
60
+ let c = cut;
61
+ while (c > 1) {
62
+ c--;
63
+ if (!isBoundarySafe(view, c)) continue;
64
+ const recentNow = view.slice(c);
65
+ const usersNow = recentNow.filter((m) => m.role === "user").length;
66
+ if (usersNow >= opts.anchorUserMessages) { cut = c; break; }
67
+ }
68
+ if (cut > 1) {
69
+ const finalRecent = view.slice(cut);
70
+ if (finalRecent.filter((m) => m.role === "user").length < opts.anchorUserMessages) {
71
+ return null; // cannot satisfy the floor without dropping too much — retry next call
72
+ }
73
+ } else {
74
+ return null;
75
+ }
76
+ }
50
77
  return cut;
51
78
  }
52
79
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
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-2-Clause",
package/src/memoryOps.ts CHANGED
@@ -12,6 +12,24 @@ import {
12
12
  removeMemory,
13
13
  type MemoryRecord,
14
14
  } from "./store/sqlite.js";
15
+ import { defaultEmbedder } from "./embedder.js";
16
+ 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
+ }
15
33
 
16
34
  /** Find a memory row whose content exactly matches (case-insensitive). */
17
35
  function findByContent(memories: MemoryRecord[], content: string): MemoryRecord | undefined {
@@ -19,6 +37,23 @@ function findByContent(memories: MemoryRecord[], content: string): MemoryRecord
19
37
  return memories.find((m) => m.content.trim().toLowerCase() === norm);
20
38
  }
21
39
 
40
+ /**
41
+ * Fire-and-forget mirror of a memory write into the cross-repo PGlite index
42
+ * (S24 optional memory-RAG mirror). Best-effort + non-fatal: never blocks the
43
+ * 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.
46
+ */
47
+ function indexMemoryWrite(stateDir: string, memoryId: number, content: string): void {
48
+ const repoId = resolveRepoRootLocal(stateDir) ?? stateDir;
49
+ try {
50
+ const vec = defaultEmbedder().embed(content);
51
+ void upsertMemoryEmbedding(repoId, memoryId, content, vec);
52
+ } catch {
53
+ /* non-fatal — embedding/index failure must never break the SQLite write */
54
+ }
55
+ }
56
+
22
57
  /**
23
58
  * Apply add/replace/remove ops to the memories table. Replaces are matched by
24
59
  * existing content; removes by content. Idempotent: an add that already exists
@@ -32,7 +67,7 @@ export async function applyMemoryOps(ops: MemoryOp[], stateDir: string): Promise
32
67
  if (op.op === "add") {
33
68
  // Skip if an identical memory already exists.
34
69
  if (findByContent(existing, op.memory.content)) continue;
35
- addMemory(
70
+ const id = addMemory(
36
71
  {
37
72
  kind: op.memory.category,
38
73
  content: op.memory.content,
@@ -44,6 +79,8 @@ export async function applyMemoryOps(ops: MemoryOp[], stateDir: string): Promise
44
79
  repo,
45
80
  stateDir,
46
81
  );
82
+ // S24: mirror into the cross-repo index (fire-and-forget; non-fatal).
83
+ indexMemoryWrite(stateDir, id, op.memory.content);
47
84
  } else if (op.op === "replace") {
48
85
  const match = findByContent(existing, op.targetContent);
49
86
  if (match) {
@@ -53,9 +90,11 @@ export async function applyMemoryOps(ops: MemoryOp[], stateDir: string): Promise
53
90
  category: op.memory.category,
54
91
  sourceTurn: op.memory.sourceTurn,
55
92
  }, stateDir);
93
+ // S24: re-mirror under the same memory id (fire-and-forget; non-fatal).
94
+ indexMemoryWrite(stateDir, match.id, op.memory.content);
56
95
  } else {
57
96
  // Target missing (e.g. earlier in-conversation contradiction) → add.
58
- addMemory(
97
+ const id = addMemory(
59
98
  {
60
99
  kind: op.memory.category,
61
100
  content: op.memory.content,
@@ -66,6 +105,7 @@ export async function applyMemoryOps(ops: MemoryOp[], stateDir: string): Promise
66
105
  repo,
67
106
  stateDir,
68
107
  );
108
+ indexMemoryWrite(stateDir, id, op.memory.content);
69
109
  }
70
110
  } else {
71
111
  const match = findByContent(existing, op.content);
@@ -98,3 +98,61 @@ test("recallMemories: fresher reference beats older at equal similarity", async
98
98
  test("cleanup memrec", () => {
99
99
  rmSync(baseTmp, { recursive: true, force: true });
100
100
  });
101
+
102
+ // ---- S24: cross-repo memory recall (PGlite mirror) ---------------------------
103
+ test("recallMemoriesAndInline: surfaces a memory saved in ANOTHER repo via cross-repo index", async () => {
104
+ // Isolate the global PGlite index to a temp dir shared by both "repos".
105
+ process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "xrepo-index");
106
+ const repoA = join(baseTmp, "repo-a");
107
+ const repoB = join(baseTmp, "repo-b");
108
+ try {
109
+ // repoA owns a decision about the store backend.
110
+ const { applyMemoryOps } = await import("./memoryOps.js");
111
+ await applyMemoryOps(
112
+ [{ op: "add", memory: { content: "we standardized on node:sqlite for the store backend", category: "decision", sourceTurn: 0 } }],
113
+ repoA,
114
+ );
115
+ // repoB is a fresh session with NO local memory about the store backend.
116
+ const { recallMemoriesAndInline } = await import("./recall.js");
117
+ const res = await recallMemoriesAndInline({
118
+ query: "what store backend do we use?",
119
+ stateDir: repoB,
120
+ limit: 5,
121
+ crossRepo: true,
122
+ crossRepoCosine: 0.3,
123
+ });
124
+ assert.ok(!res.empty, "cross-repo recall found the other repo's memory");
125
+ assert.ok(/node:sqlite/.test(res.block), "the node:sqlite decision was recalled from repo A");
126
+ assert.ok(res.report.some((r) => /from /.test(r)), "report labels the memory as cross-repo");
127
+ } finally {
128
+ const { closeMemoryIndex } = await import("./store/memoryIndex.js");
129
+ await closeMemoryIndex();
130
+ delete process.env.MEGACOMPACT_INDEX_DIR;
131
+ }
132
+ });
133
+
134
+ test("recallMemoriesAndInline: cross-repo disabled when MEGACOMPACT_PGLITE_DISABLED", async () => {
135
+ process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "xrepo-index-off");
136
+ process.env.MEGACOMPACT_PGLITE_DISABLED = "true";
137
+ const repoA = join(baseTmp, "repo-a2");
138
+ const repoB = join(baseTmp, "repo-b2");
139
+ try {
140
+ const { applyMemoryOps } = await import("./memoryOps.js");
141
+ await applyMemoryOps(
142
+ [{ op: "add", memory: { content: "we standardized on node:sqlite for the store backend", category: "decision", sourceTurn: 0 } }],
143
+ repoA,
144
+ );
145
+ const { recallMemoriesAndInline } = await import("./recall.js");
146
+ const res = await recallMemoriesAndInline({
147
+ query: "what store backend do we use?",
148
+ stateDir: repoB,
149
+ limit: 5,
150
+ crossRepo: true,
151
+ });
152
+ // Index disabled → no cross-repo hit; repoB has no local memory → empty.
153
+ assert.equal(res.empty, true, "cross-repo recall degrades to empty when disabled");
154
+ } finally {
155
+ delete process.env.MEGACOMPACT_PGLITE_DISABLED;
156
+ delete process.env.MEGACOMPACT_INDEX_DIR;
157
+ }
158
+ });
@@ -81,3 +81,55 @@ export async function recallMemories(
81
81
  }
82
82
  return top;
83
83
  }
84
+
85
+ /**
86
+ * Cross-repo memory recall (S24): augments the same-repo `recallMemories` with
87
+ * HNSW NN over the global PGlite `memory_index` (other repos' memories). Content
88
+ * is read inline from the index hit (the recall process can't open other repos'
89
+ * SQLite dirs), so no other-repo db access is required. Returns hits sorted by
90
+ * descending cosine, above `crossRepoCosine`. De-duped by content against
91
+ * `sameRepoContent` so we never surface a memory the same-repo scan already has.
92
+ * Non-fatal: any index failure returns []. Best-effort + PREVENT-PI-004 (local
93
+ * WASM only).
94
+ */
95
+ export async function recallMemoriesCrossRepo(
96
+ query: string,
97
+ stateDir: string,
98
+ opts: RecallMemoriesOptions & { crossRepoCosine?: number; limit?: number } = {},
99
+ ): Promise<Array<{ memory: MemoryRecord; score: number; repoId: string }>> {
100
+ const embedder = opts.embedder ?? defaultEmbedder();
101
+ const queryVec = embedder.embed(query);
102
+ const { searchMemoriesAsync } = await import("./store/memoryIndex.js");
103
+ const k = opts.limit ?? 5;
104
+ const floor = opts.crossRepoCosine ?? 0.3;
105
+ const hits = await searchMemoriesAsync(queryVec, { k });
106
+ if (!hits.length) return [];
107
+ // Mark same-repo content as already-covered so we don't duplicate it.
108
+ const sameRepo = new Set(
109
+ listMemories(opts.repo ?? null, 1000, stateDir).map((m) => m.content.trim().toLowerCase()),
110
+ );
111
+ const out: Array<{ memory: MemoryRecord; score: number; repoId: string }> = [];
112
+ for (const h of hits) {
113
+ if (h.score < floor) continue;
114
+ if (sameRepo.has(h.content.trim().toLowerCase())) continue;
115
+ out.push({
116
+ memory: {
117
+ id: h.memoryId,
118
+ repo: h.repoId,
119
+ kind: "note",
120
+ content: h.content,
121
+ tags: [],
122
+ createdAt: 0,
123
+ lastRecalledAt: null,
124
+ category: null,
125
+ target: null,
126
+ lastReferenced: null,
127
+ sourceTurn: null,
128
+ } as MemoryRecord,
129
+ score: h.score,
130
+ repoId: h.repoId,
131
+ });
132
+ }
133
+ out.sort((a, b) => b.score - a.score);
134
+ return out;
135
+ }
package/src/recall.ts CHANGED
@@ -159,6 +159,10 @@ export interface MemoryRecallInjectOptions {
159
159
  recallMaxTokens?: number;
160
160
  /** Cosine threshold; default 0.2. */
161
161
  minSimilarity?: number;
162
+ /** When true, augment same-repo recall with cross-repo PGlite NN (S24). */
163
+ crossRepo?: boolean;
164
+ /** Stricter cosine floor for cross-repo memory hits (S24). Default 0.3. */
165
+ crossRepoCosine?: number;
162
166
  }
163
167
 
164
168
  /** Format one memory hit for the recall block. Category + score for traceability. */
@@ -184,28 +188,49 @@ export async function recallMemoriesAndInline(
184
188
  ): Promise<{ empty: boolean; block: string; report: string[] }> {
185
189
  const limit = opts.limit ?? 5;
186
190
  const maxTokens = opts.recallMaxTokens ?? 0;
187
- const { recallMemories } = await import("./memoryRecall.js");
191
+ const { recallMemories, recallMemoriesCrossRepo } = await import("./memoryRecall.js");
188
192
  const hits = await recallMemories(opts.query, opts.stateDir, {
189
193
  topK: limit,
190
194
  minSimilarity: opts.minSimilarity ?? 0.2,
191
195
  });
192
- if (hits.length === 0) return { empty: true, block: "", report: [] };
196
+
197
+ // S24 cross-repo augmentation: if same-repo recall is thin, pull additional
198
+ // memories from OTHER repos via the PGlite HNSW index. Non-fatal: a failure
199
+ // degrades to the same-repo hits only.
200
+ const crossHits: Array<{ memory: any; score: number; repoId: string }> = [];
201
+ if (opts.crossRepo && hits.length < limit) {
202
+ try {
203
+ const x = await recallMemoriesCrossRepo(opts.query, opts.stateDir, {
204
+ repo: null,
205
+ limit: limit - hits.length,
206
+ crossRepoCosine: opts.crossRepoCosine ?? 0.3,
207
+ });
208
+ for (const h of x) crossHits.push(h);
209
+ } catch {
210
+ /* non-fatal — cross-repo failure → same-repo only */
211
+ }
212
+ }
213
+ if (hits.length === 0 && crossHits.length === 0) return { empty: true, block: "", report: [] };
193
214
 
194
215
  // Same incremental token cap pattern as checkpoint recall.
195
216
  const parts: string[] = [];
196
217
  const report: string[] = [];
197
218
  let blockTokens = 0;
198
- for (const h of hits) {
199
- const part = formatMemoryRecallBlock([
200
- { content: h.memory.content, category: h.memory.category, score: h.score },
201
- ]);
219
+ const pushHit = (content: string, category: string | null, score: number, label: string) => {
220
+ const part = formatMemoryRecallBlock([{ content, category, score }]);
202
221
  const partTokens = estimateBlockTokens(part);
203
- if (maxTokens > 0 && blockTokens + partTokens > maxTokens) break;
222
+ if (maxTokens > 0 && blockTokens + partTokens > maxTokens) return false;
204
223
  parts.push(part);
205
- report.push(
206
- ` • memory#${h.memory.id} (${(h.score * 100).toFixed(0)}%): ${h.memory.content.slice(0, 60).replace(/\n/g, " ")}…`,
207
- );
224
+ report.push(` • ${label} (${(score * 100).toFixed(0)}%): ${content.slice(0, 60).replace(/\n/g, " ")}…`);
208
225
  blockTokens += partTokens;
226
+ return true;
227
+ };
228
+ for (const h of hits) {
229
+ if (!pushHit(h.memory.content, h.memory.category, h.score, `memory#${h.memory.id}`)) break;
230
+ }
231
+ for (const h of crossHits) {
232
+ const repoLabel = h.repoId.split(/[\\/]/).filter(Boolean).pop() ?? h.repoId;
233
+ if (!pushHit(h.memory.content, h.memory.category, h.score, `memory#${h.memory.id} (from ${repoLabel})`)) break;
209
234
  }
210
235
  return { empty: parts.length === 0, block: parts.join("\n"), report };
211
236
  }
@@ -0,0 +1,61 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { defaultEmbedder } from "../embedder.js";
7
+ import {
8
+ upsertMemoryEmbedding,
9
+ searchMemoriesAsync,
10
+ initMemoryIndex,
11
+ closeMemoryIndex,
12
+ isMemoryIndexDisabled,
13
+ } from "./memoryIndex.js";
14
+
15
+ const baseTmp = mkdtempSync(join(tmpdir(), "mc-memidx-"));
16
+
17
+ test("memoryIndex: disabled when MEGACOMPACT_PGLITE_DISABLED", async () => {
18
+ process.env.MEGACOMPACT_PGLITE_DISABLED = "true";
19
+ try {
20
+ assert.equal(isMemoryIndexDisabled(), true, "kill-switch honored");
21
+ const hits = await searchMemoriesAsync(defaultEmbedder().embed("anything"), { k: 3 });
22
+ assert.deepEqual(hits, [], "search returns [] when disabled");
23
+ } finally {
24
+ delete process.env.MEGACOMPACT_PGLITE_DISABLED;
25
+ }
26
+ });
27
+
28
+ test("memoryIndex: cross-repo upsert + NN search returns the right repo's memory", async () => {
29
+ // Isolate the global PGlite dir so concurrent test runs don't collide.
30
+ process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "index");
31
+ const repoA = "/tmp/repo-a";
32
+ const repoB = "/tmp/repo-b";
33
+ try {
34
+ await initMemoryIndex();
35
+ // Two memories in different repos, with clearly distinct content so their
36
+ // trigram embeddings separate.
37
+ const vecA = defaultEmbedder().embed("We standardized on node:sqlite for the store backend");
38
+ const vecB = defaultEmbedder().embed("The deployment target is a raspberry pi in the closet");
39
+ await upsertMemoryEmbedding(repoA, 1, "We standardized on node:sqlite for the store backend", vecA);
40
+ await upsertMemoryEmbedding(repoB, 7, "The deployment target is a raspberry pi in the closet", vecB);
41
+
42
+ // Query close to A's content → top hit should be A's memory, not B's.
43
+ const q = defaultEmbedder().embed("standardized node:sqlite store backend choice");
44
+ const hits = await searchMemoriesAsync(q, { k: 3 });
45
+ assert.ok(hits.length >= 1, "at least one hit");
46
+ assert.equal(hits[0].repoId, repoA, "nearest neighbor is repo A");
47
+ assert.equal(hits[0].memoryId, 1, "correct memory id");
48
+ assert.ok(hits[0].score > 0.5, "high cosine for the matching memory");
49
+
50
+ // Scope to repoB only → A must not appear.
51
+ const scoped = await searchMemoriesAsync(q, { k: 3, repoId: repoB });
52
+ assert.ok(scoped.every((h) => h.repoId === repoB), "scoped search stays within repoB");
53
+ } finally {
54
+ await closeMemoryIndex();
55
+ delete process.env.MEGACOMPACT_INDEX_DIR;
56
+ }
57
+ });
58
+
59
+ test("memoryIndex: cleanup", () => {
60
+ rmSync(baseTmp, { recursive: true, force: true });
61
+ });