pi-mega-compact 0.4.28 → 0.5.1

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 (56) hide show
  1. package/README.md +47 -2
  2. package/dist/extensions/dashboard-server.js +66 -3
  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 +35 -2
  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 +69 -4
  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 +35 -2
  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
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");
@@ -57,7 +57,17 @@ const cache = new Map<string, DatabaseSync>();
57
57
  /** Open (or reuse) the SQLite store for a state dir. */
58
58
  export function openStore(stateDir: string = getStateDir()): DatabaseSync {
59
59
  const existing = cache.get(stateDir);
60
- if (existing) return existing;
60
+ if (existing) {
61
+ // A closed handle in the cache (e.g. a test calling db.close() directly
62
+ // instead of closeStore) would surface as "database is not open" on the
63
+ // next reuse. Detect and evict so callers never see a dead handle.
64
+ try {
65
+ existing.prepare("SELECT 1");
66
+ return existing;
67
+ } catch {
68
+ cache.delete(stateDir);
69
+ }
70
+ }
61
71
 
62
72
  if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true });
63
73
  const db = new DatabaseSync(join(stateDir, "sqlite.db"));
@@ -121,6 +131,19 @@ export function openIndexStore(indexDir: string = getIndexDir()): DatabaseSync {
121
131
  model_captured_at INTEGER
122
132
  );
123
133
  CREATE INDEX IF NOT EXISTS idx_registry_last_seen ON repo_registry(last_seen DESC);
134
+ -- S18: machine-wide injected-set. A foreign checkpoint injected in repo A is
135
+ -- recorded here so repo B's recall never re-injects it. Keyed by checkpoint
136
+ -- + session (a checkpoint may be injected once per session); repo_id is the
137
+ -- source repo (the foreign repo's stateDir) for tracking/source labels.
138
+ -- PRAMETERIZED queries (PREVENT-002); local node:sqlite (PREVENT-PI-004).
139
+ CREATE TABLE IF NOT EXISTS injected_global (
140
+ checkpoint_id TEXT NOT NULL,
141
+ repo_id TEXT NOT NULL,
142
+ session_id TEXT NOT NULL,
143
+ injected_at INTEGER NOT NULL,
144
+ PRIMARY KEY (checkpoint_id, session_id)
145
+ );
146
+ CREATE INDEX IF NOT EXISTS idx_injected_global_cid ON injected_global(checkpoint_id);
124
147
  `);
125
148
  indexCache = iddb;
126
149
  indexCacheDir = indexDir;
@@ -160,6 +183,19 @@ export function upsertRepoRegistry(
160
183
  tokensSaved: number;
161
184
  compressedOriginalBytes: number;
162
185
  lastCompactedAt?: number | null;
186
+ // The fields below are optional passthroughs so test fixtures and the
187
+ // /api/repos active-window filter can seed them directly. They're also
188
+ // written by other paths (recordRepoModel, registry refresh) — passing
189
+ // them here is harmless because the ON CONFLICT clause keeps first_seen
190
+ // and the model columns from being clobbered.
191
+ firstSeen?: number;
192
+ lastSeen?: number;
193
+ provider?: string | null;
194
+ providerName?: string | null;
195
+ modelName?: string | null;
196
+ inputRate?: number | null;
197
+ outputRate?: number | null;
198
+ modelCapturedAt?: number | null;
163
199
  },
164
200
  indexDir: string = getIndexDir(),
165
201
  ): void {
@@ -168,26 +204,42 @@ export function upsertRepoRegistry(
168
204
  db.prepare(
169
205
  `INSERT INTO repo_registry
170
206
  (repo_root, display_name, state_dir, first_seen, last_seen, last_compacted_at,
171
- checkpoint_count, tokens_saved, compressed_original_bytes)
172
- VALUES (@repo_root, @display_name, @state_dir, @now, @now, @last_compacted_at,
173
- @checkpoint_count, @tokens_saved, @compressed_original_bytes)
207
+ checkpoint_count, tokens_saved, compressed_original_bytes,
208
+ provider, provider_name, model_name, input_rate, output_rate, model_captured_at)
209
+ VALUES (@repo_root, @display_name, @state_dir, @first_seen, @last_seen, @last_compacted_at,
210
+ @checkpoint_count, @tokens_saved, @compressed_original_bytes,
211
+ @provider, @provider_name, @model_name, @input_rate, @output_rate, @model_captured_at)
174
212
  ON CONFLICT(repo_root) DO UPDATE SET
175
213
  display_name = excluded.display_name,
176
214
  state_dir = excluded.state_dir,
177
- last_seen = excluded.last_seen,
215
+ last_seen = COALESCE(excluded.last_seen, @now),
178
216
  last_compacted_at = COALESCE(excluded.last_compacted_at, repo_registry.last_compacted_at),
