the-campfire 0.2.1 → 0.3.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 (67) hide show
  1. package/README.md +28 -4
  2. package/dist/assets/{MermaidDiagram-D_MwUXi3.js → MermaidDiagram-Cas1Ngze.js} +2 -2
  3. package/dist/assets/{_basePickBy-DsMRBmhy.js → _basePickBy-D1h3RRT9.js} +1 -1
  4. package/dist/assets/{_baseUniq-CZ0BR5Mx.js → _baseUniq-Z9p6uaM9.js} +1 -1
  5. package/dist/assets/{arc-D6Y0ooDg.js → arc-DJLNkmre.js} +1 -1
  6. package/dist/assets/{architectureDiagram-VXUJARFQ-3T5ZoEn1.js → architectureDiagram-VXUJARFQ-CBHHYg8R.js} +1 -1
  7. package/dist/assets/{blockDiagram-VD42YOAC-CvOCnB8f.js → blockDiagram-VD42YOAC-DMtvAHPO.js} +1 -1
  8. package/dist/assets/{c4Diagram-YG6GDRKO-DPXJvInm.js → c4Diagram-YG6GDRKO-G9Zj2LWB.js} +1 -1
  9. package/dist/assets/channel-CcCVj0rn.js +1 -0
  10. package/dist/assets/{chunk-4BX2VUAB-DPHGuPB4.js → chunk-4BX2VUAB-s3svQOtk.js} +1 -1
  11. package/dist/assets/{chunk-55IACEB6-BbI83gIK.js → chunk-55IACEB6-C6fhtS_L.js} +1 -1
  12. package/dist/assets/{chunk-B4BG7PRW-DGvVY1Y_.js → chunk-B4BG7PRW-CpePj3eQ.js} +1 -1
  13. package/dist/assets/{chunk-DI55MBZ5-2YN0FhPB.js → chunk-DI55MBZ5-Dq9AChBD.js} +1 -1
  14. package/dist/assets/{chunk-FMBD7UC4-Ctqs0bWj.js → chunk-FMBD7UC4-DSOMddvX.js} +1 -1
  15. package/dist/assets/{chunk-QN33PNHL-CfvfTLi4.js → chunk-QN33PNHL-ZrdWE4nY.js} +1 -1
  16. package/dist/assets/{chunk-QZHKN3VN-C-dG1NiD.js → chunk-QZHKN3VN-BqLE4kwR.js} +1 -1
  17. package/dist/assets/{chunk-TZMSLE5B-Bh07YiOi.js → chunk-TZMSLE5B-DY7GliuX.js} +1 -1
  18. package/dist/assets/classDiagram-2ON5EDUG-8I6xWkSV.js +1 -0
  19. package/dist/assets/classDiagram-v2-WZHVMYZB-8I6xWkSV.js +1 -0
  20. package/dist/assets/clone-zAvPgvOP.js +1 -0
  21. package/dist/assets/{cose-bilkent-S5V4N54A-CTC2n8Hd.js → cose-bilkent-S5V4N54A-DmRYvz2l.js} +1 -1
  22. package/dist/assets/{dagre-6UL2VRFP-CAgGZKYt.js → dagre-6UL2VRFP-D4IfaYgb.js} +1 -1
  23. package/dist/assets/{diagram-PSM6KHXK-DymwxXvC.js → diagram-PSM6KHXK-ClfgeCpw.js} +1 -1
  24. package/dist/assets/{diagram-QEK2KX5R-CAEL5_ej.js → diagram-QEK2KX5R-BaBfarh-.js} +1 -1
  25. package/dist/assets/{diagram-S2PKOQOG-B6onWI1I.js → diagram-S2PKOQOG-DT1eYmOM.js} +1 -1
  26. package/dist/assets/{erDiagram-Q2GNP2WA-DP-dbxgu.js → erDiagram-Q2GNP2WA-C5ZVoHhn.js} +1 -1
  27. package/dist/assets/{flowDiagram-NV44I4VS-OMmA9Rvb.js → flowDiagram-NV44I4VS-C9MYAakn.js} +1 -1
  28. package/dist/assets/{ganttDiagram-JELNMOA3-B2oAHD8b.js → ganttDiagram-JELNMOA3-HHt6rJj9.js} +1 -1
  29. package/dist/assets/{gitGraphDiagram-V2S2FVAM-CAZO9ISt.js → gitGraphDiagram-V2S2FVAM-CfA3qwoT.js} +1 -1
  30. package/dist/assets/{graph-CeeDmBlM.js → graph-BLJloKEi.js} +1 -1
  31. package/dist/assets/index-CvOAfVHK.css +32 -0
  32. package/dist/assets/{index-CAGJQXdJ.js → index-CxNtY8EJ.js} +82 -82
  33. package/dist/assets/{infoDiagram-HS3SLOUP-m0CP23H-.js → infoDiagram-HS3SLOUP-ASXl-hwl.js} +1 -1
  34. package/dist/assets/{journeyDiagram-XKPGCS4Q-C9WvzT-H.js → journeyDiagram-XKPGCS4Q-B-wDjjzW.js} +1 -1
  35. package/dist/assets/{kanban-definition-3W4ZIXB7-KIEeugzr.js → kanban-definition-3W4ZIXB7-BNWLVKm2.js} +1 -1
  36. package/dist/assets/{layout-P-20nhx7.js → layout-cpBTqNBI.js} +1 -1
  37. package/dist/assets/{linear-CMM5aRIM.js → linear-DAonGknK.js} +1 -1
  38. package/dist/assets/{mermaid.core-Z9YQUdi5.js → mermaid.core-BdetWGOO.js} +4 -4
  39. package/dist/assets/{mindmap-definition-VGOIOE7T-B-NyLDuB.js → mindmap-definition-VGOIOE7T-CJOEqJKm.js} +1 -1
  40. package/dist/assets/{pieDiagram-ADFJNKIX-Dp8zgF7X.js → pieDiagram-ADFJNKIX-Dnc1ekZC.js} +1 -1
  41. package/dist/assets/{quadrantDiagram-AYHSOK5B-Cs07PJ50.js → quadrantDiagram-AYHSOK5B-Dr56Xspi.js} +1 -1
  42. package/dist/assets/{requirementDiagram-UZGBJVZJ-CWMm228C.js → requirementDiagram-UZGBJVZJ-z-G-ThCz.js} +1 -1
  43. package/dist/assets/{sankeyDiagram-TZEHDZUN-Cf7zGgTA.js → sankeyDiagram-TZEHDZUN-D_4p6Me1.js} +1 -1
  44. package/dist/assets/{sequenceDiagram-WL72ISMW-TzZvjAhX.js → sequenceDiagram-WL72ISMW-DD1K4OoS.js} +1 -1
  45. package/dist/assets/{stateDiagram-FKZM4ZOC-CH45r52Y.js → stateDiagram-FKZM4ZOC-ENIvCriI.js} +1 -1
  46. package/dist/assets/stateDiagram-v2-4FDKWEC3-BgJoZY9E.js +1 -0
  47. package/dist/assets/{timeline-definition-IT6M3QCI-CLaDCX_H.js → timeline-definition-IT6M3QCI-V8I1MBPc.js} +1 -1
  48. package/dist/assets/{treemap-GDKQZRPO-BNNAKE7E.js → treemap-GDKQZRPO-1lnlvkR3.js} +1 -1
  49. package/dist/assets/{xychartDiagram-PRI3JC2R-BG_0_QvU.js → xychartDiagram-PRI3JC2R-CrSrN3UV.js} +1 -1
  50. package/dist/index.html +2 -2
  51. package/package.json +1 -1
  52. package/server/collective-intelligence.ts +125 -43
  53. package/server/embedding.ts +12 -3
  54. package/server/memory-consolidation.ts +703 -0
  55. package/server/memory-migration.ts +468 -0
  56. package/server/routes/ci-routes.ts +68 -10
  57. package/server/routes/settings-routes.ts +10 -2
  58. package/server/semantic-memory.ts +1127 -187
  59. package/server/session-types.ts +15 -0
  60. package/server/settings-manager.ts +122 -3
  61. package/server/ws-bridge.ts +207 -26
  62. package/dist/assets/channel-D6cof2B8.js +0 -1
  63. package/dist/assets/classDiagram-2ON5EDUG-BG_dxrC_.js +0 -1
  64. package/dist/assets/classDiagram-v2-WZHVMYZB-BG_dxrC_.js +0 -1
  65. package/dist/assets/clone-vuZ4vhTG.js +0 -1
  66. package/dist/assets/index-Byjg9zgI.css +0 -32
  67. package/dist/assets/stateDiagram-v2-4FDKWEC3-DaPUshfK.js +0 -1
@@ -1,29 +1,62 @@
1
1
  /**
2
- * Layer 1: Semantic Memory
2
+ * Layer 1: Semantic Memory — v2 core store.
3
3
  *
4
- * Persistent, shared knowledge base anchored to a git repository.
5
- * Backed by LanceDB (embedded, TypeScript-native vector database — same stack as Continue.dev).
4
+ * Persistent, shared knowledge base anchored to namespaces (design doc
5
+ * docs/design/semantic-memory-v2.md §3.1–§3.5). Backed by LanceDB.
6
6
  *
7
- * Two tables:
8
- * - fragments: episodic/semantic MemoryFragments with vector embeddings
9
- * - consolidated: distilled ConsolidatedKnowledge per semantic tag
7
+ * Two active tables (names tracked in ~/.campfire/memory/meta.json):
8
+ * - fragments_v2[_<dim>]: episodic/semantic MemoryFragments with vectors
9
+ * - consolidated_v2[_<dim>]: distilled ConsolidatedKnowledge per (namespace, tag)
10
10
  *
11
- * Data is stored at ~/.campfire/memory/lancedb/ (one DB directory, two tables).
12
- *
13
- * Retrieval is vector similarity search via LanceDB's built-in ANN/flat-scan.
14
- * Embeddings are generated by the configured provider (OpenAI or Ollama) at write time.
11
+ * v2 adds:
12
+ * - namespaces (global / repo:<hash> / session:<id> / agent:<backend>) with
13
+ * pushed-down where() filtering (§3.1, fixes §1.7 starvation)
14
+ * - lazy decay + capped reinforcement (§3.2) computed at read time
15
+ * - composite scored retrieval simNorm^1.5 × w(t) × confidence (§3.3)
16
+ * - embeddingStatus lifecycle ("ok"/"pending"/"none") — no zero vectors in
17
+ * ANN results (§1.6) + lazy re-embed queue at ≤ 2 req/s
18
+ * - consolidation primitives: idempotent upsert by (namespace, tag) with
19
+ * supersession tombstones and source marking (§1.2, §3.4 Stage 3)
20
+ * - v1 → v2 migration + dimension-change handling (§3.5, memory-migration.ts)
15
21
  */
