the-campfire 0.2.0 → 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 (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2388 -0
  3. package/dist/assets/{MermaidDiagram-D_MwUXi3.js → MermaidDiagram-Cas1Ngze.js} +2 -2
  4. package/dist/assets/{_basePickBy-DsMRBmhy.js → _basePickBy-D1h3RRT9.js} +1 -1
  5. package/dist/assets/{_baseUniq-CZ0BR5Mx.js → _baseUniq-Z9p6uaM9.js} +1 -1
  6. package/dist/assets/{arc-D6Y0ooDg.js → arc-DJLNkmre.js} +1 -1
  7. package/dist/assets/{architectureDiagram-VXUJARFQ-3T5ZoEn1.js → architectureDiagram-VXUJARFQ-CBHHYg8R.js} +1 -1
  8. package/dist/assets/{blockDiagram-VD42YOAC-CvOCnB8f.js → blockDiagram-VD42YOAC-DMtvAHPO.js} +1 -1
  9. package/dist/assets/{c4Diagram-YG6GDRKO-DPXJvInm.js → c4Diagram-YG6GDRKO-G9Zj2LWB.js} +1 -1
  10. package/dist/assets/channel-CcCVj0rn.js +1 -0
  11. package/dist/assets/{chunk-4BX2VUAB-DPHGuPB4.js → chunk-4BX2VUAB-s3svQOtk.js} +1 -1
  12. package/dist/assets/{chunk-55IACEB6-BbI83gIK.js → chunk-55IACEB6-C6fhtS_L.js} +1 -1
  13. package/dist/assets/{chunk-B4BG7PRW-DGvVY1Y_.js → chunk-B4BG7PRW-CpePj3eQ.js} +1 -1
  14. package/dist/assets/{chunk-DI55MBZ5-2YN0FhPB.js → chunk-DI55MBZ5-Dq9AChBD.js} +1 -1
  15. package/dist/assets/{chunk-FMBD7UC4-Ctqs0bWj.js → chunk-FMBD7UC4-DSOMddvX.js} +1 -1
  16. package/dist/assets/{chunk-QN33PNHL-CfvfTLi4.js → chunk-QN33PNHL-ZrdWE4nY.js} +1 -1
  17. package/dist/assets/{chunk-QZHKN3VN-C-dG1NiD.js → chunk-QZHKN3VN-BqLE4kwR.js} +1 -1
  18. package/dist/assets/{chunk-TZMSLE5B-Bh07YiOi.js → chunk-TZMSLE5B-DY7GliuX.js} +1 -1
  19. package/dist/assets/classDiagram-2ON5EDUG-8I6xWkSV.js +1 -0
  20. package/dist/assets/classDiagram-v2-WZHVMYZB-8I6xWkSV.js +1 -0
  21. package/dist/assets/clone-zAvPgvOP.js +1 -0
  22. package/dist/assets/{cose-bilkent-S5V4N54A-CTC2n8Hd.js → cose-bilkent-S5V4N54A-DmRYvz2l.js} +1 -1
  23. package/dist/assets/{dagre-6UL2VRFP-CAgGZKYt.js → dagre-6UL2VRFP-D4IfaYgb.js} +1 -1
  24. package/dist/assets/{diagram-PSM6KHXK-DymwxXvC.js → diagram-PSM6KHXK-ClfgeCpw.js} +1 -1
  25. package/dist/assets/{diagram-QEK2KX5R-CAEL5_ej.js → diagram-QEK2KX5R-BaBfarh-.js} +1 -1
  26. package/dist/assets/{diagram-S2PKOQOG-B6onWI1I.js → diagram-S2PKOQOG-DT1eYmOM.js} +1 -1
  27. package/dist/assets/{erDiagram-Q2GNP2WA-DP-dbxgu.js → erDiagram-Q2GNP2WA-C5ZVoHhn.js} +1 -1
  28. package/dist/assets/{flowDiagram-NV44I4VS-OMmA9Rvb.js → flowDiagram-NV44I4VS-C9MYAakn.js} +1 -1
  29. package/dist/assets/{ganttDiagram-JELNMOA3-B2oAHD8b.js → ganttDiagram-JELNMOA3-HHt6rJj9.js} +1 -1
  30. package/dist/assets/{gitGraphDiagram-V2S2FVAM-CAZO9ISt.js → gitGraphDiagram-V2S2FVAM-CfA3qwoT.js} +1 -1
  31. package/dist/assets/{graph-CeeDmBlM.js → graph-BLJloKEi.js} +1 -1
  32. package/dist/assets/index-CvOAfVHK.css +32 -0
  33. package/dist/assets/{index-CAGJQXdJ.js → index-CxNtY8EJ.js} +82 -82
  34. package/dist/assets/{infoDiagram-HS3SLOUP-m0CP23H-.js → infoDiagram-HS3SLOUP-ASXl-hwl.js} +1 -1
  35. package/dist/assets/{journeyDiagram-XKPGCS4Q-C9WvzT-H.js → journeyDiagram-XKPGCS4Q-B-wDjjzW.js} +1 -1
  36. package/dist/assets/{kanban-definition-3W4ZIXB7-KIEeugzr.js → kanban-definition-3W4ZIXB7-BNWLVKm2.js} +1 -1
  37. package/dist/assets/{layout-P-20nhx7.js → layout-cpBTqNBI.js} +1 -1
  38. package/dist/assets/{linear-CMM5aRIM.js → linear-DAonGknK.js} +1 -1
  39. package/dist/assets/{mermaid.core-Z9YQUdi5.js → mermaid.core-BdetWGOO.js} +4 -4
  40. package/dist/assets/{mindmap-definition-VGOIOE7T-B-NyLDuB.js → mindmap-definition-VGOIOE7T-CJOEqJKm.js} +1 -1
  41. package/dist/assets/{pieDiagram-ADFJNKIX-Dp8zgF7X.js → pieDiagram-ADFJNKIX-Dnc1ekZC.js} +1 -1
  42. package/dist/assets/{quadrantDiagram-AYHSOK5B-Cs07PJ50.js → quadrantDiagram-AYHSOK5B-Dr56Xspi.js} +1 -1
  43. package/dist/assets/{requirementDiagram-UZGBJVZJ-CWMm228C.js → requirementDiagram-UZGBJVZJ-z-G-ThCz.js} +1 -1
  44. package/dist/assets/{sankeyDiagram-TZEHDZUN-Cf7zGgTA.js → sankeyDiagram-TZEHDZUN-D_4p6Me1.js} +1 -1
  45. package/dist/assets/{sequenceDiagram-WL72ISMW-TzZvjAhX.js → sequenceDiagram-WL72ISMW-DD1K4OoS.js} +1 -1
  46. package/dist/assets/{stateDiagram-FKZM4ZOC-CH45r52Y.js → stateDiagram-FKZM4ZOC-ENIvCriI.js} +1 -1
  47. package/dist/assets/stateDiagram-v2-4FDKWEC3-BgJoZY9E.js +1 -0
  48. package/dist/assets/{timeline-definition-IT6M3QCI-CLaDCX_H.js → timeline-definition-IT6M3QCI-V8I1MBPc.js} +1 -1
  49. package/dist/assets/{treemap-GDKQZRPO-BNNAKE7E.js → treemap-GDKQZRPO-1lnlvkR3.js} +1 -1
  50. package/dist/assets/{xychartDiagram-PRI3JC2R-BG_0_QvU.js → xychartDiagram-PRI3JC2R-CrSrN3UV.js} +1 -1
  51. package/dist/index.html +2 -2
  52. package/package.json +2 -2
  53. package/server/collective-intelligence.ts +125 -43
  54. package/server/embedding.ts +12 -3
  55. package/server/memory-consolidation.ts +703 -0
  56. package/server/memory-migration.ts +468 -0
  57. package/server/routes/ci-routes.ts +68 -10
  58. package/server/routes/settings-routes.ts +10 -2
  59. package/server/semantic-memory.ts +1127 -187
  60. package/server/session-types.ts +15 -0
  61. package/server/settings-manager.ts +122 -3
  62. package/server/ws-bridge.ts +207 -26
  63. package/dist/assets/channel-D6cof2B8.js +0 -1
  64. package/dist/assets/classDiagram-2ON5EDUG-BG_dxrC_.js +0 -1
  65. package/dist/assets/classDiagram-v2-WZHVMYZB-BG_dxrC_.js +0 -1
  66. package/dist/assets/clone-vuZ4vhTG.js +0 -1
  67. package/dist/assets/index-Byjg9zgI.css +0 -32
  68. package/dist/assets/stateDiagram-v2-4FDKWEC3-DaPUshfK.js +0 -1
@@ -0,0 +1,468 @@
1
+ /**
2
+ * Semantic memory schema v2 — meta.json versioning, namespaces, and the
3
+ * v1 → v2 / dimension-change migrations (design doc §3.1 and §3.5).
4
+ *
5
+ * Layout under the memory root (default ~/.campfire/memory/):
6
+ * meta.json — { schemaVersion, embeddingProvider, dim, active table names }
7
+ * lancedb/ — LanceDB database directory (tables: fragments_v2, consolidated_v2,
8
+ * fragments_v2_<dim> after a dimension change, plus retained v1
9
+ * tables "fragments"/"consolidated" kept as backups)
10
+ *
11
+ * Migration properties (per §3.5):
12
+ * - Versioned tables, never in-place ALTER.
13
+ * - v1 rows are copied with namespace backfill (repoRoot → repo:<hash>, else
14
+ * session:<sessionId>; consolidated rows with repoRoot === "" → global).
15
+ * - Zero-vector rows are detected and marked embeddingStatus = "pending" so
16
+ * they are excluded from ANN (§1.6 fix) and lazily re-embedded.
17
+ * - v1 tables are retained untouched as backups. (The installed LanceDB SDK
18
+ * has no renameTable, so instead of renaming to *_v1_backup we simply never
19
+ * open them again once meta.schemaVersion >= 2.)
20
+ * - meta.json is written only after a successful copy, so an interrupted
21
+ * migration re-runs from scratch (partially-copied v2 tables are dropped).
22
+ * - Dimension/provider changes create a new active table fragments_v2_<dim>
23
+ * and mark all rows "pending" for the lazy re-embed queue.
24
+ */
25
+
26
+ import { createHash } from "node:crypto";
27
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
28
+ import { join } from "node:path";
29
+
30
+ // ─── Namespace model (§3.1) ──────────────────────────────────────────────────
31
+
32
+ export type NamespaceClass = "global" | "repo" | "session" | "agent";
33
+
34
+ /** Short SHA-256 of the absolute repo root — stable, path-privacy-friendly, safe in where() strings. */
35
+ export function hashRepoRoot(repoRoot: string): string {
36
+ return createHash("sha256").update(repoRoot).digest("hex").slice(0, 16);
37
+ }
38
+
39
+ export function repoNamespace(repoRoot: string): string {
40
+ return `repo:${hashRepoRoot(repoRoot)}`;
41
+ }
42
+
43
+ export function sessionNamespace(sessionId: string): string {
44
+ return `session:${sessionId}`;
45
+ }
46
+
47
+ export function agentNamespace(backendType: string): string {
48
+ return `agent:${backendType}`;
49
+ }
50
+
51
+ /** Classify a namespace string into its decay-policy class. Unknown prefixes fall back to "global". */
52
+ export function namespaceClass(namespace: string): NamespaceClass {
53
+ if (namespace.startsWith("repo:")) return "repo";
54
+ if (namespace.startsWith("session:")) return "session";
55
+ if (namespace.startsWith("agent:")) return "agent";
56
+ return "global";
57
+ }
58
+
59
+ /** True when the string is a namespace (vs. a bare session id). */
60
+ export function isNamespaceString(value: string): boolean {
61
+ return value === "global" || value.includes(":");
62
+ }
63
+
64
+ // ─── meta.json ───────────────────────────────────────────────────────────────
65
+
66
+ export const MEMORY_SCHEMA_VERSION = 2;
67
+
68
+ /** Vector-column width used when no embedding provider is configured. */
69
+ export const NO_PROVIDER_DIM = 1;
70
+
71
+ export interface MemoryMeta {
72
+ schemaVersion: number;
73
+ embeddingProvider: string;
74
+ /** Width of the vector column on the active tables. */
75
+ dim: number;
76
+ activeFragmentsTable: string;
77
+ activeConsolidatedTable: string;
78
+ /** v1 table names retained as backups (never opened again). */
79
+ v1BackupTables?: string[];
80
+ migratedFromV1?: boolean;
81
+ updatedAt: number;
82
+ }
83
+
84
+ export function metaPath(memoryRoot: string): string {
85
+ return join(memoryRoot, "meta.json");
86
+ }
87
+
88
+ export function readMemoryMeta(memoryRoot: string): MemoryMeta | null {
89
+ try {
90
+ const p = metaPath(memoryRoot);
91
+ if (!existsSync(p)) return null;
92
+ const raw = JSON.parse(readFileSync(p, "utf-8")) as Partial<MemoryMeta>;
93
+ if (typeof raw?.schemaVersion !== "number") return null;
94
+ return raw as MemoryMeta;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ export function writeMemoryMeta(memoryRoot: string, meta: MemoryMeta): void {
101
+ mkdirSync(memoryRoot, { recursive: true });
102
+ writeFileSync(metaPath(memoryRoot), JSON.stringify(meta, null, 2), "utf-8");
103
+ }
104
+
105
+ // ─── Vector helpers ──────────────────────────────────────────────────────────
106
+
107
+ /** Normalize an Arrow/TypedArray/plain vector value into number[]. */
108
+ export function toNumberArray(value: unknown): number[] {
109
+ if (value == null) return [];
110
+ if (Array.isArray(value)) return value as number[];
111
+ if (ArrayBuffer.isView(value)) return Array.from(value as Float32Array);
112
+ const maybe = value as { toArray?: () => ArrayLike<number> };
113
+ if (typeof maybe.toArray === "function") return Array.from(maybe.toArray());
114
+ return [];
115
+ }
116
+
117
+ /** A vector that is missing, empty, or all zeros carries no embedding (§1.6). */
118
+ export function isZeroVector(value: unknown): boolean {
119
+ const arr = toNumberArray(value);
120
+ if (arr.length === 0) return true;
121
+ return arr.every((v) => v === 0);
122
+ }
123
+
124
+ // ─── LanceDB row shapes ──────────────────────────────────────────────────────
125
+
126
+ type LanceDB = typeof import("@lancedb/lancedb");
127
+ type LanceConnection = Awaited<ReturnType<LanceDB["connect"]>>;
128
+ type LanceTable = Awaited<ReturnType<LanceConnection["openTable"]>>;
129
+
130
+ /** Build a v2 fragments seed row (used to establish the table schema). */
131
+ export function fragmentSeedRow(dim: number): Record<string, unknown> {
132
+ return {
133
+ id: "__seed__",
134
+ sessionId: "",
135
+ agentId: "",
136
+ backendType: "claude",
137
+ timestamp: 0,
138
+ type: "observation",
139
+ content: "",
140
+ gitContextJson: "{}",
141
+ referencesJson: "[]",
142
+ tagsJson: "[]",
143
+ confidence: 0,
144
+ consolidatedInto: "",
145
+ isConsolidated: false,
146
+ namespace: "",
147
+ repoRoot: "",
148
+ repoRootHash: "",
149
+ lastReinforcedAt: 0,
150
+ accessCount: 0,
151
+ pinned: false,
152
+ // 0 = no per-fragment override (null in the domain model)
153
+ halfLifeHours: 0,
154
+ embeddingStatus: "none",
155
+ vector: Array(dim).fill(0) as number[],
156
+ };
157
+ }
158
+
159
+ /** Build a v2 consolidated-knowledge seed row. */
160
+ export function consolidatedSeedRow(dim: number): Record<string, unknown> {
161
+ return {
162
+ id: "__seed__",
163
+ tag: "",
164
+ summary: "",
165
+ sourceFragmentsJson: "[]",
166
+ lastUpdated: 0,
167
+ confidence: 0,
168
+ repoRoot: "",
169
+ namespace: "",
170
+ repoRootHash: "",
171
+ knowledgeType: "",
172
+ synthesisMethod: "",
173
+ supersededBy: "",
174
+ accessCount: 0,
175
+ lastReinforcedAt: 0,
176
+ embeddingStatus: "none",
177
+ vector: Array(dim).fill(0) as number[],
178
+ };
179
+ }
180
+
181
+ async function createSeededTable(
182
+ db: LanceConnection,
183
+ name: string,
184
+ seed: Record<string, unknown>,
185
+ ): Promise<LanceTable> {
186
+ const table = await db.createTable(name, [seed]);
187
+ await table.delete('id = "__seed__"');
188
+ return table;
189
+ }
190
+
191
+ async function readAllRows(table: LanceTable, limit = 100000): Promise<Record<string, unknown>[]> {
192
+ const rows = await table.query().limit(limit).toArray();
193
+ return rows as unknown as Record<string, unknown>[];
194
+ }
195
+
196
+ async function addInChunks(table: LanceTable, rows: Record<string, unknown>[], chunk = 500): Promise<void> {
197
+ for (let i = 0; i < rows.length; i += chunk) {
198
+ await table.add(rows.slice(i, i + chunk));
199
+ }
200
+ }
201
+
202
+ // ─── v1 → v2 row transforms ──────────────────────────────────────────────────
203
+
204
+ function str(v: unknown): string {
205
+ return typeof v === "string" ? v : "";
206
+ }
207
+
208
+ function num(v: unknown): number {
209
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
210
+ }
211
+
212
+ /** Backfill rule (§3.5.1): repoRoot present → repo:<hash>; else session:<sessionId>. */
213
+ export function backfillFragmentNamespace(repoRoot: string, sessionId: string): string {
214
+ if (repoRoot) return repoNamespace(repoRoot);
215
+ return sessionNamespace(sessionId);
216
+ }
217
+
218
+ /** Consolidated rows: repoRoot present → repo:<hash>; repoRoot === "" → global. */
219
+ export function backfillKnowledgeNamespace(repoRoot: string): string {
220
+ return repoRoot ? repoNamespace(repoRoot) : "global";
221
+ }
222
+
223
+ function migrateFragmentRow(
224
+ row: Record<string, unknown>,
225
+ targetDim: number,
226
+ providerDim: number | null,
227
+ ): Record<string, unknown> {
228
+ let repoRoot = "";
229
+ try {
230
+ const git = JSON.parse(str(row.gitContextJson) || "{}") as { repoRoot?: string };
231
+ repoRoot = typeof git.repoRoot === "string" ? git.repoRoot : "";
232
+ } catch {
233
+ repoRoot = "";
234
+ }
235
+ const sessionId = str(row.sessionId);
236
+ const vec = toNumberArray(row.vector);
237
+ const zero = isZeroVector(vec);
238
+
239
+ // Vector carries over only when it is non-zero AND already matches the
240
+ // active dimension; otherwise it must be re-embedded (status "pending").
241
+ const keepVector = !zero && vec.length === targetDim && providerDim !== null && providerDim === targetDim;
242
+ return {
243
+ id: str(row.id),
244
+ sessionId,
245
+ agentId: str(row.agentId),
246
+ backendType: str(row.backendType) || "claude",
247
+ timestamp: num(row.timestamp),
248
+ type: str(row.type) || "observation",
249
+ content: str(row.content),
250
+ gitContextJson: str(row.gitContextJson) || "{}",
251
+ referencesJson: str(row.referencesJson) || "[]",
252
+ tagsJson: str(row.tagsJson) || "[]",
253
+ confidence: num(row.confidence),
254
+ consolidatedInto: str(row.consolidatedInto),
255
+ isConsolidated: row.isConsolidated === true,
256
+ namespace: backfillFragmentNamespace(repoRoot, sessionId),
257
+ repoRoot,
258
+ repoRootHash: repoRoot ? hashRepoRoot(repoRoot) : "",
259
+ lastReinforcedAt: num(row.timestamp),
260
+ accessCount: 0,
261
+ pinned: false,
262
+ halfLifeHours: 0,
263
+ embeddingStatus: keepVector ? "ok" : "pending",
264
+ vector: keepVector ? vec : (Array(targetDim).fill(0) as number[]),
265
+ };
266
+ }
267
+
268
+ function migrateConsolidatedRow(row: Record<string, unknown>, targetDim: number): Record<string, unknown> {
269
+ const repoRoot = str(row.repoRoot);
270
+ return {
271
+ id: str(row.id),
272
+ tag: str(row.tag),
273
+ summary: str(row.summary),
274
+ sourceFragmentsJson: str(row.sourceFragmentsJson) || "[]",
275
+ lastUpdated: num(row.lastUpdated),
276
+ confidence: num(row.confidence),
277
+ repoRoot,
278
+ namespace: backfillKnowledgeNamespace(repoRoot),
279
+ repoRootHash: repoRoot ? hashRepoRoot(repoRoot) : "",
280
+ knowledgeType: "",
281
+ // v1 synthesize() was concatenation
282
+ synthesisMethod: "concat",
283
+ supersededBy: "",
284
+ accessCount: 0,
285
+ lastReinforcedAt: num(row.lastUpdated),
286
+ // v1 had no vector column on consolidated rows — needs embedding
287
+ embeddingStatus: "pending",
288
+ vector: Array(targetDim).fill(0) as number[],
289
+ };
290
+ }
291
+
292
+ // ─── Migration entry point ───────────────────────────────────────────────────
293
+
294
+ export interface EnsureSchemaOptions {
295
+ db: LanceConnection;
296
+ memoryRoot: string;
297
+ /** Current embedding provider name from settings ("openai" | "ollama" | "none"). */
298
+ provider: string;
299
+ /** Current provider dim, or null when provider is "none". */
300
+ providerDim: number | null;
301
+ }
302
+
303
+ export interface EnsuredSchema {
304
+ meta: MemoryMeta;
305
+ fragments: LanceTable;
306
+ consolidated: LanceTable;
307
+ }
308
+
309
+ /**
310
+ * Ensure the on-disk store is at schema v2 and consistent with the currently
311
+ * configured embedding provider. Handles, in order:
312
+ * 1. fresh install (no meta, no tables)
313
+ * 2. v1 → v2 copy migration with namespace backfill + zero-vector detection
314
+ * 3. provider/dimension changes on an existing v2 store
315
+ */
316
+ export async function ensureSchemaV2(opts: EnsureSchemaOptions): Promise<EnsuredSchema> {
317
+ const { db, memoryRoot, provider, providerDim } = opts;
318
+ let meta = readMemoryMeta(memoryRoot);
319
+ const tableNames = await db.tableNames();
320
+
321
+ if (!meta || meta.schemaVersion < MEMORY_SCHEMA_VERSION) {
322
+ meta = await migrateToV2(db, memoryRoot, tableNames, provider, providerDim);
323
+ }
324
+
325
+ meta = await reconcileProviderChange(db, memoryRoot, meta, provider, providerDim);
326
+
327
+ const fragments = await db.openTable(meta.activeFragmentsTable);
328
+ const consolidated = await db.openTable(meta.activeConsolidatedTable);
329
+ return { meta, fragments, consolidated };
330
+ }
331
+
332
+ async function migrateToV2(
333
+ db: LanceConnection,
334
+ memoryRoot: string,
335
+ tableNames: string[],
336
+ provider: string,
337
+ providerDim: number | null,
338
+ ): Promise<MemoryMeta> {
339
+ const hasV1Fragments = tableNames.includes("fragments");
340
+ const hasV1Consolidated = tableNames.includes("consolidated");
341
+
342
+ // Interrupted-migration safety: v2 tables without meta.json are partial — drop and redo.
343
+ for (const name of tableNames) {
344
+ if (name.startsWith("fragments_v2") || name.startsWith("consolidated_v2")) {
345
+ await db.dropTable(name);
346
+ }
347
+ }
348
+
349
+ // Determine the active vector dimension: prefer the configured provider's
350
+ // dim; with no provider, inherit the v1 dim (keeps migrated vectors intact)
351
+ // or fall back to the placeholder width.
352
+ let v1Dim: number | null = null;
353
+ let v1FragmentRows: Record<string, unknown>[] = [];
354
+ if (hasV1Fragments) {
355
+ const v1 = await db.openTable("fragments");
356
+ v1FragmentRows = await readAllRows(v1);
357
+ const withVec = v1FragmentRows.find((r) => toNumberArray(r.vector).length > 0);
358
+ v1Dim = withVec ? toNumberArray(withVec.vector).length : null;
359
+ }
360
+ const dim = providerDim ?? v1Dim ?? NO_PROVIDER_DIM;
361
+
362
+ const fragments = await createSeededTable(db, "fragments_v2", fragmentSeedRow(dim));
363
+ const consolidated = await createSeededTable(db, "consolidated_v2", consolidatedSeedRow(dim));
364
+
365
+ if (v1FragmentRows.length > 0) {
366
+ await addInChunks(fragments, v1FragmentRows.map((r) => migrateFragmentRow(r, dim, providerDim)));
367
+ }
368
+ if (hasV1Consolidated) {
369
+ const v1c = await db.openTable("consolidated");
370
+ const rows = await readAllRows(v1c);
371
+ if (rows.length > 0) {
372
+ await addInChunks(consolidated, rows.map((r) => migrateConsolidatedRow(r, dim)));
373
+ }
374
+ }
375
+
376
+ const backups: string[] = [];
377
+ if (hasV1Fragments) backups.push("fragments");
378
+ if (hasV1Consolidated) backups.push("consolidated");
379
+
380
+ // meta.json written last — an interrupted copy re-runs from scratch.
381
+ const meta: MemoryMeta = {
382
+ schemaVersion: MEMORY_SCHEMA_VERSION,
383
+ embeddingProvider: provider,
384
+ dim,
385
+ activeFragmentsTable: "fragments_v2",
386
+ activeConsolidatedTable: "consolidated_v2",
387
+ v1BackupTables: backups.length > 0 ? backups : undefined,
388
+ migratedFromV1: backups.length > 0 || undefined,
389
+ updatedAt: Date.now(),
390
+ };
391
+ writeMemoryMeta(memoryRoot, meta);
392
+ return meta;
393
+ }
394
+
395
+ /**
396
+ * Handle provider/dimension changes on an existing v2 store (§3.5.2):
397
+ * - provider → "none": keep tables; record provider; new rows get status "none".
398
+ * - same dim, different provider: embeddings are model-specific — mark all
399
+ * "ok" rows "pending" in place for re-embedding.
400
+ * - different dim: create fragments_v2_<dim>/consolidated_v2_<dim> as the
401
+ * active tables, copy rows with zero vectors + status "pending".
402
+ */
403
+ async function reconcileProviderChange(
404
+ db: LanceConnection,
405
+ memoryRoot: string,
406
+ meta: MemoryMeta,
407
+ provider: string,
408
+ providerDim: number | null,
409
+ ): Promise<MemoryMeta> {
410
+ if (provider === meta.embeddingProvider) return meta;
411
+
412
+ if (providerDim === null) {
413
+ // Real provider → none: nothing to re-embed; just record it.
414
+ const next = { ...meta, embeddingProvider: provider, updatedAt: Date.now() };
415
+ writeMemoryMeta(memoryRoot, next);
416
+ return next;
417
+ }
418
+
419
+ if (providerDim === meta.dim) {
420
+ // Same width, different model — existing vectors are not comparable.
421
+ for (const name of [meta.activeFragmentsTable, meta.activeConsolidatedTable]) {
422
+ const table = await db.openTable(name);
423
+ await table.update({ where: "embeddingStatus = 'ok'", values: { embeddingStatus: "pending" } });
424
+ }
425
+ const next = { ...meta, embeddingProvider: provider, updatedAt: Date.now() };
426
+ writeMemoryMeta(memoryRoot, next);
427
+ return next;
428
+ }
429
+
430
+ // Dimension change: versioned new active tables, rows queued for re-embed.
431
+ const newFragName = `fragments_v2_${providerDim}`;
432
+ const newConsName = `consolidated_v2_${providerDim}`;
433
+ const existing = await db.tableNames();
434
+ // Re-created from the current active tables (source of truth) if left over
435
+ // from a previous switch.
436
+ if (existing.includes(newFragName)) await db.dropTable(newFragName);
437
+ if (existing.includes(newConsName)) await db.dropTable(newConsName);
438
+
439
+ const newFragments = await createSeededTable(db, newFragName, fragmentSeedRow(providerDim));
440
+ const newConsolidated = await createSeededTable(db, newConsName, consolidatedSeedRow(providerDim));
441
+
442
+ const oldFragments = await db.openTable(meta.activeFragmentsTable);
443
+ const fragRows = await readAllRows(oldFragments);
444
+ await addInChunks(newFragments, fragRows.map((r) => ({
445
+ ...r,
446
+ vector: Array(providerDim).fill(0) as number[],
447
+ embeddingStatus: "pending",
448
+ })));
449
+
450
+ const oldConsolidated = await db.openTable(meta.activeConsolidatedTable);
451
+ const consRows = await readAllRows(oldConsolidated);
452
+ await addInChunks(newConsolidated, consRows.map((r) => ({
453
+ ...r,
454
+ vector: Array(providerDim).fill(0) as number[],
455
+ embeddingStatus: "pending",
456
+ })));
457
+
458
+ const next: MemoryMeta = {
459
+ ...meta,
460
+ embeddingProvider: provider,
461
+ dim: providerDim,
462
+ activeFragmentsTable: newFragName,
463
+ activeConsolidatedTable: newConsName,
464
+ updatedAt: Date.now(),
465
+ };
466
+ writeMemoryMeta(memoryRoot, next);
467
+ return next;
468
+ }
@@ -5,15 +5,26 @@ import type { BackendType } from "../session-types.js";
5
5
  export function registerCiRoutes(api: Hono, deps: RouteDeps): void {
6
6
  const { wsBridge } = deps;
7
7
 
8
+ /** repoRoot for a session — the namespace anchor for repo-scoped memory. */
9
+ const sessionRepoRoot = (sessionId: string): string => {
10
+ const session = wsBridge.getSession(sessionId);
11
+ return session?.state.repo_root || session?.state.cwd || "";
12
+ };
13
+
8
14
  // ─── Collective Intelligence: Semantic Memory ──────────────────────────────
9
15
  api.get("/sessions/:id/memory", async (c) => {
10
- const { queryFragments, getConsolidatedKnowledge, getSessionFragments } = await import("../semantic-memory.js");
16
+ const { getConsolidatedKnowledge, getSessionFragments } = await import("../semantic-memory.js");
11
17
  const sessionId = c.req.param("id");
12
- const [fragments, consolidated] = await Promise.all([
18
+ // §1.8 fix: consolidated knowledge lives in namespaces now. "" resolves to
19
+ // the `global` namespace; repo-scoped rows come from repo:<hash of the
20
+ // session cwd> — the old getConsolidatedKnowledge("") matched nothing.
21
+ const repoRoot = sessionRepoRoot(sessionId);
22
+ const [fragments, globalKnowledge, repoKnowledge] = await Promise.all([
13
23
  getSessionFragments(sessionId),
14
- getConsolidatedKnowledge(""), // cross-session consolidated
24
+ getConsolidatedKnowledge(""), // global namespace
25
+ repoRoot ? getConsolidatedKnowledge(repoRoot) : Promise.resolve([]),
15
26
  ]);
16
- return c.json({ fragments, consolidated });
27
+ return c.json({ fragments, consolidated: [...repoKnowledge, ...globalKnowledge] });
17
28
  });
18
29
 
19
30
  api.post("/sessions/:id/memory", async (c) => {
@@ -43,21 +54,68 @@ export function registerCiRoutes(api: Hono, deps: RouteDeps): void {
43
54
  });
44
55
 
45
56
  api.post("/sessions/:id/memory/consolidate", async (c) => {
46
- const { consolidateSession } = await import("../semantic-memory.js");
57
+ // §3.4 trigger 4: manual consolidation routes through the JUDGE → DISTILL
58
+ // → CONSOLIDATE pipeline and returns its ConsolidationResult.
59
+ const { consolidate } = await import("../memory-consolidation.js");
47
60
  const sessionId = c.req.param("id");
48
61
  const session = wsBridge.getSession(sessionId);
49
- const repoRoot = session?.state.cwd ?? "";
50
- const consolidated = await consolidateSession(sessionId, repoRoot);
51
- return c.json({ consolidated, count: consolidated.length });
62
+ const result = await consolidate({
63
+ sessionId,
64
+ repoRoot: sessionRepoRoot(sessionId),
65
+ backendType: session?.backendType ?? session?.state.backend_type ?? "claude",
66
+ reason: "manual",
67
+ });
68
+ return c.json(result);
52
69
  });
53
70
 
54
71
  api.get("/memory/global", async (c) => {
55
- const { getConsolidatedKnowledge } = await import("../semantic-memory.js");
72
+ // §1.8 fix: query the `global` namespace explicitly (v2 semantics)
73
+ // the old getConsolidatedKnowledge("") matched only literally-empty
74
+ // repoRoot rows, i.e. nothing.
75
+ const { getKnowledgeByNamespace } = await import("../semantic-memory.js");
56
76
  const tag = c.req.query("tag");
57
- const knowledge = await getConsolidatedKnowledge("", tag);
77
+ const knowledge = await getKnowledgeByNamespace("global", tag);
58
78
  return c.json({ knowledge });
59
79
  });
60
80
 
81
+ // Per-namespace fragment stats + active consolidated knowledge for the
82
+ // memory panel. Shape is the frontend contract (api.ts MemoryOverviewResponse):
83
+ // { namespaces: [{namespace, count, avgWeight, pinnedCount}],
84
+ // knowledge: [{id, tag, summary, confidence, namespace, synthesisMethod?}] }
85
+ api.get("/sessions/:id/memory/overview", async (c) => {
86
+ const { getNamespaceOverview, getKnowledgeByNamespace, repoNamespace } = await import("../semantic-memory.js");
87
+ const sessionId = c.req.param("id");
88
+ const session = wsBridge.getSession(sessionId);
89
+ const repoRoot = sessionRepoRoot(sessionId);
90
+ const backendType = session?.backendType ?? session?.state.backend_type ?? "claude";
91
+
92
+ const namespaces = await getNamespaceOverview({ sessionId, repoRoot, backendType });
93
+ const knowledgeRows = [
94
+ ...(repoRoot ? await getKnowledgeByNamespace(repoNamespace(repoRoot)) : []),
95
+ ...(await getKnowledgeByNamespace("global")),
96
+ ];
97
+ const knowledge = knowledgeRows.map((k) => ({
98
+ id: k.id,
99
+ tag: k.tag,
100
+ summary: k.summary,
101
+ confidence: k.confidence,
102
+ namespace: k.namespace ?? "",
103
+ synthesisMethod: k.synthesisMethod,
104
+ }));
105
+ return c.json({ namespaces, knowledge });
106
+ });
107
+
108
+ // Pin/unpin a memory fragment (§3.2: pinned rows never decay or get evicted).
109
+ api.post("/memory/pin", async (c) => {
110
+ const { setFragmentPinned } = await import("../semantic-memory.js");
111
+ const body = await c.req.json() as { id?: string; pinned?: boolean };
112
+ if (!body.id || typeof body.pinned !== "boolean") {
113
+ return c.json({ error: "id and pinned are required" }, 400);
114
+ }
115
+ const ok = await setFragmentPinned(body.id, body.pinned);
116
+ return c.json({ ok });
117
+ });
118
+
61
119
  // ─── Collective Intelligence: Deliberation ─────────────────────────────────
62
120
  api.get("/sessions/:id/deliberations", (c) => {
63
121
  const { deliberationEngine } = require("../deliberation-engine.js") as typeof import("../deliberation-engine.js");
@@ -15,6 +15,8 @@ function settingsResponse(s: CampfireSettings) {
15
15
  openaiApiKeyConfigured: !!s.openaiApiKey?.trim(),
16
16
  anthropicApiKeyConfigured: !!s.anthropicApiKey?.trim(),
17
17
  onboardingCompleted: s.onboardingCompleted,
18
+ // Semantic memory v2: decay policies + recall depths (not secret — full values)
19
+ memory: s.memory,
18
20
  };
19
21
  }
20
22
 
@@ -57,13 +59,19 @@ export function registerSettingsRoutes(api: Hono, _deps: RouteDeps): void {
57
59
  }
58
60
 
59
61
  const hasOnboarding = typeof body.onboardingCompleted === "boolean";
60
- const hasAnyField = STRING_FIELDS.some((f) => body[f] !== undefined) || hasOnboarding;
62
+ const hasMemory = body.memory !== undefined;
63
+ if (hasMemory && (typeof body.memory !== "object" || body.memory === null || Array.isArray(body.memory))) {
64
+ return c.json({ error: "memory must be an object" }, 400);
65
+ }
66
+ const hasAnyField = STRING_FIELDS.some((f) => body[f] !== undefined) || hasOnboarding || hasMemory;
61
67
  if (!hasAnyField) {
62
68
  return c.json({ error: "At least one settings field is required" }, 400);
63
69
  }
64
70
 
65
- const patch: Record<string, string | boolean> = {};
71
+ const patch: Record<string, string | boolean | object> = {};
66
72
  if (hasOnboarding) patch.onboardingCompleted = body.onboardingCompleted;
73
+ // Partial memory patch — deep-merged + normalized by the settings manager
74
+ if (hasMemory) patch.memory = body.memory;
67
75
  for (const field of STRING_FIELDS) {
68
76
  if (typeof body[field] === "string") {
69
77
  patch[field] = field === "openrouterModel"