179
217
  checkpoint_count = excluded.checkpoint_count,
180
218
  tokens_saved = excluded.tokens_saved,
181
- compressed_original_bytes = excluded.compressed_original_bytes`,
219
+ compressed_original_bytes = excluded.compressed_original_bytes,
220
+ provider = COALESCE(excluded.provider, repo_registry.provider),
221
+ provider_name = COALESCE(excluded.provider_name, repo_registry.provider_name),
222
+ model_name = COALESCE(excluded.model_name, repo_registry.model_name),
223
+ input_rate = COALESCE(excluded.input_rate, repo_registry.input_rate),
224
+ output_rate = COALESCE(excluded.output_rate, repo_registry.output_rate),
225
+ model_captured_at = COALESCE(excluded.model_captured_at, repo_registry.model_captured_at)`,
182
226
  ).run({
183
227
  repo_root: row.repoRoot,
184
228
  display_name: row.displayName,
185
229
  state_dir: row.stateDir,
186
230
  now,
231
+ first_seen: row.firstSeen ?? null,
232
+ last_seen: row.lastSeen ?? null,
187
233
  last_compacted_at: row.lastCompactedAt ?? null,
188
234
  checkpoint_count: row.checkpointCount,
189
235
  tokens_saved: row.tokensSaved,
190
236
  compressed_original_bytes: row.compressedOriginalBytes,
237
+ provider: row.provider ?? null,
238
+ provider_name: row.providerName ?? null,
239
+ model_name: row.modelName ?? null,
240
+ input_rate: row.inputRate ?? null,
241
+ output_rate: row.outputRate ?? null,
242
+ model_captured_at: row.modelCapturedAt ?? null,
191
243
  });
192
244
  }
193
245
 
@@ -281,6 +333,51 @@ export function closeIndexStore(): void {
281
333
  }
282
334
  }
283
335
 