16
22
 
17
- import { mkdirSync, appendFileSync } from "node:fs";
23
+ import { mkdirSync } from "node:fs";
18
24
  import { join } from "node:path";
19
25
  import { homedir } from "node:os";
20
26
  import { randomUUID } from "node:crypto";
21
- import type { BackendType } from "./session-types.js";
22
- import { embed, getEmbeddingDim } from "./embedding.js";
27
+ import type { BackendType, MemoryEnrichmentItem } from "./session-types.js";
28
+ import { embed, getEmbeddingDim, getEmbeddingProviderName } from "./embedding.js";
29
+ import { getMemorySettings, type MemoryDecayPolicy } from "./settings-manager.js";
30
+ import {
31
+ ensureSchemaV2,
32
+ hashRepoRoot,
33
+ repoNamespace,
34
+ sessionNamespace,
35
+ agentNamespace,
36
+ namespaceClass,
37
+ isNamespaceString,
38
+ toNumberArray,
39
+ type MemoryMeta,
40
+ type NamespaceClass,
41
+ } from "./memory-migration.js";
42
+
43
+ // Re-export the namespace model so consumers only need this module.
44
+ export {
45
+ hashRepoRoot,
46
+ repoNamespace,
47
+ sessionNamespace,
48
+ agentNamespace,
49
+ namespaceClass,
50
+ isNamespaceString,
51
+ type NamespaceClass,
52
+ };
23
53
 
24
54
  // ─── Types ───────────────────────────────────────────────────────────────────
25
55
 
26
56
  export type MemoryType = "observation" | "hypothesis" | "decision" | "pattern";
57
+ export type EmbeddingStatus = "ok" | "pending" | "none";
58
+ export type SynthesisMethod = "llm" | "concat";
59
+ export type KnowledgeType = "pattern" | "decision" | "convention" | "failure" | "fact";
27
60
 
