pi-mega-compact 0.4.28 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +47 -2
  2. package/dist/extensions/dashboard-server.js +58 -2
  3. package/dist/extensions/dashboard-server.test.js +95 -3
  4. package/dist/extensions/mega-commands.js +25 -9
  5. package/dist/extensions/mega-compact.test.js +133 -31
  6. package/dist/extensions/mega-config.js +5 -0
  7. package/dist/extensions/mega-conflict-cmds.js +79 -0
  8. package/dist/extensions/mega-dashboard-cmds.js +6 -4
  9. package/dist/extensions/mega-events.js +144 -27
  10. package/dist/extensions/mega-pipeline.js +84 -1
  11. package/dist/extensions/mega-runtime.js +14 -0
  12. package/dist/extensions/mega-trim.js +48 -0
  13. package/dist/extensions/mega-trim.test.js +58 -0
  14. package/dist/src/config/dedup.js +1 -0
  15. package/dist/src/driftDetection.js +103 -0
  16. package/dist/src/driftDetection.test.js +87 -0
  17. package/dist/src/memory.js +147 -0
  18. package/dist/src/memory.test.js +41 -0
  19. package/dist/src/memoryConsolidate.test.js +38 -0
  20. package/dist/src/memoryOps.js +58 -0
  21. package/dist/src/memoryOps.test.js +41 -0
  22. package/dist/src/memoryRecall.js +60 -0
  23. package/dist/src/memoryRecall.test.js +92 -0
  24. package/dist/src/recall.js +70 -1
  25. package/dist/src/recall.test.js +69 -1
  26. package/dist/src/store/sqlite.js +127 -11
  27. package/dist/src/vectorStore.js +6 -1
  28. package/extensions/dashboard-server.test.ts +115 -3
  29. package/extensions/dashboard-server.ts +63 -2
  30. package/extensions/mega-commands.ts +24 -9
  31. package/extensions/mega-compact.test.ts +134 -31
  32. package/extensions/mega-config.ts +22 -0
  33. package/extensions/mega-conflict-cmds.ts +81 -0
  34. package/extensions/mega-dashboard-cmds.ts +6 -4
  35. package/extensions/mega-events.ts +139 -28
  36. package/extensions/mega-pipeline.ts +94 -1
  37. package/extensions/mega-runtime.ts +15 -0
  38. package/extensions/mega-trim.test.ts +64 -0
  39. package/extensions/mega-trim.ts +75 -0
  40. package/extensions/openclaw-mega-compact.ts +24 -9
  41. package/package.json +2 -2
  42. package/src/config/dedup.ts +2 -0
  43. package/src/driftDetection.test.ts +100 -0
  44. package/src/driftDetection.ts +136 -0
  45. package/src/memory.test.ts +46 -0
  46. package/src/memory.ts +164 -0
  47. package/src/memoryConsolidate.test.ts +47 -0
  48. package/src/memoryOps.test.ts +53 -0
  49. package/src/memoryOps.ts +75 -0
  50. package/src/memoryRecall.test.ts +100 -0
  51. package/src/memoryRecall.ts +83 -0
  52. package/src/recall.test.ts +77 -1
  53. package/src/recall.ts +94 -1
  54. package/src/store/sqlite.ts +188 -11
  55. package/src/store.ts +3 -0
  56. package/src/vectorStore.ts +10 -1
  57. package/dist/extensions/openclaw-mega-compact.js +0 -291
  58. package/dist/src/minilm.js +0 -92
  59. package/dist/src/wordpiece.js +0 -129