336
+ // ---------------------------------------------------------------------------
337
+ // S18: machine-wide injected-set (cross-repo dedup markers)
338
+ //
339
+ // A foreign checkpoint injected in repo A is recorded here so repo B's recall
340
+ // never re-injects it (a stronger, machine-wide version of the per-session
341
+ // injected-set in the local store). Keyed by (checkpoint_id, session_id); the
342
+ // session_id here is the RECEIVING session, so the same foreign checkpoint can
343
+ // be injected into different sessions but never twice into the same one.
344
+ // PRAMETERIZED queries (PREVENT-002); local node:sqlite + WAL (PREVENT-PI-004),
345
+ // multi-process safe.
346
+ // ---------------------------------------------------------------------------
347
+
348
+ /** Record that a (foreign) checkpoint was injected into `sessionId`. Idempotent. */
349
+ export function markInjectedGlobal(
350
+ checkpointId: string,
351
+ repoId: string,
352
+ sessionId: string,
353
+ indexDir: string = getIndexDir(),
354
+ ): void {
355
+ const db = openIndexStore(indexDir);
356
+ db.prepare(
357
+ "INSERT OR IGNORE INTO injected_global (checkpoint_id, repo_id, session_id, injected_at) VALUES ($cid, $rid, $sid, $ts)",
358
+ ).run({ $cid: checkpointId, $rid: repoId, $sid: sessionId, $ts: Date.now() });
359
+ }
360
+
361
+ /** True when a checkpoint was already injected into `sessionId` (machine-wide). */
362
+ export function wasInjectedGlobal(
363
+ checkpointId: string,
364
+ sessionId: string,
365
+ indexDir: string = getIndexDir(),
366
+ ): boolean {
367
+ const db = openIndexStore(indexDir);
368
+ const row = db.prepare(
369
+ "SELECT 1 FROM injected_global WHERE checkpoint_id = $cid AND session_id = $sid LIMIT 1",
370
+ ).get({ $cid: checkpointId, $sid: sessionId }) as { "1": number } | undefined;
371
+ return row !== undefined;
372
+ }
373
+
374
+ /** Count of cross-repo injections recorded (for /mega-status stats). */
375
+ export function countInjectedGlobal(indexDir: string = getIndexDir()): number {
376
+ const db = openIndexStore(indexDir);
377
+ const row = db.prepare("SELECT COUNT(*) AS n FROM injected_global").get() as { n: number } | undefined;
378
+ return row?.n ?? 0;
379
+ }
380
+
284
381
  function initSchema(db: DatabaseSync): void {
285
382
  db.exec(`
286
383
  CREATE TABLE IF NOT EXISTS context_chunks (
@@ -430,7 +527,12 @@ function initSchema(db: DatabaseSync): void {
430
527
  content TEXT NOT NULL,
431
528
  tags TEXT, -- JSON array of strings
432
529
  created_at INTEGER,
433
- last_recalled_at INTEGER
530
+ last_recalled_at INTEGER,
531
+ -- S20 memory-RAG extension (auto-review add/replace/remove ops).
532
+ category TEXT, -- typed bucket, e.g. decision | fact | preference
533
+ target TEXT, -- optional subject/scope this memory targets
534
+ last_referenced INTEGER, -- last time memory was referenced by recall (epoch s)
535
+ source_turn INTEGER -- conversation turn that produced this memory
434
536
  );
435
537
  CREATE INDEX IF NOT EXISTS idx_memories_repo ON memories(repo);
436
538
 
@@ -447,6 +549,12 @@ function initSchema(db: DatabaseSync): void {
447
549
  // databases created by an older version — otherwise repoStats()/upsert crash
448
550
  // with "no such column" and the extension fails to load. Additive only.
449
551
  ensureColumn(db, "context_chunks", "original_token_estimate", "INTEGER");
552
+ // S20 memory-RAG extension: additive columns for auto-review ops. Idempotent —
553
+ // only alters DBs created by an older version that lack these columns.
554
+ ensureColumn(db, "memories", "category", "TEXT");
555
+ ensureColumn(db, "memories", "target", "TEXT");
556
+ ensureColumn(db, "memories", "last_referenced", "INTEGER");
557
+ ensureColumn(db, "memories", "source_turn", "INTEGER");
450
558
  const v = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get() as
451
559
  | { value: string }
452
560
  | undefined;
@@ -613,11 +721,15 @@ export interface MemoryRecord {
613
721
  tags: string[];
614
722
  createdAt: number;
615
723
  lastRecalledAt: number | null;
724
+ category: string | null;
725
+ target: string | null;
726
+ lastReferenced: number | null;
727
+ sourceTurn: number | null;
616
728
  }
617
729
 
618
730
  /** Save a memory to the current repo's store. Returns the new row id. */
619
731
  export function addMemory(
620
- memory: { kind?: string; content: string; tags?: string[] },
732
+ memory: { kind?: string; content: string; tags?: string[]; category?: string; target?: string; sourceTurn?: number },
621
733
  repo: string | null,
622
734
  stateDir: string = getStateDir(),
623
735
  ): number {
@@ -625,10 +737,19 @@ export function addMemory(
625
737
  const now = Math.floor(Date.now() / 1000);
626
738
  const res = db
627
739
  .prepare(
628
- `INSERT INTO memories(repo, kind, content, tags, created_at, last_recalled_at)
629
- VALUES(?, ?, ?, ?, ?, NULL)`,
740
+ `INSERT INTO memories(repo, kind, content, tags, created_at, last_recalled_at, category, target, source_turn)
741
+ VALUES(?, ?, ?, ?, ?, NULL, ?, ?, ?)`,
630
742
  )
631
- .run(repo ?? null, memory.kind ?? "note", memory.content, JSON.stringify(memory.tags ?? []), now);
743
+ .run(
744
+ repo ?? null,
745
+ memory.kind ?? "note",
746
+ memory.content,
747
+ JSON.stringify(memory.tags ?? []),
748
+ now,
749
+ memory.category ?? null,
750
+ memory.target ?? null,
751
+ memory.sourceTurn ?? null,
752
+ );
632
753
  return Number(res.lastInsertRowid);
633
754
  }
634
755
 
@@ -659,6 +780,58 @@ export function recallMemory(id: number, stateDir: string = getStateDir()): bool
659
780
  return res.changes > 0;
660
781
  }
661
782
 
783
+ /** Mark a memory as referenced (updates last_referenced). Returns true if found. */
784
+ export function referenceMemory(id: number, stateDir: string = getStateDir()): boolean {
785
+ const db = openStore(stateDir);
786
+ const now = Math.floor(Date.now() / 1000);
787
+ const res = db.prepare("UPDATE memories SET last_referenced = ? WHERE id = ?").run(now, id);
788
+ return res.changes > 0;
789
+ }
790
+
791
+ /** Replace a memory's mutable fields by id. Returns true if a row was updated. */
792
+ export function replaceMemory(
793
+ id: number,
794
+ patch: { kind?: string; content?: string; tags?: string[]; category?: string; target?: string; sourceTurn?: number },
795
+ stateDir: string = getStateDir(),
796
+ ): boolean {
797
+ const db = openStore(stateDir);
798
+ const res = db
799
+ .prepare(
800
+ `UPDATE memories
801
+ SET kind = COALESCE(?, kind),
802
+ content = COALESCE(?, content),
803
+ tags = COALESCE(?, tags),
804
+ category = COALESCE(?, category),
805
+ target = COALESCE(?, target),
806
+ source_turn = COALESCE(?, source_turn)
807
+ WHERE id = ?`,
808
+ )
809
+ .run(
810
+ patch.kind ?? null,
811
+ patch.content ?? null,
812
+ patch.tags ? JSON.stringify(patch.tags) : null,
813
+ "category" in patch ? (patch.category ?? null) : null,
814
+ "target" in patch ? (patch.target ?? null) : null,
815
+ "sourceTurn" in patch ? (patch.sourceTurn ?? null) : null,
816
+ id,
817
+ );
818
+ return res.changes > 0;
819
+ }
820
+
821
+ /** Remove a memory by id. Returns true if a row was deleted. */
822
+ export function removeMemory(id: number, stateDir: string = getStateDir()): boolean {
823
+ const db = openStore(stateDir);
824
+ const res = db.prepare("DELETE FROM memories WHERE id = ?").run(id);
825
+ return res.changes > 0;
826
+ }
827
+
828
+ /** Look up a single memory by id (or undefined). */
829
+ export function getMemory(id: number, stateDir: string = getStateDir()): MemoryRecord | undefined {
830
+ const db = openStore(stateDir);
831
+ const row = db.prepare("SELECT * FROM memories WHERE id = ?").get(id);
832
+ return row ? mapMemoryRow(row) : undefined;
833
+ }
834
+
662
835
  function mapMemoryRow(row: any): MemoryRecord {
663
836
  return {
664
837
  id: row.id,
@@ -668,6 +841,10 @@ function mapMemoryRow(row: any): MemoryRecord {
668
841
  tags: row.tags ? JSON.parse(row.tags) : [],
669
842
  createdAt: row.created_at ?? 0,
670
843
  lastRecalledAt: row.last_recalled_at ?? null,
844
+ category: row.category ?? null,
845
+ target: row.target ?? null,
846
+ lastReferenced: row.last_referenced ?? null,
847
+ sourceTurn: row.source_turn ?? null,
671
848
  };
672
849
  }
673
850
 
package/src/store.ts CHANGED
@@ -49,6 +49,9 @@ export function normalizeSessionId(sessionId: string | undefined | null): string
49
49
  export interface StoredCheckpoint {
50
50
  checkpointId: string;
51
51
  sessionId: string;
52
+ /** Source repo id (foreign stateDir) when this checkpoint came from a global
53
+ * cross-repo index entry. Undefined for same-repo checkpoints. Spec S17.1. */
54
+ repoId?: string;
52
55
  summary: string;
53
56
  /** Compressed topic summary (extractive, ~2K tokens vs ~70K raw). */
54
57
  topicSummary?: string;
@@ -51,6 +51,10 @@ import { migrateJsonToSqlite } from "./store/migrate.js";
51
51
  export interface SearchHit {
52
52
  checkpoint: StoredCheckpoint;
53
53
  score: number;
54
+ /** Source repo id (the foreign repo's stateDir) for cross-repo hits, set by
55
+ * `searchAsync` so the recall block can label foreign checkpoints. Undefined
56
+ * for same-repo hits (the default path). */
57
+ repoId?: string;
54
58
  }
55
59
 
56
60
  export interface AddInput {
@@ -326,6 +330,7 @@ export class VectorStore {
326
330
  const checkpoint: StoredCheckpoint = {
327
331
  checkpointId,
328
332
  sessionId,
333
+ repoId: this.repoId,
329
334
  summary: input.summary,
330
335
  topicSummary: input.topicSummary,
331
336
  summaryHash,
@@ -525,11 +530,15 @@ export class VectorStore {
525
530
  }
526
531
  // Hydrate each index hit from the authoritative node:sqlite store. repoId is
527
532
  // that repo's stateDir, so cross-repo hits resolve against their own store.
533
+ // Tag cross-repo hits with their source repoId so the recall block can label
534
+ // them ("from repo <name>"); same-repo hits stay unlabeled.
535
+ const selfRepo = this.repoId;
528
536
  const hydrated: SearchHit[] = [];
529
537
  for (const h of indexHits) {
530
538
  const cp = getCheckpoint(h.sessionId, h.checkpointId, h.repoId);
531
539
  if (cp && cp.dedupStatus !== "removed") {
532
- hydrated.push({ checkpoint: cp, score: h.score });
540
+ const crossRepo = opts.crossRepo && selfRepo && h.repoId && h.repoId !== selfRepo;
541
+ hydrated.push({ checkpoint: cp, score: h.score, repoId: crossRepo ? h.repoId : undefined });
533
542
  }
534
543
  }
535
544
  if (hydrated.length === 0) return this.search(sid, query, k);