28
61
  export interface GitContext {
29
62
  commitHash?: string;
@@ -46,8 +79,19 @@ export interface MemoryFragment {
46
79
  tags: string[];
47
80
  consolidatedInto?: string;
48
81
  isConsolidated: boolean;
49
- // Stored in LanceDB as a Float32 vector column — null when provider = "none"
82
+ // Stored in LanceDB as a Float32 vector column — populated on read only
83
+ // when embeddingStatus === "ok"
50
84
  embedding?: number[];
85
+ // ── v2 lifecycle fields (§3.1/§3.2) — always populated by the store;
86
+ // optional so pre-v2 constructors/mocks keep compiling.
87
+ namespace?: string;
88
+ repoRootHash?: string;
89
+ lastReinforcedAt?: number;
90
+ accessCount?: number;
91
+ pinned?: boolean;
92
+ /** Per-fragment half-life override in hours; null = use namespace policy. */
93
+ halfLifeHours?: number | null;
94
+ embeddingStatus?: EmbeddingStatus;
51
95
  }
52
96
 
53
97
  export interface ConsolidatedKnowledge {
@@ -58,6 +102,12 @@ export interface ConsolidatedKnowledge {
58
102
  lastUpdated: number;
59
103
  confidence: number;
60
104
  repoRoot: string;
105
+ // ── v2 fields
106
+ namespace?: string;
107
+ type?: KnowledgeType;
108
+ synthesisMethod?: SynthesisMethod;
109
+ /** Tombstone: id of the knowledge row that superseded this one ("" / undefined = active). */
110
+ supersededBy?: string;
61
111
  }
62
112
 
63
113
  export interface MemoryQueryOptions {
@@ -66,84 +116,144 @@ export interface MemoryQueryOptions {
66
116
  tags?: string[];
67
117
  type?: MemoryType;
68
118
  sessionId?: string;
119
+ /** v2: restrict to a single namespace (pushed down into LanceDB where()). */
120
+ namespace?: string;
121
+ }
122
+
123
+ // ─── Tunables (§3.2/§3.3) ────────────────────────────────────────────────────
124
+
125
+ /** Similarity sharpening exponent — decay/confidence break ties, not override matches. */
126
+ export const SIM_EXPONENT = 1.5;
127
+ /** Fragments with pairwise cosine above this are near-duplicates (keep higher score). */
128
+ export const NEAR_DUP_COSINE = 0.97;
129
+ /** Decayed-weight floor below which consolidated, unpinned fragments are evicted. */
130
+ export const EVICTION_WEIGHT_THRESHOLD = 0.05;
131
+ /** Hard cap of fragments per namespace (lowest-w eviction backstop). */
132
+ export const NAMESPACE_HARD_CAP = 5000;
133
+ /** Reinforcement cap: half-life extension multiplier applies at most this many times. */
134
+ export const REINFORCE_ACCESS_CAP = 8;
135
+ /** Enrichment budget: max chars of raw fragment text in the block. */
136
+ export const FRAGMENT_BUDGET_CHARS = 1200;
137
+ /** Enrichment budget: max chars of the whole injected block. */
138
+ export const TOTAL_BUDGET_CHARS = 2000;
139
+ /** Max fragment lines in the enrichment block (§3.6.2). */
140
+ export const MAX_ENRICHMENT_FRAGMENTS = 5;
141
+
142
+ const REINFORCE_DEBOUNCE_MS = 500;
143
+ const SWEEP_INTERVAL_MS = 60 * 60 * 1000; // hourly (§3.2)
144
+ const REEMBED_INTERVAL_MS = 1000; // batch of 2 per second = ≤ 2 req/s (§3.5)
145
+
146
+ // ─── Decay + reinforcement (§3.2) ────────────────────────────────────────────
147
+
148
+ export type DecayPolicy = MemoryDecayPolicy;
149
+
150
+ /** Minimal shape needed to compute a decayed weight (table-driven-testable). */
151
+ export interface DecayableLike {
152
+ pinned?: boolean;
153
+ lastReinforcedAt?: number;
154
+ timestamp?: number;
155
+ accessCount?: number;
156
+ halfLifeHours?: number | null;
157
+ }
158
+
159
+ /**
160
+ * Lazy decay weight, computed at read time and never stored:
161
+ *
162
+ * halfLife_eff = halfLife_base × reinforceMultiplier ^ min(accessCount, 8)
163
+ * w(t) = pinned ? 1.0 : 0.5 ^ ((now − lastReinforcedAt) / halfLife_eff)
164
+ *
165
+ * Pinned rows and null half-lives never decay (w = 1).
166
+ */
167
+ export function computeDecayedWeight(fragment: DecayableLike, now: number, policy: DecayPolicy): number {
168
+ if (fragment.pinned) return 1;
169
+ const override = fragment.halfLifeHours;
170
+ const baseHalfLife = typeof override === "number" && override > 0 ? override : policy.halfLifeHours;
171
+ if (baseHalfLife === null || baseHalfLife <= 0) return 1;
172
+ const multiplier = policy.reinforceMultiplier > 0 ? policy.reinforceMultiplier : 1;
173
+ const capped = Math.min(Math.max(fragment.accessCount ?? 0, 0), REINFORCE_ACCESS_CAP);
174
+ const effectiveHalfLife = baseHalfLife * Math.pow(multiplier, capped);
175
+ const anchor = fragment.lastReinforcedAt ?? fragment.timestamp ?? now;
176
+ const ageHours = Math.max(0, now - anchor) / 3_600_000;
177
+ return Math.pow(0.5, ageHours / effectiveHalfLife);
178
+ }
179
+
180
+ /** Decay policy for a namespace, from settings (defaults per §3.1). */
181
+ export function policyForNamespace(namespace: string): DecayPolicy {
182
+ const cls: NamespaceClass = namespaceClass(namespace);
183
+ return getMemorySettings().decay[cls];
184
+ }
185
+
186
+ // ─── SQL helpers (pushed-down filters, §1.7 fix) ─────────────────────────────
187
+
188
+ /** Quote a string literal for a LanceDB (DataFusion) where() predicate. */
189
+ export function sqlQuote(value: string): string {
190
+ return `'${value.replace(/'/g, "''")}'`;
191
+ }
192
+
193
+ /**
194
+ * Where-clause for a namespace query. `embeddedOnly` additionally excludes
195
+ * rows without a usable embedding from ANN search (§1.6 / §3.3).
196
+ */
197
+ export function buildNamespaceWhere(namespace: string, embeddedOnly = true): string {
198
+ const base = `namespace = ${sqlQuote(namespace)}`;
199
+ return embeddedOnly ? `${base} AND embeddingStatus = 'ok'` : base;
69
200
  }
70
201
 
71
202
  // ─── LanceDB integration ─────────────────────────────────────────────────────
72
203
 
73
- // Lazily import to avoid startup cost when memory is not used
74
204
  type LanceDB = typeof import("@lancedb/lancedb");
75
- type LanceTable = Awaited<ReturnType<Awaited<ReturnType<LanceDB["connect"]>>["openTable"]>>;
205
+ type LanceConnection = Awaited<ReturnType<LanceDB["connect"]>>;
206
+ type LanceTable = Awaited<ReturnType<LanceConnection["openTable"]>>;
76
207
 
77
- let _db: Awaited<ReturnType<LanceDB["connect"]>> | null = null;
78
- let _fragmentsTable: LanceTable | null = null;
79
- let _consolidatedTable: LanceTable | null = null;
80
- let _memoryDir = join(homedir(), ".campfire", "memory", "lancedb");
208
+ interface StoreState {
209
+ db: LanceConnection;
210
+ fragments: LanceTable;
211
+ consolidated: LanceTable;
212
+ meta: MemoryMeta;
213
+ }
214
+
215
+ let _memoryRoot = join(homedir(), ".campfire", "memory");
216
+ let _initPromise: Promise<StoreState> | null = null;
81
217
 
82
- async function getDB() {
83
- if (_db) return _db;
218
+ async function initState(): Promise<StoreState> {
84
219
  const lancedb = await import("@lancedb/lancedb");
85
- mkdirSync(_memoryDir, { recursive: true });
86
- _db = await lancedb.connect(_memoryDir);
87
- return _db;
220
+ const dbDir = join(_memoryRoot, "lancedb");
221
+ mkdirSync(dbDir, { recursive: true });
222
+ const db = await lancedb.connect(dbDir);
223
+ const { meta, fragments, consolidated } = await ensureSchemaV2({
224
+ db,
225
+ memoryRoot: _memoryRoot,
226
+ provider: getEmbeddingProviderName(),
227
+ providerDim: getEmbeddingDim(),
228
+ });
229
+ return { db, fragments, consolidated, meta };
88
230
  }
89
231
 
90
- async function getFragmentsTable(): Promise<LanceTable> {
91
- if (_fragmentsTable) return _fragmentsTable;
92
- const db = await getDB();
93
- const tableNames = await db.tableNames();
94
- if (tableNames.includes("fragments")) {
95
- _fragmentsTable = await db.openTable("fragments");
96
- } else {
97
- // Create with a seed row so the schema is established, then delete seed
98
- const dim = getEmbeddingDim();
99
- const seed = [{
100
- id: "__seed__",
101
- sessionId: "",
102
- agentId: "",
103
- backendType: "claude" as BackendType,
104
- timestamp: 0,
105
- type: "observation" as MemoryType,
106
- content: "",
107
- gitContextJson: "{}",
108
- referencesJson: "[]",
109
- tagsJson: "[]",
110
- confidence: 0,
111
- consolidatedInto: "",
112
- isConsolidated: false,
113
- vector: Array(dim).fill(0) as number[],
114
- }];
115
- _fragmentsTable = await db.createTable("fragments", seed);
116
- await _fragmentsTable.delete('id = "__seed__"');
232
+ async function getState(): Promise<StoreState> {
233
+ let state = await (_initPromise ??= initState());
234
+ // Live provider/dimension change (settings edited at runtime): re-run the
235
+ // reconciliation path so the active tables always match the provider (§3.5.2).
236
+ const provider = getEmbeddingProviderName();
237
+ const dim = getEmbeddingDim();
238
+ if (provider !== state.meta.embeddingProvider || (dim !== null && dim !== state.meta.dim)) {
239
+ _initPromise = initState();
240
+ state = await _initPromise;
117
241
  }
118
- return _fragmentsTable;
242
+ ensureMaintenanceTimers();
243
+ return state;
119
244
  }
120
245
 
121
- async function getConsolidatedTable(): Promise<LanceTable> {
122
- if (_consolidatedTable) return _consolidatedTable;
123
- const db = await getDB();
124
- const tableNames = await db.tableNames();
125
- if (tableNames.includes("consolidated")) {
126
- _consolidatedTable = await db.openTable("consolidated");
127
- } else {
128
- const seed = [{
129
- id: "__seed__",
130
- tag: "",
131
- summary: "",
132
- sourceFragmentsJson: "[]",
133
- lastUpdated: 0,
134
- confidence: 0,
135
- repoRoot: "",
136
- }];
137
- _consolidatedTable = await db.createTable("consolidated", seed);
138
- await _consolidatedTable.delete('id = "__seed__"');
139
- }
140
- return _consolidatedTable;
246
+ // ─── Row <-> domain object mapping ───────────────────────────────────────────
247
+
248
+ function rowStr(v: unknown): string {
249
+ return typeof v === "string" ? v : "";
141
250
  }
142
251
 
143
- // ─── Internal row <-> domain object mapping ──────────────────────────────────
252
+ function rowNum(v: unknown): number {
253
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
254
+ }
144
255
 
145
- function fragmentToRow(fragment: MemoryFragment, embedding: number[] | null) {
146
- const dim = getEmbeddingDim();
256
+ function fragmentToRow(fragment: MemoryFragment, vector: number[], status: EmbeddingStatus) {
147
257
  return {
148
258
  id: fragment.id,
149
259
  sessionId: fragment.sessionId,
@@ -158,41 +268,84 @@ function fragmentToRow(fragment: MemoryFragment, embedding: number[] | null) {
158
268
  confidence: fragment.confidence,
159
269
  consolidatedInto: fragment.consolidatedInto ?? "",
160
270
  isConsolidated: fragment.isConsolidated,
161
- vector: embedding ?? Array(dim).fill(0),
271
+ namespace: fragment.namespace ?? "",
272
+ repoRoot: fragment.gitContext.repoRoot ?? "",
273
+ repoRootHash: fragment.repoRootHash ?? "",
274
+ lastReinforcedAt: fragment.lastReinforcedAt ?? fragment.timestamp,
275
+ accessCount: fragment.accessCount ?? 0,
276
+ pinned: fragment.pinned ?? false,
277
+ // 0 = no override (null in the domain model)
278
+ halfLifeHours: fragment.halfLifeHours ?? 0,
279
+ embeddingStatus: status,
280
+ vector,
162
281
  };
163
282
  }
164
283
 
165
284
  function rowToFragment(row: Record<string, unknown>): MemoryFragment {
285
+ const status = (rowStr(row.embeddingStatus) || "none") as EmbeddingStatus;
286
+ const halfLife = rowNum(row.halfLifeHours);
166
287
  return {
167
- id: row.id as string,
168
- sessionId: row.sessionId as string,
169
- agentId: row.agentId as string,
170
- backendType: row.backendType as BackendType,
171
- timestamp: row.timestamp as number,
172
- type: row.type as MemoryType,
173
- content: row.content as string,
174
- gitContext: JSON.parse(row.gitContextJson as string || "{}") as GitContext,
175
- references: JSON.parse(row.referencesJson as string || "[]") as string[],
176
- tags: JSON.parse(row.tagsJson as string || "[]") as string[],
177
- confidence: row.confidence as number,
178
- consolidatedInto: (row.consolidatedInto as string) || undefined,
179
- isConsolidated: row.isConsolidated as boolean,
288
+ id: rowStr(row.id),
289
+ sessionId: rowStr(row.sessionId),
290
+ agentId: rowStr(row.agentId),
291
+ backendType: (rowStr(row.backendType) || "claude") as BackendType,
292
+ timestamp: rowNum(row.timestamp),
293
+ type: (rowStr(row.type) || "observation") as MemoryType,
294
+ content: rowStr(row.content),
295
+ gitContext: JSON.parse(rowStr(row.gitContextJson) || "{}") as GitContext,
296
+ references: JSON.parse(rowStr(row.referencesJson) || "[]") as string[],
297
+ tags: JSON.parse(rowStr(row.tagsJson) || "[]") as string[],
298
+ confidence: rowNum(row.confidence),
299
+ consolidatedInto: rowStr(row.consolidatedInto) || undefined,
300
+ isConsolidated: row.isConsolidated === true,
301
+ embedding: status === "ok" ? toNumberArray(row.vector) : undefined,
302
+ namespace: rowStr(row.namespace),
303
+ repoRootHash: rowStr(row.repoRootHash),
304
+ lastReinforcedAt: rowNum(row.lastReinforcedAt) || rowNum(row.timestamp),
305
+ accessCount: rowNum(row.accessCount),
306
+ pinned: row.pinned === true,
307
+ halfLifeHours: halfLife > 0 ? halfLife : null,
308
+ embeddingStatus: status,
180
309
  };
181
310
  }
182
311
 
183
312
  function rowToConsolidated(row: Record<string, unknown>): ConsolidatedKnowledge {
184
313
  return {
185
- id: row.id as string,
186
- tag: row.tag as string,
187
- summary: row.summary as string,
188
- sourceFragments: JSON.parse(row.sourceFragmentsJson as string || "[]") as string[],
189
- lastUpdated: row.lastUpdated as number,
190
- confidence: row.confidence as number,
191
- repoRoot: row.repoRoot as string,
314
+ id: rowStr(row.id),
315
+ tag: rowStr(row.tag),
316
+ summary: rowStr(row.summary),
317
+ sourceFragments: JSON.parse(rowStr(row.sourceFragmentsJson) || "[]") as string[],
318
+ lastUpdated: rowNum(row.lastUpdated),
319
+ confidence: rowNum(row.confidence),
320
+ repoRoot: rowStr(row.repoRoot),
321
+ namespace: rowStr(row.namespace),
322
+ type: (rowStr(row.knowledgeType) || undefined) as KnowledgeType | undefined,
323
+ synthesisMethod: (rowStr(row.synthesisMethod) || undefined) as SynthesisMethod | undefined,
324
+ supersededBy: rowStr(row.supersededBy) || undefined,
192
325
  };
193
326
  }
194
327
 
195
- // ─── Public API ──────────────────────────────────────────────────────────────
328
+ // ─── Vector math ─────────────────────────────────────────────────────────────
329
+
330
+ function cosineSimilarity(a: number[], b: number[]): number {
331
+ if (a.length === 0 || a.length !== b.length) return 0;
332
+ let dot = 0;
333
+ let magA = 0;
334
+ let magB = 0;
335
+ for (let i = 0; i < a.length; i++) {
336
+ dot += a[i] * b[i];
337
+ magA += a[i] * a[i];
338
+ magB += b[i] * b[i];
339
+ }
340
+ if (magA === 0 || magB === 0) return 0;
341
+ return dot / (Math.sqrt(magA) * Math.sqrt(magB));
342
+ }
343
+
344
+ function zeros(dim: number): number[] {
345
+ return Array(dim).fill(0) as number[];
346
+ }
347
+
348
+ // ─── storeFragment ───────────────────────────────────────────────────────────
196
349
 
197
350
  export interface StoreFragmentOptions {
198
351
  sessionId: string;
@@ -204,20 +357,32 @@ export interface StoreFragmentOptions {
204
357
  tags?: string[];
205
358
  confidence?: number;
206
359
  references?: string[];
360
+ /** v2: explicit target namespace. Default: repo:<hash> when a repoRoot is present, else session:<id>. */
361
+ namespace?: string;
362
+ pinned?: boolean;
363
+ /** Per-fragment half-life override in hours (null/omitted = namespace policy). */
364
+ halfLifeHours?: number | null;
207
365
  }
208
366
 
209
367
  /**
210
368
  * Store a new memory fragment. Generates an embedding at write time.
211
- * Non-blocking: embedding generation failures are logged and the fragment
212
- * is stored with a zero vector (still retrievable by metadata filters).
369
+ *
370
+ * §1.6 fix: no zero-vector pollution when the provider is "none" the row is
371
+ * stored with embeddingStatus "none"; when an embed call fails (or the dim
372
+ * mismatches mid-migration) it is stored "pending" and picked up by the lazy
373
+ * re-embed queue. Both states are excluded from ANN by where() (§3.3).
213
374
  */
214
375
  export async function storeFragment(opts: StoreFragmentOptions): Promise<MemoryFragment> {
376
+ const repoRoot = opts.gitContext.repoRoot ?? "";
377
+ const namespace =
378
+ opts.namespace ?? (repoRoot ? repoNamespace(repoRoot) : sessionNamespace(opts.sessionId));
379
+ const now = Date.now();
215
380
  const fragment: MemoryFragment = {
216
381
  id: randomUUID(),
217
382
  sessionId: opts.sessionId,
218
383
  agentId: opts.agentId,
219
384
  backendType: opts.backendType,
220
- timestamp: Date.now(),
385
+ timestamp: now,
221
386
  type: opts.type,
222
387
  content: opts.content,
223
388
  gitContext: opts.gitContext,
@@ -225,90 +390,701 @@ export async function storeFragment(opts: StoreFragmentOptions): Promise<MemoryF
225
390
  confidence: opts.confidence ?? 0.7,
226
391
  tags: opts.tags ?? [],
227
392
  isConsolidated: false,
393
+ namespace,
394
+ repoRootHash: repoRoot ? hashRepoRoot(repoRoot) : "",
395
+ lastReinforcedAt: now,
396
+ accessCount: 0,
397
+ pinned: opts.pinned ?? false,
398
+ halfLifeHours: opts.halfLifeHours ?? null,
228
399
  };
229
400
 
230
401
  const embedding = await embed(opts.content);
231
- const table = await getFragmentsTable();
232
- await table.add([fragmentToRow(fragment, embedding)]);
402
+ const state = await getState();
403
+ const dim = state.meta.dim;
404
+
405
+ let status: EmbeddingStatus;
406
+ let vector: number[];
407
+ if (embedding && embedding.length === dim) {
408
+ status = "ok";
409
+ vector = embedding;
410
+ } else if (getEmbeddingDim() === null) {
411
+ status = "none";
412
+ vector = zeros(dim);
413
+ } else {
414
+ status = "pending";
415
+ vector = zeros(dim);
416
+ }
417
+ fragment.embeddingStatus = status;
418
+ if (status === "ok") fragment.embedding = embedding as number[];
233
419
 
420
+ await state.fragments.add([fragmentToRow(fragment, vector, status)]);
234
421
  return fragment;
235
422
  }
236
423
 
424
+ // ─── Scored retrieval (§3.3) ─────────────────────────────────────────────────
425
+
426
+ export interface RecallPlanEntry {
427
+ namespace: string;
428
+ depth: number;
429
+ }
430
+
431
+ export interface ScoredFragment {
432
+ fragment: MemoryFragment;
433
+ /** Composite score: simNorm^1.5 × w(t) × confidence. */
434
+ score: number;
435
+ /** Decayed weight w(t) at recall time. */
436
+ weight: number;
437
+ /** Normalized similarity (1 when retrieval had no query vector). */
438
+ simNorm: number;
439
+ }
440
+
441
+ function scoreRow(
442
+ row: Record<string, unknown>,
443
+ queryVector: number[] | null,
444
+ policy: DecayPolicy,
445
+ now: number,
446
+ ): ScoredFragment {
447
+ const fragment = rowToFragment(row);
448
+ const weight = computeDecayedWeight(fragment, now, policy);
449
+ let simNorm = 1;
450
+ if (queryVector) {
451
+ simNorm = Math.max(0, cosineSimilarity(queryVector, toNumberArray(row.vector)));
452
+ }
453
+ const score = Math.pow(simNorm, SIM_EXPONENT) * weight * fragment.confidence;
454
+ return { fragment, score, weight, simNorm };
455
+ }
456
+
457
+ /** Near-duplicate dedupe: cosine > 0.97 (or identical content) keeps the higher score. */
458
+ function dedupeNearDuplicates(scored: ScoredFragment[]): ScoredFragment[] {
459
+ const kept: ScoredFragment[] = [];
460
+ const sorted = [...scored].sort((a, b) => b.score - a.score);
461
+ for (const candidate of sorted) {
462
+ const isDup = kept.some((existing) => {
463
+ if (existing.fragment.content === candidate.fragment.content) return true;
464
+ const a = existing.fragment.embedding;
465
+ const b = candidate.fragment.embedding;
466
+ if (!a || !b) return false;
467
+ return cosineSimilarity(a, b) > NEAR_DUP_COSINE;
468
+ });
469
+ if (!isDup) kept.push(candidate);
470
+ }
471
+ return kept;
472
+ }
473
+
474
+ /**
475
+ * Per-namespace scored search (§3.3). For each namespace: pushed-down
476
+ * where("namespace = ... AND embeddingStatus = 'ok'"), limit depth×4, composite
477
+ * scoring in TS, top-depth per namespace, merged + near-dup deduped.
478
+ *
479
+ * No-embedding fallback: metadata scan per namespace ranked by w(t)×confidence.
480
+ */
481
+ export async function queryScoredFragments(
482
+ queryText: string,
483
+ plan: RecallPlanEntry[],
484
+ now: number = Date.now(),
485
+ ): Promise<ScoredFragment[]> {
486
+ const state = await getState();
487
+ const queryVector = await embed(queryText);
488
+ const useVector = !!queryVector && queryVector.length === state.meta.dim;
489
+ const merged: ScoredFragment[] = [];
490
+
491
+ for (const entry of plan) {
492
+ if (entry.depth <= 0) continue;
493
+ const policy = policyForNamespace(entry.namespace);
494
+ let rows: Record<string, unknown>[];
495
+ if (useVector) {
496
+ rows = (await state.fragments
497
+ .search(queryVector as number[])
498
+ .where(buildNamespaceWhere(entry.namespace, true))
499
+ .limit(entry.depth * 4)
500
+ .toArray()) as unknown as Record<string, unknown>[];
501
+ } else {
502
+ rows = (await state.fragments
503
+ .query()
504
+ .where(buildNamespaceWhere(entry.namespace, false))
505
+ .limit(1000)
506
+ .toArray()) as unknown as Record<string, unknown>[];
507
+ }
508
+ const scored = rows
509
+ .map((row) => scoreRow(row, useVector ? (queryVector as number[]) : null, policy, now))
510
+ .sort((a, b) => b.score - a.score)
511
+ .slice(0, entry.depth);
512
+ merged.push(...scored);
513
+ }
514
+
515
+ return dedupeNearDuplicates(merged).sort((a, b) => b.score - a.score);
516
+ }
517
+
237
518
  /**
238
- * Semantic similarity search over stored fragments.
239
- * If an embedding is available, uses vector search.
240
- * Falls back to a full table scan filtered by metadata (tag/session/repo).
519
+ * Semantic similarity search over stored fragments (legacy-compatible API).
520
+ *
521
+ * v2: metadata filters (repoRoot/sessionId/type/namespace) are pushed down
522
+ * into LanceDB where() instead of post-hoc JS filtering (§1.7 fix), and
523
+ * results are ranked by the composite score simNorm^1.5 × w(t) × confidence.
524
+ * Without a provider, falls back to a metadata scan ranked by w(t)×confidence.
241
525
  */
242
526
  export async function queryFragments(
243
527
  query: string,
244
528
  options: MemoryQueryOptions = {},
245
529
  ): Promise<MemoryFragment[]> {
246
- const { limit = 10, repoRoot, tags, type, sessionId } = options;
247
- const table = await getFragmentsTable();
530
+ const { limit = 10, repoRoot, tags, type, sessionId, namespace } = options;
531
+ const state = await getState();
532
+ const now = Date.now();
248
533
  const queryVector = await embed(query);
534
+ const useVector = !!queryVector && queryVector.length === state.meta.dim;
249
535
 
250
- let rows: Record<string, unknown>[];
536
+ const conds: string[] = [];
537
+ if (namespace) conds.push(`namespace = ${sqlQuote(namespace)}`);
538
+ if (repoRoot) conds.push(`repoRootHash = ${sqlQuote(hashRepoRoot(repoRoot))}`);
539
+ if (sessionId) conds.push(`sessionId = ${sqlQuote(sessionId)}`);
540
+ if (type) conds.push(`type = ${sqlQuote(type)}`);
251
541
 
252
- if (queryVector) {
253
- // Vector similarity search — LanceDB returns closest by cosine distance
254
- let search = table.search(queryVector).limit(limit * 3); // over-fetch for post-filter
255
- const result = await search.toArray();
256
- rows = result as unknown as Record<string, unknown>[];
542
+ let rows: Record<string, unknown>[];
543
+ if (useVector) {
544
+ conds.push("embeddingStatus = 'ok'");
545
+ rows = (await state.fragments
546
+ .search(queryVector as number[])
547
+ .where(conds.join(" AND "))
548
+ .limit(limit * 4)
549
+ .toArray()) as unknown as Record<string, unknown>[];
257
550
  } else {
258
- // No embedding configured — full scan
259
- const result = await table.query().limit(500).toArray();
260
- rows = result as unknown as Record<string, unknown>[];
551
+ let q = state.fragments.query();
552
+ if (conds.length > 0) q = q.where(conds.join(" AND "));
553
+ rows = (await q.limit(2000).toArray()) as unknown as Record<string, unknown>[];
261
554
  }
262
555
 
263
- // Post-filter by metadata
264
- let fragments = rows.map(rowToFragment).filter((f) => {
265
- if (repoRoot && f.gitContext.repoRoot !== repoRoot) return false;
266
- if (sessionId && f.sessionId !== sessionId) return false;
267
- if (type && f.type !== type) return false;
268
- if (tags && tags.length > 0) {
269
- const fragTags = new Set(f.tags);
270
- if (!tags.some((t) => fragTags.has(t))) return false;
271
- }
272
- return true;
556
+ let scored = rows.map((row) => {
557
+ const ns = rowStr(row.namespace);
558
+ return scoreRow(row, useVector ? (queryVector as number[]) : null, policyForNamespace(ns), now);
273
559
  });
274
560
 
275
- // If no vector search, sort by recency
276
- if (!queryVector) {
277
- fragments.sort((a, b) => b.timestamp - a.timestamp);
561
+ // Tags live in a JSON column — post-filter only this one.
562
+ if (tags && tags.length > 0) {
563
+ scored = scored.filter((s) => {
564
+ const fragTags = new Set(s.fragment.tags);
565
+ return tags.some((t) => fragTags.has(t));
566
+ });
278
567
  }
279
568
 
280
- return fragments.slice(0, limit);
569
+ return scored
570
+ .sort((a, b) => b.score - a.score)
571
+ .slice(0, limit)
572
+ .map((s) => s.fragment);
281
573
  }
282
574
 
283
575
  /**
284
576
  * Retrieve all fragments for a session (used during consolidation).
577
+ * v2: sessionId filter pushed down into where().
285
578
  */
286
579
  export async function getSessionFragments(sessionId: string): Promise<MemoryFragment[]> {
287
- const table = await getFragmentsTable();
288
- const result = await table.query().limit(10000).toArray();
289
- return (result as unknown as Record<string, unknown>[])
290
- .map(rowToFragment)
291
- .filter((f) => f.sessionId === sessionId);
580
+ const state = await getState();
581
+ const rows = (await state.fragments
582
+ .query()
583
+ .where(`sessionId = ${sqlQuote(sessionId)}`)
584
+ .limit(10000)
585
+ .toArray()) as unknown as Record<string, unknown>[];
586
+ return rows.map(rowToFragment);
587
+ }
588
+
589
+ // ─── Reinforcement (§3.2) ────────────────────────────────────────────────────
590
+
591
+ const _pendingReinforce = {
592
+ fragments: new Map<string, number>(),
593
+ knowledge: new Map<string, number>(),
594
+ };
595
+ let _reinforceTimer: ReturnType<typeof setTimeout> | null = null;
596
+
597
+ function scheduleReinforceFlush(): void {
598
+ if (_reinforceTimer) return;
599
+ _reinforceTimer = setTimeout(() => {
600
+ flushReinforcements().catch((err) => console.warn("[memory] reinforcement flush failed:", err));
601
+ }, REINFORCE_DEBOUNCE_MS);
602
+ _reinforceTimer.unref?.();
292
603
  }
293
604
 
294
605
  /**
295
- * Consolidate episodic fragments from a session into semantic knowledge.
296
- * Groups fragments by tag, synthesizes a summary per tag, and stores in
297
- * the consolidated table.
298
- *
299
- * Returns the list of consolidated knowledge items created/updated.
606
+ * Reinforce fragments that were *actually used* (included in an enrichment
607
+ * block or explicitly returned by memory_query): accessCount += 1 and
608
+ * lastReinforcedAt = now. Writes are debounced/batched; the reinforcement cap
609
+ * (×multiplier at most 8 times) is applied at read time in computeDecayedWeight.
610
+ */
611
+ export function reinforceFragments(ids: string[]): void {
612
+ for (const id of ids) {
613
+ _pendingReinforce.fragments.set(id, (_pendingReinforce.fragments.get(id) ?? 0) + 1);
614
+ }
615
+ if (ids.length > 0) scheduleReinforceFlush();
616
+ }
617
+
618
+ /** Same as reinforceFragments but for consolidated knowledge rows. */
619
+ export function reinforceKnowledge(ids: string[]): void {
620
+ for (const id of ids) {
621
+ _pendingReinforce.knowledge.set(id, (_pendingReinforce.knowledge.get(id) ?? 0) + 1);
622
+ }
623
+ if (ids.length > 0) scheduleReinforceFlush();
624
+ }
625
+
626
+ async function applyReinforcements(
627
+ table: LanceTable,
628
+ pending: Map<string, number>,
629
+ now: number,
630
+ ): Promise<void> {
631
+ if (pending.size === 0) return;
632
+ const ids = [...pending.keys()];
633
+ const rows = (await table
634
+ .query()
635
+ .where(`id IN (${ids.map(sqlQuote).join(", ")})`)
636
+ .limit(ids.length)
637
+ .toArray()) as unknown as Record<string, unknown>[];
638
+ for (const row of rows) {
639
+ const id = rowStr(row.id);
640
+ const inc = pending.get(id) ?? 0;
641
+ if (inc <= 0) continue;
642
+ await table.update({
643
+ where: `id = ${sqlQuote(id)}`,
644
+ values: { accessCount: rowNum(row.accessCount) + inc, lastReinforcedAt: now },
645
+ });
646
+ }
647
+ }
648
+
649
+ /** Force the debounced reinforcement batch to write now (tests / shutdown). */
650
+ export async function flushReinforcements(): Promise<void> {
651
+ if (_reinforceTimer) {
652
+ clearTimeout(_reinforceTimer);
653
+ _reinforceTimer = null;
654
+ }
655
+ const fragmentBatch = new Map(_pendingReinforce.fragments);
656
+ const knowledgeBatch = new Map(_pendingReinforce.knowledge);
657
+ _pendingReinforce.fragments.clear();
658
+ _pendingReinforce.knowledge.clear();
659
+ if (fragmentBatch.size === 0 && knowledgeBatch.size === 0) return;
660
+ const state = await getState();
661
+ const now = Date.now();
662
+ await applyReinforcements(state.fragments, fragmentBatch, now);
663
+ await applyReinforcements(state.consolidated, knowledgeBatch, now);
664
+ }
665
+
666
+ /** Pin/unpin a fragment. Pinned rows never decay and are never evicted. */
667
+ export async function setFragmentPinned(id: string, pinned: boolean): Promise<boolean> {
668
+ const state = await getState();
669
+ const exists = (await state.fragments.countRows(`id = ${sqlQuote(id)}`)) > 0;
670
+ if (!exists) return false;
671
+ await state.fragments.update({ where: `id = ${sqlQuote(id)}`, values: { pinned } });
672
+ return true;
673
+ }
674
+
675
+ // ─── Eviction sweep (§3.2) ───────────────────────────────────────────────────
676
+
677
+ export interface EvictionSweepOptions {
678
+ now?: number;
679
+ perNamespaceCap?: number;
680
+ weightThreshold?: number;
681
+ }
682
+
683
+ export interface EvictionSweepResult {
684
+ /** Consolidated, unpinned fragments deleted because w(t) < threshold. */
685
+ decayedDeleted: number;
686
+ /** Fragments evicted by the per-namespace hard cap (lowest-w first). */
687
+ capEvicted: number;
688
+ }
689
+
690
+ async function deleteFragmentIds(table: LanceTable, ids: string[]): Promise<void> {
691
+ const CHUNK = 200;
692
+ for (let i = 0; i < ids.length; i += CHUNK) {
693
+ const chunk = ids.slice(i, i + CHUNK);
694
+ await table.delete(`id IN (${chunk.map(sqlQuote).join(", ")})`);
695
+ }
696
+ }
697
+
698
+ /**
699
+ * Eviction sweep (runs hourly):
700
+ * - w(t) < threshold AND unpinned AND isConsolidated → delete (essence lives
701
+ * in the consolidated table).
702
+ * - Un-consolidated low-w fragments are left alone — consolidation drops
703
+ * them, never a silent delete.
704
+ * - Per-namespace hard cap (default 5000) with lowest-w eviction as backstop.
705
+ * - Pinned rows are never evicted.
706
+ */
707
+ export async function runEvictionSweep(opts: EvictionSweepOptions = {}): Promise<EvictionSweepResult> {
708
+ const now = opts.now ?? Date.now();
709
+ const cap = opts.perNamespaceCap ?? NAMESPACE_HARD_CAP;
710
+ const threshold = opts.weightThreshold ?? EVICTION_WEIGHT_THRESHOLD;
711
+ const state = await getState();
712
+
713
+ const rows = (await state.fragments.query().limit(200000).toArray()) as unknown as Record<
714
+ string,
715
+ unknown
716
+ >[];
717
+
718
+ const byNamespace = new Map<string, Array<{ id: string; weight: number; pinned: boolean; consolidated: boolean }>>();
719
+ for (const row of rows) {
720
+ const ns = rowStr(row.namespace) || "global";
721
+ const fragment = rowToFragment(row);
722
+ const weight = computeDecayedWeight(fragment, now, policyForNamespace(ns));
723
+ const list = byNamespace.get(ns) ?? [];
724
+ list.push({
725
+ id: fragment.id,
726
+ weight,
727
+ pinned: fragment.pinned === true,
728
+ consolidated: fragment.isConsolidated,
729
+ });
730
+ byNamespace.set(ns, list);
731
+ }
732
+
733
+ const decayedIds: string[] = [];
734
+ const capIds: string[] = [];
735
+ for (const [, list] of byNamespace) {
736
+ const remaining: typeof list = [];
737
+ for (const item of list) {
738
+ if (!item.pinned && item.consolidated && item.weight < threshold) {
739
+ decayedIds.push(item.id);
740
+ } else {
741
+ remaining.push(item);
742
+ }
743
+ }
744
+ if (remaining.length > cap) {
745
+ const evictable = remaining.filter((i) => !i.pinned).sort((a, b) => a.weight - b.weight);
746
+ const overflow = remaining.length - cap;
747
+ for (const item of evictable.slice(0, overflow)) capIds.push(item.id);
748
+ }
749
+ }
750
+
751
+ if (decayedIds.length > 0) await deleteFragmentIds(state.fragments, decayedIds);
752
+ if (capIds.length > 0) await deleteFragmentIds(state.fragments, capIds);
753
+ return { decayedDeleted: decayedIds.length, capEvicted: capIds.length };
754
+ }
755
+
756
+ // ─── Lazy re-embed queue (§3.5) ──────────────────────────────────────────────
757
+
758
+ /**
759
+ * Re-embed up to `maxItems` rows with embeddingStatus = "pending" (fragments
760
+ * first, then consolidated knowledge). Called at ≤ 2 req/s by the maintenance
761
+ * timer whenever a real provider is configured; also directly callable.
762
+ * Returns the number of rows re-embedded.
763
+ */
764
+ export async function processReembedBatch(maxItems = 2): Promise<number> {
765
+ if (getEmbeddingDim() === null) return 0; // no provider — nothing to do
766
+ const state = await getState();
767
+ let processed = 0;
768
+
769
+ for (const table of [state.fragments, state.consolidated]) {
770
+ if (processed >= maxItems) break;
771
+ const rows = (await table
772
+ .query()
773
+ .where("embeddingStatus = 'pending'")
774
+ .limit(maxItems - processed)
775
+ .toArray()) as unknown as Record<string, unknown>[];
776
+ for (const row of rows) {
777
+ const id = rowStr(row.id);
778
+ const text = rowStr(row.content) || rowStr(row.summary);
779
+ if (!text) {
780
+ // Nothing to embed — flip to "none" so we don't retry forever.
781
+ await table.update({ where: `id = ${sqlQuote(id)}`, values: { embeddingStatus: "none" } });
782
+ continue;
783
+ }
784
+ const vector = await embed(text);
785
+ if (!vector || vector.length !== state.meta.dim) return processed; // provider failing — retry next tick
786
+ await table.update({
787
+ where: `id = ${sqlQuote(id)}`,
788
+ values: { vector, embeddingStatus: "ok" },
789
+ });
790
+ processed++;
791
+ }
792
+ }
793
+ return processed;
794
+ }
795
+
796
+ // ─── Maintenance timers ──────────────────────────────────────────────────────
797
+
798
+ let _sweepTimer: ReturnType<typeof setInterval> | null = null;
799
+ let _reembedTimer: ReturnType<typeof setInterval> | null = null;
800
+
801
+ function ensureMaintenanceTimers(): void {
802
+ // Tests drive sweeps/re-embeds explicitly — background timers would make
803
+ // row states flip mid-assertion.
804
+ if (process.env.VITEST || process.env.NODE_ENV === "test") return;
805
+ if (!_sweepTimer) {
806
+ _sweepTimer = setInterval(() => {
807
+ runEvictionSweep().catch((err) => console.warn("[memory] eviction sweep failed:", err));
808
+ }, SWEEP_INTERVAL_MS);
809
+ _sweepTimer.unref?.();
810
+ }
811
+ if (!_reembedTimer) {
812
+ _reembedTimer = setInterval(() => {
813
+ processReembedBatch(2).catch((err) => console.warn("[memory] re-embed batch failed:", err));
814
+ }, REEMBED_INTERVAL_MS);
815
+ _reembedTimer.unref?.();
816
+ }
817
+ }
818
+
819
+ /** Stop background maintenance timers (shutdown / tests). */
820
+ export function stopMemoryMaintenance(): void {
821
+ if (_sweepTimer) {
822
+ clearInterval(_sweepTimer);
823
+ _sweepTimer = null;
824
+ }
825
+ if (_reembedTimer) {
826
+ clearInterval(_reembedTimer);
827
+ _reembedTimer = null;
828
+ }
829
+ }
830
+
831
+ // ─── Consolidated knowledge retrieval ────────────────────────────────────────
832
+
833
+ /**
834
+ * Active (non-superseded) consolidated knowledge for a namespace, optionally
835
+ * filtered by tag, ranked by confidence then recency.
836
+ */
837
+ export async function getKnowledgeByNamespace(
838
+ namespace: string,
839
+ tag?: string,
840
+ ): Promise<ConsolidatedKnowledge[]> {
841
+ const state = await getState();
842
+ const conds = [`namespace = ${sqlQuote(namespace)}`, "supersededBy = ''"];
843
+ if (tag) conds.push(`tag = ${sqlQuote(tag)}`);
844
+ const rows = (await state.consolidated
845
+ .query()
846
+ .where(conds.join(" AND "))
847
+ .limit(1000)
848
+ .toArray()) as unknown as Record<string, unknown>[];
849
+ return rows
850
+ .map(rowToConsolidated)
851
+ .sort((a, b) => b.confidence - a.confidence || b.lastUpdated - a.lastUpdated);
852
+ }
853
+
854
+ /**
855
+ * Retrieve consolidated knowledge for a repo, optionally filtered by tag
856
+ * (legacy-compatible API).
300
857
  *
301
- * Note: Summary synthesis uses simple concatenation by default.
302
- * In production, replace the `synthesize` function with an LLM call.
858
+ * v2 behavior change (§1.8 fix): repoRoot === "" now means the `global`
859
+ * namespace instead of matching literally-empty repoRoot rows, so existing
860
+ * callers that pass "" get cross-repo knowledge rather than nothing.
303
861
  */
304
- export async function consolidateSession(
862
+ export async function getConsolidatedKnowledge(
863
+ repoRoot: string,
864
+ tag?: string,
865
+ ): Promise<ConsolidatedKnowledge[]> {
866
+ const namespace = repoRoot ? repoNamespace(repoRoot) : "global";
867
+ return getKnowledgeByNamespace(namespace, tag);
868
+ }
869
+
870
+ // ─── Consolidation support APIs (§3.4 Stage 1/3) ─────────────────────────────
871
+
872
+ /**
873
+ * Un-consolidated fragments for a namespace (e.g. "session:abc", "repo:<hash>")
874
+ * or a bare session id, newest first. Embeddings are included (when status is
875
+ * "ok") so Stage-1 JUDGE can cluster/dedupe without re-embedding.
876
+ */
877
+ export async function getUnconsolidatedFragments(
878
+ namespaceOrSessionId: string,
879
+ limit = 200,
880
+ ): Promise<MemoryFragment[]> {
881
+ const state = await getState();
882
+ const scopeCond = isNamespaceString(namespaceOrSessionId)
883
+ ? `namespace = ${sqlQuote(namespaceOrSessionId)}`
884
+ : `sessionId = ${sqlQuote(namespaceOrSessionId)}`;
885
+ const rows = (await state.fragments
886
+ .query()
887
+ .where(`${scopeCond} AND isConsolidated = false`)
888
+ .limit(10000)
889
+ .toArray()) as unknown as Record<string, unknown>[];
890
+ return rows
891
+ .map(rowToFragment)
892
+ .sort((a, b) => b.timestamp - a.timestamp)
893
+ .slice(0, limit);
894
+ }
895
+
896
+ /**
897
+ * Mark source fragments as consolidated into a knowledge row (§1.2 fix —
898
+ * makes isConsolidated/consolidatedInto live state).
899
+ */
900
+ export async function markFragmentsConsolidated(ids: string[], knowledgeId: string): Promise<void> {
901
+ if (ids.length === 0) return;
902
+ const state = await getState();
903
+ const CHUNK = 200;
904
+ for (let i = 0; i < ids.length; i += CHUNK) {
905
+ const chunk = ids.slice(i, i + CHUNK);
906
+ await state.fragments.update({
907
+ where: `id IN (${chunk.map(sqlQuote).join(", ")})`,
908
+ values: { isConsolidated: true, consolidatedInto: knowledgeId },
909
+ });
910
+ }
911
+ }
912
+
913
+ export interface DistilledKnowledgeItem {
914
+ tag: string;
915
+ type?: KnowledgeType;
916
+ summary: string;
917
+ confidence: number;
918
+ sourceFragmentIds: string[];
919
+ /** Existing knowledge ids this item replaces (tombstoned with supersededBy). */
920
+ supersedes?: string[];
921
+ /** Namespace class from the distillation output ("repo" | "global" | "agent") or a full namespace string. */
922
+ namespace: string;
923
+ }
924
+
925
+ export interface DistillationContext {
926
+ sessionId: string;
927
+ repoRoot: string;
928
+ backendType: string;
929
+ /** Defaults to "llm"; the concat fallback passes "concat". */
930
+ synthesisMethod?: SynthesisMethod;
931
+ }
932
+
933
+ function resolveKnowledgeNamespace(nsField: string, ctx: DistillationContext): string {
934
+ if (isNamespaceString(nsField)) return nsField;
935
+ if (nsField === "repo") return ctx.repoRoot ? repoNamespace(ctx.repoRoot) : "global";
936
+ if (nsField === "agent") return agentNamespace(ctx.backendType);
937
+ if (nsField === "session") return sessionNamespace(ctx.sessionId);
938
+ return "global";
939
+ }
940
+
941
+ /**
942
+ * Stage-3 CONSOLIDATE (§3.4): upsert distilled knowledge by (namespace, tag).
943
+ * - Any active row with the same (namespace, tag), plus any rows named in
944
+ * `supersedes`, is tombstoned with supersededBy = <new id> (audit trail).
945
+ * - The new summary is embedded (status "pending" if that fails/no provider).
946
+ * - All sourceFragmentIds are marked consolidated into the new row.
947
+ * Idempotent: re-running with the same (namespace, tag) replaces, never duplicates.
948
+ */
949
+ export async function upsertKnowledgeFromDistillation(
950
+ items: DistilledKnowledgeItem[],
951
+ ctx: DistillationContext,
952
+ ): Promise<ConsolidatedKnowledge[]> {
953
+ const state = await getState();
954
+ const dim = state.meta.dim;
955
+ const results: ConsolidatedKnowledge[] = [];
956
+ const synthesisMethod: SynthesisMethod = ctx.synthesisMethod ?? "llm";
957
+
958
+ for (const item of items) {
959
+ const namespace = resolveKnowledgeNamespace(item.namespace, ctx);
960
+ const cls = namespaceClass(namespace);
961
+ const repoRoot = cls === "repo" ? ctx.repoRoot : "";
962
+ const newId = randomUUID();
963
+ const now = Date.now();
964
+
965
+ // Tombstone: same (namespace, tag) active rows + explicit supersedes list.
966
+ await state.consolidated.update({
967
+ where: `namespace = ${sqlQuote(namespace)} AND tag = ${sqlQuote(item.tag)} AND supersededBy = ''`,
968
+ values: { supersededBy: newId },
969
+ });
970
+ const supersedes = (item.supersedes ?? []).filter(Boolean);
971
+ if (supersedes.length > 0) {
972
+ await state.consolidated.update({
973
+ where: `id IN (${supersedes.map(sqlQuote).join(", ")})`,
974
+ values: { supersededBy: newId },
975
+ });
976
+ }
977
+
978
+ const embedding = await embed(item.summary);
979
+ let status: EmbeddingStatus;
980
+ let vector: number[];
981
+ if (embedding && embedding.length === dim) {
982
+ status = "ok";
983
+ vector = embedding;
984
+ } else if (getEmbeddingDim() === null) {
985
+ status = "none";
986
+ vector = zeros(dim);
987
+ } else {
988
+ status = "pending";
989
+ vector = zeros(dim);
990
+ }
991
+
992
+ await state.consolidated.add([
993
+ {
994
+ id: newId,
995
+ tag: item.tag,
996
+ summary: item.summary,
997
+ sourceFragmentsJson: JSON.stringify(item.sourceFragmentIds),
998
+ lastUpdated: now,
999
+ confidence: item.confidence,
1000
+ repoRoot,
1001
+ namespace,
1002
+ repoRootHash: repoRoot ? hashRepoRoot(repoRoot) : "",
1003
+ knowledgeType: item.type ?? "",
1004
+ synthesisMethod,
1005
+ supersededBy: "",
1006
+ accessCount: 0,
1007
+ lastReinforcedAt: now,
1008
+ embeddingStatus: status,
1009
+ vector,
1010
+ },
1011
+ ]);
1012
+
1013
+ await markFragmentsConsolidated(item.sourceFragmentIds, newId);
1014
+
1015
+ results.push({
1016
+ id: newId,
1017
+ tag: item.tag,
1018
+ summary: item.summary,
1019
+ sourceFragments: item.sourceFragmentIds,
1020
+ lastUpdated: now,
1021
+ confidence: item.confidence,
1022
+ repoRoot,
1023
+ namespace,
1024
+ type: item.type,
1025
+ synthesisMethod,
1026
+ supersededBy: undefined,
1027
+ });
1028
+ }
1029
+
1030
+ return results;
1031
+ }
1032
+
1033
+ /**
1034
+ * Active knowledge in a namespace whose embedding is within `threshold`
1035
+ * cosine similarity of a centroid (§3.4 Stage 2 — `existingKnowledge` input).
1036
+ * Accepts a precomputed centroid vector or texts to embed-and-average.
1037
+ * Returns [] when no provider/vectors are available.
1038
+ */
1039
+ export async function findRelatedKnowledge(
1040
+ namespace: string,
1041
+ centroidVectorOrTexts: number[] | string[],
1042
+ threshold = 0.8,
1043
+ ): Promise<ConsolidatedKnowledge[]> {
1044
+ const state = await getState();
1045
+ if (centroidVectorOrTexts.length === 0) return [];
1046
+
1047
+ let centroid: number[] | null = null;
1048
+ if (typeof centroidVectorOrTexts[0] === "number") {
1049
+ centroid = centroidVectorOrTexts as number[];
1050
+ } else {
1051
+ const vectors: number[][] = [];
1052
+ for (const text of centroidVectorOrTexts as string[]) {
1053
+ const v = await embed(text);
1054
+ if (v && v.length === state.meta.dim) vectors.push(v);
1055
+ }
1056
+ if (vectors.length > 0) {
1057
+ centroid = zeros(state.meta.dim);
1058
+ for (const v of vectors) for (let i = 0; i < v.length; i++) centroid[i] += v[i];
1059
+ for (let i = 0; i < centroid.length; i++) centroid[i] /= vectors.length;
1060
+ }
1061
+ }
1062
+ if (!centroid || centroid.length !== state.meta.dim) return [];
1063
+
1064
+ const rows = (await state.consolidated
1065
+ .search(centroid)
1066
+ .where(`namespace = ${sqlQuote(namespace)} AND supersededBy = '' AND embeddingStatus = 'ok'`)
1067
+ .limit(50)
1068
+ .toArray()) as unknown as Record<string, unknown>[];
1069
+ return rows
1070
+ .filter((row) => cosineSimilarity(centroid as number[], toNumberArray(row.vector)) >= threshold)
1071
+ .map(rowToConsolidated);
1072
+ }
1073
+
1074
+ /**
1075
+ * No-LLM consolidation fallback: today's concatenation synthesis, grouped by
1076
+ * tag, upserted by (namespace, tag) with synthesisMethod = "concat" and all
1077
+ * sources marked consolidated. Used when no OpenRouter key is configured or
1078
+ * when distillation output fails validation (§3.4).
1079
+ */
1080
+ export async function concatFallbackConsolidate(
305
1081
  sessionId: string,
306
1082
  repoRoot: string,
1083
+ backendType = "claude",
307
1084
  ): Promise<ConsolidatedKnowledge[]> {
308
- const fragments = await getSessionFragments(sessionId);
1085
+ const fragments = await getUnconsolidatedFragments(sessionId, 10000);
309
1086
  if (fragments.length === 0) return [];
310
1087
 
311
- // Group by tag
312
1088
  const byTag = new Map<string, MemoryFragment[]>();
313
1089
  for (const f of fragments) {
314
1090
  for (const tag of f.tags.length > 0 ? f.tags : ["general"]) {
@@ -317,57 +1093,212 @@ export async function consolidateSession(
317
1093
  }
318
1094
  }
319
1095
 
320
- const table = await getConsolidatedTable();
321
- const results: ConsolidatedKnowledge[] = [];
322
-
1096
+ const items: DistilledKnowledgeItem[] = [];
323
1097
  for (const [tag, tagFragments] of byTag) {
324
- const avgConfidence = tagFragments.reduce((s, f) => s + f.confidence, 0) / tagFragments.length;
325
- const summary = synthesize(tag, tagFragments);
326
- const knowledge: ConsolidatedKnowledge = {
327
- id: randomUUID(),
1098
+ const avgConfidence =
1099
+ tagFragments.reduce((s, f) => s + f.confidence, 0) / tagFragments.length;
1100
+ items.push({
328
1101
  tag,
329
- summary,
330
- sourceFragments: tagFragments.map((f) => f.id),
331
- lastUpdated: Date.now(),
1102
+ summary: synthesize(tag, tagFragments),
332
1103
  confidence: avgConfidence,
333
- repoRoot,
334
- };
335
-
336
- await table.add([{
337
- id: knowledge.id,
338
- tag: knowledge.tag,
339
- summary: knowledge.summary,
340
- sourceFragmentsJson: JSON.stringify(knowledge.sourceFragments),
341
- lastUpdated: knowledge.lastUpdated,
342
- confidence: knowledge.confidence,
343
- repoRoot: knowledge.repoRoot,
344
- }]);
345
-
346
- results.push(knowledge);
1104
+ sourceFragmentIds: tagFragments.map((f) => f.id),
1105
+ namespace: repoRoot ? "repo" : "global",
1106
+ });
347
1107
  }
348
1108
 
349
- return results;
1109
+ return upsertKnowledgeFromDistillation(items, {
1110
+ sessionId,
1111
+ repoRoot,
1112
+ backendType,
1113
+ synthesisMethod: "concat",
1114
+ });
350
1115
  }
351
1116
 
352
1117
  /**
353
- * Retrieve consolidated knowledge for a repo, optionally filtered by tag.
1118
+ * Consolidate episodic fragments from a session into semantic knowledge
1119
+ * (legacy-compatible API — routes to the v2 concat fallback).
1120
+ *
1121
+ * v2 (§1.2 fix): idempotent — upserts by (namespace, tag), tombstones the
1122
+ * replaced rows, and marks source fragments isConsolidated/consolidatedInto.
1123
+ * Running twice no longer duplicates rows (the second run sees no
1124
+ * un-consolidated fragments and returns []).
354
1125
  */
355
- export async function getConsolidatedKnowledge(
1126
+ export async function consolidateSession(
1127
+ sessionId: string,
356
1128
  repoRoot: string,
357
- tag?: string,
358
1129
  ): Promise<ConsolidatedKnowledge[]> {
359
- const table = await getConsolidatedTable();
360
- const result = await table.query().limit(1000).toArray();
361
- return (result as unknown as Record<string, unknown>[])
362
- .map(rowToConsolidated)
363
- .filter((k) => k.repoRoot === repoRoot && (!tag || k.tag === tag));
1130
+ return concatFallbackConsolidate(sessionId, repoRoot);
1131
+ }
1132
+
1133
+ // ─── Enrichment entry point (§3.3 + §3.6.2) ──────────────────────────────────
1134
+
1135
+ export interface EnrichmentQueryOptions {
1136
+ sessionId: string;
1137
+ repoRoot: string;
1138
+ backendType: string;
1139
+ queryText: string;
1140
+ }
1141
+
1142
+ export interface EnrichmentResult {
1143
+ items: MemoryEnrichmentItem[];
1144
+ /** Injectable block per §3.6.2, or null when nothing was recalled. */
1145
+ block: string | null;
364
1146
  }
365
1147
 
366
- // ─── Internal helpers ─────────────────────────────────────────────────────────
1148
+ const ENRICHMENT_HEADER = "--- Campfire memory (auto-recalled; may be stale) ---";
1149
+ const ENRICHMENT_FOOTER = "--- end memory ---";
1150
+ const LINE_CLIP_CHARS = 300;
1151
+
1152
+ function clip(text: string, max: number): string {
1153
+ const oneLine = text.replace(/\s+/g, " ").trim();
1154
+ return oneLine.length <= max ? oneLine : `${oneLine.slice(0, max - 1)}…`;
1155
+ }
367
1156
 
368
1157
  /**
369
- * Simple synthesis: concatenate fragment contents into a bullet list.
370
- * Replace with an LLM call for production-quality summaries.
1158
+ * Query memory for prompt enrichment. Namespace recall order per §3.6.2:
1159
+ * fragments query [repo:<hash>, agent:<backend>, global] NOT session:<id>,
1160
+ * since same-session context is already in the agent's own conversation.
1161
+ * Consolidated knowledge (same namespaces) is ranked above fragments.
1162
+ *
1163
+ * Budgets: ≤ ~1200 chars of fragment text, ≤ ~2000 chars total, max 5
1164
+ * fragment lines. Every included row is reinforced (§3.2). Returns the exact
1165
+ * injectable block plus the item list for the UI `memory_enriched` chip.
1166
+ */
1167
+ export async function queryForEnrichment(opts: EnrichmentQueryOptions): Promise<EnrichmentResult> {
1168
+ const depths = getMemorySettings().recallDepth;
1169
+ const now = Date.now();
1170
+ const nsRepo = opts.repoRoot ? repoNamespace(opts.repoRoot) : null;
1171
+ const nsAgent = agentNamespace(opts.backendType);
1172
+
1173
+ // Consolidated knowledge, priority order: repo → agent → global.
1174
+ const knowledgeNamespaces = [nsRepo, nsAgent, "global"].filter((n): n is string => !!n);
1175
+ const knowledgeCandidates: ConsolidatedKnowledge[] = [];
1176
+ for (const ns of knowledgeNamespaces) {
1177
+ knowledgeCandidates.push(...(await getKnowledgeByNamespace(ns)));
1178
+ }
1179
+
1180
+ // Fragments: repo/agent/global with per-namespace recall depths (§3.1).
1181
+ const plan: RecallPlanEntry[] = [];
1182
+ if (nsRepo) plan.push({ namespace: nsRepo, depth: depths.repo });
1183
+ plan.push({ namespace: nsAgent, depth: depths.agent });
1184
+ plan.push({ namespace: "global", depth: depths.global });
1185
+ const scoredFragments = await queryScoredFragments(opts.queryText, plan, now);
1186
+
1187
+ // Assemble under budgets. Header/footer + section labels count toward total.
1188
+ const items: MemoryEnrichmentItem[] = [];
1189
+ const knowledgeLines: string[] = [];
1190
+ const fragmentLines: string[] = [];
1191
+ const baseOverhead =
1192
+ ENRICHMENT_HEADER.length + ENRICHMENT_FOOTER.length + "Knowledge:".length + "Notes:".length + 4;
1193
+ let totalChars = baseOverhead;
1194
+
1195
+ for (const k of knowledgeCandidates) {
1196
+ const line = `- [${k.tag}] ${clip(k.summary, LINE_CLIP_CHARS)}`;
1197
+ if (totalChars + line.length + 1 > TOTAL_BUDGET_CHARS) break;
1198
+ knowledgeLines.push(line);
1199
+ totalChars += line.length + 1;
1200
+ items.push({
1201
+ id: k.id,
1202
+ kind: "knowledge",
1203
+ namespace: k.namespace ?? "",
1204
+ summary: k.summary,
1205
+ tag: k.tag,
1206
+ weight: 1, // consolidated knowledge does not decay (§3.2)
1207
+ });
1208
+ }
1209
+
1210
+ let fragmentChars = 0;
1211
+ for (const s of scoredFragments) {
1212
+ if (fragmentLines.length >= MAX_ENRICHMENT_FRAGMENTS) break;
1213
+ if (s.score <= 0) continue;
1214
+ const line = `- [${s.fragment.type}] ${clip(s.fragment.content, LINE_CLIP_CHARS)}`;
1215
+ if (fragmentChars + line.length + 1 > FRAGMENT_BUDGET_CHARS) break;
1216
+ if (totalChars + line.length + 1 > TOTAL_BUDGET_CHARS) break;
1217
+ fragmentLines.push(line);
1218
+ fragmentChars += line.length + 1;
1219
+ totalChars += line.length + 1;
1220
+ items.push({
1221
+ id: s.fragment.id,
1222
+ kind: "fragment",
1223
+ namespace: s.fragment.namespace ?? "",
1224
+ summary: clip(s.fragment.content, LINE_CLIP_CHARS),
1225
+ tag: s.fragment.tags[0],
1226
+ weight: s.weight,
1227
+ });
1228
+ }
1229
+
1230
+ if (items.length === 0) return { items: [], block: null };
1231
+
1232
+ const parts: string[] = [ENRICHMENT_HEADER];
1233
+ if (knowledgeLines.length > 0) parts.push("Knowledge:", ...knowledgeLines);
1234
+ if (fragmentLines.length > 0) parts.push("Notes:", ...fragmentLines);
1235
+ parts.push(ENRICHMENT_FOOTER);
1236
+
1237
+ // Reinforce exactly what was included (§3.2: inclusion reinforces, matching doesn't).
1238
+ reinforceFragments(items.filter((i) => i.kind === "fragment").map((i) => i.id));
1239
+ reinforceKnowledge(items.filter((i) => i.kind === "knowledge").map((i) => i.id));
1240
+
1241
+ return { items, block: parts.join("\n") };
1242
+ }
1243
+
1244
+ // ─── Namespace overview (UI) ─────────────────────────────────────────────────
1245
+
1246
+ export interface NamespaceOverviewEntry {
1247
+ namespace: string;
1248
+ count: number;
1249
+ avgWeight: number;
1250
+ pinnedCount: number;
1251
+ }
1252
+
1253
+ /**
1254
+ * Per-namespace fragment stats for the memory panel: count, average decayed
1255
+ * weight, and pinned count, over [session:<id>, repo:<hash>, agent:<backend>, global].
1256
+ */
1257
+ export async function getNamespaceOverview(opts: {
1258
+ sessionId: string;
1259
+ repoRoot: string;
1260
+ backendType: string;
1261
+ }): Promise<NamespaceOverviewEntry[]> {
1262
+ const state = await getState();
1263
+ const now = Date.now();
1264
+ const namespaces = [
1265
+ sessionNamespace(opts.sessionId),
1266
+ ...(opts.repoRoot ? [repoNamespace(opts.repoRoot)] : []),
1267
+ agentNamespace(opts.backendType),
1268
+ "global",
1269
+ ];
1270
+
1271
+ const overview: NamespaceOverviewEntry[] = [];
1272
+ for (const namespace of namespaces) {
1273
+ const rows = (await state.fragments
1274
+ .query()
1275
+ .where(buildNamespaceWhere(namespace, false))
1276
+ .limit(100000)
1277
+ .toArray()) as unknown as Record<string, unknown>[];
1278
+ const policy = policyForNamespace(namespace);
1279
+ let weightSum = 0;
1280
+ let pinnedCount = 0;
1281
+ for (const row of rows) {
1282
+ const fragment = rowToFragment(row);
1283
+ weightSum += computeDecayedWeight(fragment, now, policy);
1284
+ if (fragment.pinned) pinnedCount++;
1285
+ }
1286
+ overview.push({
1287
+ namespace,
1288
+ count: rows.length,
1289
+ avgWeight: rows.length > 0 ? weightSum / rows.length : 0,
1290
+ pinnedCount,
1291
+ });
1292
+ }
1293
+ return overview;
1294
+ }
1295
+
1296
+ // ─── Internal helpers ────────────────────────────────────────────────────────
1297
+
1298
+ /**
1299
+ * Concatenation synthesis — the no-LLM fallback (§3.4). Kept from v1; the
1300
+ * LLM distillation pipeline (memory-consolidation.ts) replaces this as the
1301
+ * primary path.
371
1302
  */
372
1303
  function synthesize(tag: string, fragments: MemoryFragment[]): string {
373
1304
  const sorted = [...fragments].sort((a, b) => b.confidence - a.confidence);
@@ -380,10 +1311,19 @@ function synthesize(tag: string, fragments: MemoryFragment[]): string {
380
1311
 
381
1312
  // ─── Test helpers ─────────────────────────────────────────────────────────────
382
1313
 
383
- /** Override memory dir for tests — must be called before any DB access. */
1314
+ /**
1315
+ * Override the memory root dir for tests — must be called before any DB access.
1316
+ * v2 note: the argument is the memory ROOT (meta.json lives here; LanceDB in
1317
+ * <dir>/lancedb), where v1 treated it as the LanceDB dir itself.
1318
+ */
384
1319
  export function _resetForTest(dir: string): void {
385
- _db = null;
386
- _fragmentsTable = null;
387
- _consolidatedTable = null;
388
- _memoryDir = dir;
1320
+ _initPromise = null;
1321
+ _memoryRoot = dir;
1322
+ stopMemoryMaintenance();
1323
+ if (_reinforceTimer) {
1324
+ clearTimeout(_reinforceTimer);
1325
+ _reinforceTimer = null;
1326
+ }
1327
+ _pendingReinforce.fragments.clear();
1328
+ _pendingReinforce.knowledge.clear();
389
1329
  }