@@ -0,0 +1,100 @@
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 { recallMemories } from "./memoryRecall.js";
7
+ import { addMemory, getMemory } from "./store/sqlite.js";
8
+
9
+ const baseTmp = mkdtempSync(join(tmpdir(), "mc-memrec-"));
10
+
11
+ function biGramEmbedder() {
12
+ // Deterministic test embedder — bigger dim + binary encoding so two semantically
13
+ // related strings have higher cosine than unrelated ones.
14
+ const dim = 64;
15
+ return {
16
+ dim,
17
+ embed(text: string): number[] {
18
+ const v = new Array(dim).fill(0);
19
+ const norm = text.toLowerCase().replace(/[^a-z0-9 ]/g, "").trim();
20
+ for (let i = 0; i < norm.length - 1; i++) {
21
+ const idx = ((norm.charCodeAt(i) * 31 + norm.charCodeAt(i + 1)) >>> 0) % dim;
22
+ v[idx] = 1;
23
+ }
24
+ return v;
25
+ },
26
+ };
27
+ }
28
+
29
+ test("recallMemories: ranks relevant memory above unrelated", async () => {
30
+ const dir = join(baseTmp, "rank");
31
+ addMemory({ content: "we use sqlite for the durable store", category: "decision" }, null, dir);
32
+ addMemory({ content: "the threshold is 100 thousand tokens", category: "decision" }, null, dir);
33
+ addMemory({ content: "katz says hi", category: "note" }, null, dir);
34
+ const hits = await recallMemories("what store do we use?", dir, {
35
+ embedder: biGramEmbedder(),
36
+ topK: 3,
37
+ minSimilarity: 0.0,
38
+ });
39
+ assert.ok(hits.length >= 2, "finds relevant");
40
+ assert.ok(/sqlite/.test(hits[0].memory.content), "top hit is sqlite one");
41
+ assert.ok(!/katz/.test(hits[0].memory.content), "unrelated not on top");
42
+ });
43
+
44
+ test("recallMemories: marks referenced hits (last_referenced updated)", async () => {
45
+ const dir = join(baseTmp, "ref");
46
+ addMemory({ content: "policy is local-only", category: "rule" }, null, dir);
47
+ const hits = await recallMemories("local-only policy", dir, { embedder: biGramEmbedder() });
48
+ assert.ok(hits.length >= 1);
49
+ const fresh = getMemory(hits[0].memory.id, dir);
50
+ assert.ok(fresh && fresh.lastReferenced && fresh.lastReferenced > 0, "lastReferenced set");
51
+ });
52
+
53
+ test("recallMemories: empty store returns []", async () => {
54
+ const dir = join(baseTmp, "empty");
55
+ const hits = await recallMemories("anything", dir, { embedder: biGramEmbedder() });
56
+ assert.deepEqual(hits, []);
57
+ });
58
+
59
+ test("recallMemories: decision category beats fact category at equal similarity", async () => {
60
+ const dir = join(baseTmp, "category");
61
+ // Two memories that share many bigrams with the query — both will have very
62
+ // similar cosine. The decision category should win because of categoryWeight.
63
+ addMemory({ content: "we use redis for cache" }, null, dir);
64
+ const aId = addMemory({ content: "we use redis as primary cache key", category: "fact" }, null, dir);
65
+ const dId = addMemory({ content: "we use redis as primary cache layer", category: "decision" }, null, dir);
66
+ const hits = await recallMemories("redis cache layer", dir, {
67
+ embedder: biGramEmbedder(),
68
+ topK: 3,
69
+ minSimilarity: 0.0,
70
+ });
71
+ assert.ok(hits.length >= 2);
72
+ assert.equal(hits[0].memory.id, dId, "decision-tagged memory outranks fact-tagged");
73
+ assert.notEqual(hits[0].memory.id, aId, "top is the decision row, not the unknown-category row");
74
+ });
75
+
76
+ test("recallMemories: fresher reference beats older at equal similarity", async () => {
77
+ const dir = join(baseTmp, "recency");
78
+ const aId = addMemory({ content: "we use redis for cache", category: "fact" }, null, dir);
79
+ const bId = addMemory({ content: "we use redis for cache layer", category: "fact" }, null, dir);
80
+ // Backdate aId so its last_referenced is older; bId stays fresh.
81
+ const { openStore, closeStore } = await import("./store/sqlite.js");
82
+ const db = openStore(dir);
83
+ const longAgo = Math.floor(Date.now() / 1000) - 30 * 86_400; // 30d
84
+ db.prepare("UPDATE memories SET last_referenced = ? WHERE id = ?").run(longAgo, aId);
85
+ // closeStore evicts the cached handle — calling db.close() directly would
86
+ // leave a stale closed DB in the openStore cache and break subsequent
87
+ // callers (the "database is not open" failure under parallel runs).
88
+ closeStore(dir);
89
+ const hits = await recallMemories("redis cache layer", dir, {
90
+ embedder: biGramEmbedder(),
91
+ topK: 3,
92
+ minSimilarity: 0.0,
93
+ });
94
+ assert.ok(hits.length >= 2);
95
+ assert.equal(hits[0].memory.id, bId, "freshly-referenced memory outranks 30-day-old one");
96
+ });
97
+
98
+ test("cleanup memrec", () => {
99
+ rmSync(baseTmp, { recursive: true, force: true });
100
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * memoryRecall.ts — semantic recall over the durable memories table (S21).
3
+ * Embeds a query with the same local embedder used by RAPTOR, ranks every
4
+ * memory in the current repo's SQLite by cosine similarity combined with a
5
+ * category-weighted + recency-boosted score, and returns the top-k. Side
6
+ * effect: marks returned memories as referenced (last_referenced) so drift
7
+ * can be measured. PREVENT-PI-004 — embedder is the same local one used
8
+ * everywhere else; no remote calls are introduced here.
9
+ * @module
10
+ */
11
+ import { defaultEmbedder, cosineSimilarity } from "./embedder.js";
12
+ import { listMemories, referenceMemory, type MemoryRecord } from "./store/sqlite.js";
13
+
14
+ export interface RecallMemoriesOptions {
15
+ /** Max memories to return. Default 10. */
16
+ topK?: number;
17
+ /** Min cosine similarity (0..1) to include. Default 0.2 — filters unrelated. */
18
+ minSimilarity?: number;
19
+ /** If true, mark returned memories as referenced. Default true. */
20
+ markReferenced?: boolean;
21
+ /** Override the embedder (test-only seam). */
22
+ embedder?: { embed(text: string): number[] };
23
+ /** Repo filter; null = current repo's memories. */
24
+ repo?: string | null;
25
+ /** Per-category ranking weights. Categories not listed default to 1.0. */
26
+ categoryWeights?: Record<string, number>;
27
+ /** Recency boost strength. Default 0.05 (5% bonus per log-day since reference). */
28
+ recencyWeight?: number;
29
+ }
30
+
31
+ /** Default category weights — `decision` wins ties on tied cosine. */
32
+ export const DEFAULT_CATEGORY_WEIGHTS: Record<string, number> = {
33
+ decision: 1.10,
34
+ preference: 1.05,
35
+ fact: 1.0,
36
+ };
37
+
38
+ const DEFAULT_RECENCY_WEIGHT = 0.05;
39
+
40
+ /**
41
+ * Rank memories by a blended score of cosine similarity, category weight, and
42
+ * recency of last_referenced (with createdAt as a fallback). Returns the
43
+ * top-k above minSimilarity, sorted by blended score descending. Empty on
44
+ * no matches.
45
+ */
46
+ export async function recallMemories(
47
+ query: string,
48
+ stateDir: string,
49
+ opts: RecallMemoriesOptions = {},
50
+ ): Promise<Array<{ memory: MemoryRecord; score: number }>> {
51
+ const topK = opts.topK ?? 10;
52
+ const minSimilarity = opts.minSimilarity ?? 0.2;
53
+ const markReferenced = opts.markReferenced ?? true;
54
+ const embedder = opts.embedder ?? defaultEmbedder();
55
+ const repo = opts.repo === undefined ? null : opts.repo;
56
+ const categoryWeights = { ...DEFAULT_CATEGORY_WEIGHTS, ...(opts.categoryWeights ?? {}) };
57
+ const recencyWeight = opts.recencyWeight ?? DEFAULT_RECENCY_WEIGHT;
58
+
59
+ const queryVec = embedder.embed(query);
60
+ const memories = listMemories(repo, 1000, stateDir);
61
+ if (!memories.length || !query.trim()) return [];
62
+
63
+ const nowSec = Math.floor(Date.now() / 1000);
64
+ const scored: Array<{ memory: MemoryRecord; score: number }> = [];
65
+ for (const mem of memories) {
66
+ const vec = embedder.embed(mem.content);
67
+ const sim = cosineSimilarity(queryVec, vec);
68
+ if (sim < minSimilarity) continue;
69
+ const categoryW = categoryWeights[mem.category ?? "fact"] ?? 1.0;
70
+ const lastTouch = mem.lastReferenced ?? mem.createdAt ?? nowSec;
71
+ // log(1 + daysSince) grows slowly — half-life-style decay.
72
+ const daysSince = Math.max(0, (nowSec - lastTouch) / 86_400);
73
+ const recencyBoost = Math.log1p(daysSince) * recencyWeight;
74
+ const finalScore = sim * categoryW * (1 + recencyBoost);
75
+ scored.push({ memory: mem, score: finalScore });
76
+ }
77
+ scored.sort((a, b) => b.score - a.score);
78
+ const top = scored.slice(0, topK);
79
+ if (markReferenced) {
80
+ for (const hit of top) referenceMemory(hit.memory.id, stateDir);
81
+ }
82
+ return top;
83
+ }
@@ -5,7 +5,8 @@ import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { VectorStore } from "./vectorStore.js";
7
7
  import { compactSession } from "./engine.js";
8
- import { recallAndInline, formatRecallBlock } from "./recall.js";
8
+ import { recallAndInline, recallAndInlineAsync, formatRecallBlock } from "./recall.js";
9
+ import { markInjectedGlobal, wasInjectedGlobal, closeIndexStore } from "./store/sqlite.js";
9
10
  import type { EngineMessage } from "./types.js";
10
11
 
11
12
  const baseTmp = mkdtempSync(join(tmpdir(), "mc-recall-"));
@@ -47,6 +48,26 @@ test("formatRecallBlock is empty for no hits", () => {
47
48
  assert.equal(formatRecallBlock([]), "");
48
49
  });
49
50
 
51
+ test("formatRecallBlock (S17): labels a cross-repo hit with its source repo", () => {
52
+ const hit = {
53
+ checkpoint: { checkpointId: "chkpt_x", summary: "did thing Y", filesModified: ["a.ts"] },
54
+ score: 0.91,
55
+ repoId: "/home/u/rad-gateway",
56
+ } as any;
57
+ const block = formatRecallBlock([hit]);
58
+ assert.ok(block.includes("from repo"), "labels cross-repo source");
59
+ assert.ok(block.includes("rad-gateway"), "includes the repo display name");
60
+ });
61
+
62
+ test("formatRecallBlock (S17): omits the label for same-repo hits (no repoId)", () => {
63
+ const hit = {
64
+ checkpoint: { checkpointId: "c1", summary: "s", filesModified: [] },
65
+ score: 0.9,
66
+ } as any;
67
+ const block = formatRecallBlock([hit]);
68
+ assert.ok(!block.includes("from repo"), "no source label for same-repo hits");
69
+ });
70
+
50
71
  test("recallAndInline empty when store has nothing for query", () => {
51
72
  const s = store();
52
73
  const r = recallAndInline({ sessionId: SESS, query: "no such topic exists here", limit: 5, source: "command" }, s as any);
@@ -98,6 +119,61 @@ test("Fix C: inline dedupe drops a hit already resident in the live window", ()
98
119
  );
99
120
  });
100
121
 
122
+ test("S18: global injected-set skips a foreign checkpoint already injected machine-wide", async () => {
123
+ const indexDir = mkdtempSync(join(tmpdir(), "mc-gi-"));
124
+ try {
125
+ const sess = "sess_cross";
126
+ // A foreign checkpoint already marked injected globally (in this session).
127
+ markInjectedGlobal("chkpt_foreign", "/repo/other", sess, indexDir);
128
+ assert.equal(wasInjectedGlobal("chkpt_foreign", sess, indexDir), true);
129
+ // searchAsync returns the foreign hit; recallAndInlineAsync must skip it
130
+ // (globally injected) → toInject is empty.
131
+ const mockStore = {
132
+ searchAsync: async () => [{
133
+ checkpoint: { checkpointId: "chkpt_foreign", summary: "foreign work", filesModified: [], dedupStatus: "active" },
134
+ score: 0.92,
135
+ repoId: "/repo/other",
136
+ }],
137
+ wasInjected: () => false,
138
+ markInjected: () => {},
139
+ } as any;
140
+ const r = await recallAndInlineAsync(
141
+ { sessionId: sess, query: "foreign", limit: 3, source: "command", crossRepo: true, globalIndexDir: indexDir },
142
+ mockStore,
143
+ );
144
+ assert.equal(r.toInject.length, 0, "globally-injected foreign checkpoint skipped");
145
+ } finally {
146
+ closeIndexStore();
147
+ rmSync(indexDir, { recursive: true, force: true });
148
+ }
149
+ });
150
+
151
+ test("S18: a fresh foreign checkpoint is injected AND recorded globally", async () => {
152
+ const indexDir = mkdtempSync(join(tmpdir(), "mc-gi2-"));
153
+ try {
154
+ const sess = "sess_fresh";
155
+ assert.equal(wasInjectedGlobal("chkpt_new", sess, indexDir), false);
156
+ const mockStore = {
157
+ searchAsync: async () => [{
158
+ checkpoint: { checkpointId: "chkpt_new", summary: "brand new foreign work", filesModified: [], dedupStatus: "active" },
159
+ score: 0.93,
160
+ repoId: "/repo/alpha",
161
+ }],
162
+ wasInjected: () => false,
163
+ markInjected: () => {},
164
+ } as any;
165
+ const r = await recallAndInlineAsync(
166
+ { sessionId: sess, query: "foreign", limit: 3, source: "command", crossRepo: true, globalIndexDir: indexDir },
167
+ mockStore,
168
+ );
169
+ assert.equal(r.toInject.length, 1, "fresh foreign checkpoint injected");
170
+ assert.equal(wasInjectedGlobal("chkpt_new", sess, indexDir), true, "recorded machine-wide");
171
+ } finally {
172
+ closeIndexStore();
173
+ rmSync(indexDir, { recursive: true, force: true });
174
+ }
175
+ });
176
+
101
177
  test("cleanup", () => {
102
178
  rmSync(baseTmp, { recursive: true, force: true });
103
179
  });
package/src/recall.ts CHANGED
@@ -38,6 +38,10 @@ export interface RecallInjectOptions {
38
38
  liveWindow?: string[];
39
39
  /** Similarity threshold for inline dedupe (defaults to 0.9). */
40
40
  dedupSim?: number;
41
+ /** S18: index dir of the machine-wide injected-set. When set on a cross-repo
42
+ * recall, a foreign checkpoint already injected (in any session) is skipped
43
+ * and a fresh injection is recorded globally. */
44
+ globalIndexDir?: string;
41
45
  }
42
46
 
43
47
  export interface RecallInjectResult {
@@ -56,8 +60,12 @@ export function formatRecallBlock(hits: SearchHit[]): string {
56
60
  if (hits.length === 0) return "";
57
61
  const parts = hits.map((h, i) => {
58
62
  const score = (h.score * 100).toFixed(0);
63
+ // S17: label a cross-repo hit with its source repo (the repoId doubles as
64
+ // that repo's stateDir, so the last path segment is the repo's display
65
+ // name). Same-repo hits (no repoId) stay unlabeled.
66
+ const repoName = h.repoId ? ` (from repo ${h.repoId.split("/").filter(Boolean).pop() ?? h.repoId})` : "";
59
67
  return (
60
- `### Recalled context [${i + 1}] (relevance ${score}%)\n` +
68
+ `### Recalled context [${i + 1}] (relevance ${score}%)${repoName}\n` +
61
69
  `${h.checkpoint.summary.trim()}\n` +
62
70
  (h.checkpoint.filesModified.length
63
71
  ? `Key files: ${h.checkpoint.filesModified.join(", ")}.\n`
@@ -138,6 +146,70 @@ export function recallAndInline(
138
146
  };
139
147
  }
140
148
 
149
+ // --- S21: memory recall ----------------------------------------------------
150
+ // Durables (decisions, rules, user-saved facts) live in the `memories` table.
151
+ // We mirror the checkpoint recall path: rank by cosine, format a block, respect
152
+ // a token cap so it can never net-inflate the system prompt.
153
+
154
+ export interface MemoryRecallInjectOptions {
155
+ query: string;
156
+ stateDir: string;
157
+ limit?: number;
158
+ /** Token ceiling; defaults to the same `recallMaxTokens` used for checkpoints. */
159
+ recallMaxTokens?: number;
160
+ /** Cosine threshold; default 0.2. */
161
+ minSimilarity?: number;
162
+ }
163
+
164
+ /** Format one memory hit for the recall block. Category + score for traceability. */
165
+ export function formatMemoryRecallBlock(
166
+ hits: Array<{ content: string; category: string | null; score: number }>,
167
+ ): string {
168
+ if (hits.length === 0) return "";
169
+ const parts = hits.map((h, i) => {
170
+ const pct = (h.score * 100).toFixed(0);
171
+ const cat = h.category ? `[${h.category}] ` : "";
172
+ return `### Recalled memory [${i + 1}] (relevance ${pct}%)\n${cat}${h.content.trim()}`;
173
+ });
174
+ return (
175
+ "The following facts about this project were saved from earlier turns " +
176
+ "and are relevant to the current request. Treat them as established:\n\n" +
177
+ parts.join("\n")
178
+ );
179
+ }
180
+
181
+ /** Recall top-k durable memories, format into a token-capped block. */
182
+ export async function recallMemoriesAndInline(
183
+ opts: MemoryRecallInjectOptions,
184
+ ): Promise<{ empty: boolean; block: string; report: string[] }> {
185
+ const limit = opts.limit ?? 5;
186
+ const maxTokens = opts.recallMaxTokens ?? 0;
187
+ const { recallMemories } = await import("./memoryRecall.js");
188
+ const hits = await recallMemories(opts.query, opts.stateDir, {
189
+ topK: limit,
190
+ minSimilarity: opts.minSimilarity ?? 0.2,
191
+ });
192
+ if (hits.length === 0) return { empty: true, block: "", report: [] };
193
+
194
+ // Same incremental token cap pattern as checkpoint recall.
195
+ const parts: string[] = [];
196
+ const report: string[] = [];
197
+ 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
+ ]);
202
+ const partTokens = estimateBlockTokens(part);
203
+ if (maxTokens > 0 && blockTokens + partTokens > maxTokens) break;
204
+ 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
+ );
208
+ blockTokens += partTokens;
209
+ }
210
+ return { empty: parts.length === 0, block: parts.join("\n"), report };
211
+ }
212
+
141
213
  /**
142
214
  * Slice 2 async cross-repo recall. Same dedup/bound/inline contract as
143
215
  * `recallAndInline`, but backed by `VectorStore.searchAsync` so it can recall
@@ -180,6 +252,17 @@ export async function recallAndInlineAsync(
180
252
 
181
253
  for (const h of hits) {
182
254
  if (skip && store.wasInjected(opts.sessionId, h.checkpoint.checkpointId)) continue;
255
+ // S18: machine-wide injected-set — a foreign checkpoint already injected
256
+ // (in any session) is never re-injected. Only applies to cross-repo hits
257
+ // (same-repo hits have no repoId and are handled by the per-session set).
258
+ if (opts.globalIndexDir && h.repoId) {
259
+ try {
260
+ const { wasInjectedGlobal } = await import("./store/sqlite.js");
261
+ if (wasInjectedGlobal(h.checkpoint.checkpointId, opts.sessionId, opts.globalIndexDir)) continue;
262
+ } catch {
263
+ /* non-fatal: degrade to per-session injected-set only */
264
+ }
265
+ }
183
266
  if (doWindowDedupe && liveEmbeddings.length > 0) {
184
267
  const hitVec = defaultEmbedder().embed(h.checkpoint.summary);
185
268
  if (liveEmbeddings.some((v) => cosineSimilarity(v, hitVec) >= dedupSim)) continue;
@@ -191,6 +274,16 @@ export async function recallAndInlineAsync(
191
274
  toInject.push(h);
192
275
  blockTokens += partTokens;
193
276
  store.markInjected(opts.sessionId, h.checkpoint.checkpointId);
277
+ // S18: record the cross-repo injection machine-wide so it's not re-injected
278
+ // by a later recall (same or different session).
279
+ if (opts.globalIndexDir && h.repoId) {
280
+ try {
281
+ const { markInjectedGlobal } = await import("./store/sqlite.js");
282
+ markInjectedGlobal(h.checkpoint.checkpointId, h.repoId, opts.sessionId, opts.globalIndexDir);
283
+ } catch {
284
+ /* non-fatal */
285
+ }
286
+ }
194
287
  }
195
288
 
196
289
  const block = parts.join("\n");