coding-friend-cli 1.15.0 → 1.17.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.
- package/README.md +12 -0
- package/dist/{chunk-PYRGNY5P.js → chunk-C5LYVVEI.js} +9 -1
- package/dist/{chunk-X5WEODUD.js → chunk-CYQU33FY.js} +1 -0
- package/dist/{chunk-ITL5TY3B.js → chunk-G6CEEMAR.js} +3 -3
- package/dist/{chunk-4DB4XTSL.js → chunk-KTX4MGMR.js} +15 -1
- package/dist/{chunk-KJUGTLPQ.js → chunk-YO6JKGR3.js} +38 -2
- package/dist/{config-UQ742WPQ.js → config-LZFXXOI4.js} +276 -14
- package/dist/{dev-WJ5QQ35B.js → dev-R3IYWZ3M.js} +2 -2
- package/dist/{disable-AOZ7FLZD.js → disable-R6K5YJN4.js} +2 -2
- package/dist/{enable-MJVTT3RU.js → enable-HF4PYVJN.js} +2 -2
- package/dist/{host-NA7LZ4HX.js → host-SYZH3FVC.js} +4 -4
- package/dist/index.js +78 -18
- package/dist/{init-AHIEQ27W.js → init-YK6YRTOT.js} +271 -23
- package/dist/{install-EIN7Z5V3.js → install-Q4PWEU43.js} +4 -4
- package/dist/{mcp-DLS3J6QJ.js → mcp-TBEDYELW.js} +4 -4
- package/dist/memory-7RM67ZLS.js +668 -0
- package/dist/postinstall.js +1 -1
- package/dist/{session-E3CZJJZQ.js → session-H4XW2WXH.js} +1 -1
- package/dist/{statusline-6HQCDWBD.js → statusline-6Y2EBAFQ.js} +1 -1
- package/dist/{uninstall-2IOZZERP.js → uninstall-3PSUDGI4.js} +3 -3
- package/dist/{update-IZ5UEKZN.js → update-WL6SFGGO.js} +4 -4
- package/lib/cf-memory/CHANGELOG.md +15 -0
- package/lib/cf-memory/README.md +284 -0
- package/lib/cf-memory/package-lock.json +2790 -0
- package/lib/cf-memory/package.json +31 -0
- package/lib/cf-memory/scripts/migrate-frontmatter.ts +134 -0
- package/lib/cf-memory/src/__tests__/daemon-e2e.test.ts +223 -0
- package/lib/cf-memory/src/__tests__/daemon.test.ts +407 -0
- package/lib/cf-memory/src/__tests__/dedup.test.ts +103 -0
- package/lib/cf-memory/src/__tests__/embeddings.test.ts +292 -0
- package/lib/cf-memory/src/__tests__/lazy-install.test.ts +210 -0
- package/lib/cf-memory/src/__tests__/markdown-backend.test.ts +410 -0
- package/lib/cf-memory/src/__tests__/migration.test.ts +255 -0
- package/lib/cf-memory/src/__tests__/migrations.test.ts +288 -0
- package/lib/cf-memory/src/__tests__/minisearch-backend.test.ts +262 -0
- package/lib/cf-memory/src/__tests__/ollama.test.ts +48 -0
- package/lib/cf-memory/src/__tests__/schema.test.ts +128 -0
- package/lib/cf-memory/src/__tests__/search.test.ts +115 -0
- package/lib/cf-memory/src/__tests__/temporal-decay.test.ts +54 -0
- package/lib/cf-memory/src/__tests__/tier.test.ts +293 -0
- package/lib/cf-memory/src/__tests__/tools.test.ts +83 -0
- package/lib/cf-memory/src/backends/markdown.ts +318 -0
- package/lib/cf-memory/src/backends/minisearch.ts +203 -0
- package/lib/cf-memory/src/backends/sqlite/embeddings.ts +286 -0
- package/lib/cf-memory/src/backends/sqlite/index.ts +549 -0
- package/lib/cf-memory/src/backends/sqlite/migrations.ts +188 -0
- package/lib/cf-memory/src/backends/sqlite/schema.ts +120 -0
- package/lib/cf-memory/src/backends/sqlite/search.ts +296 -0
- package/lib/cf-memory/src/bin/cf-memory.ts +2 -0
- package/lib/cf-memory/src/daemon/entry.ts +99 -0
- package/lib/cf-memory/src/daemon/process.ts +220 -0
- package/lib/cf-memory/src/daemon/server.ts +166 -0
- package/lib/cf-memory/src/daemon/watcher.ts +90 -0
- package/lib/cf-memory/src/index.ts +45 -0
- package/lib/cf-memory/src/lib/backend.ts +23 -0
- package/lib/cf-memory/src/lib/daemon-client.ts +163 -0
- package/lib/cf-memory/src/lib/dedup.ts +80 -0
- package/lib/cf-memory/src/lib/lazy-install.ts +274 -0
- package/lib/cf-memory/src/lib/ollama.ts +76 -0
- package/lib/cf-memory/src/lib/temporal-decay.ts +19 -0
- package/lib/cf-memory/src/lib/tier.ts +107 -0
- package/lib/cf-memory/src/lib/types.ts +109 -0
- package/lib/cf-memory/src/resources/index.ts +62 -0
- package/lib/cf-memory/src/server.ts +20 -0
- package/lib/cf-memory/src/tools/delete.ts +38 -0
- package/lib/cf-memory/src/tools/list.ts +38 -0
- package/lib/cf-memory/src/tools/retrieve.ts +52 -0
- package/lib/cf-memory/src/tools/search.ts +47 -0
- package/lib/cf-memory/src/tools/store.ts +70 -0
- package/lib/cf-memory/src/tools/update.ts +62 -0
- package/lib/cf-memory/tsconfig.json +15 -0
- package/lib/cf-memory/vitest.config.ts +7 -0
- package/lib/learn-host/CHANGELOG.md +4 -1
- package/lib/learn-host/package.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SqliteBackend — Tier 1 memory backend with hybrid search.
|
|
3
|
+
*
|
|
4
|
+
* Uses better-sqlite3 for storage, FTS5 for keyword search,
|
|
5
|
+
* sqlite-vec for vector similarity, and RRF for fusion.
|
|
6
|
+
*
|
|
7
|
+
* Markdown files remain the source of truth. SQLite is a derived index
|
|
8
|
+
* that can be rebuilt from markdown at any time.
|
|
9
|
+
*
|
|
10
|
+
* DB path: ~/.coding-friend/memory/projects/{12-char-sha256}/db.sqlite
|
|
11
|
+
*/
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import crypto from "node:crypto";
|
|
15
|
+
import type { MemoryBackend } from "../../lib/backend.js";
|
|
16
|
+
import { MarkdownBackend } from "../markdown.js";
|
|
17
|
+
import {
|
|
18
|
+
makeExcerpt,
|
|
19
|
+
type ListInput,
|
|
20
|
+
type Memory,
|
|
21
|
+
type MemoryFrontmatter,
|
|
22
|
+
type MemoryMeta,
|
|
23
|
+
type MemoryStats,
|
|
24
|
+
type MemoryType,
|
|
25
|
+
type SearchInput,
|
|
26
|
+
type SearchResult,
|
|
27
|
+
type StoreInput,
|
|
28
|
+
type UpdateInput,
|
|
29
|
+
} from "../../lib/types.js";
|
|
30
|
+
import { loadDepSync } from "../../lib/lazy-install.js";
|
|
31
|
+
import {
|
|
32
|
+
migrate,
|
|
33
|
+
createVecTable,
|
|
34
|
+
checkEmbeddingMismatch,
|
|
35
|
+
getMetadata,
|
|
36
|
+
setMetadata,
|
|
37
|
+
type DatabaseLike,
|
|
38
|
+
} from "./migrations.js";
|
|
39
|
+
import {
|
|
40
|
+
EmbeddingPipeline,
|
|
41
|
+
EmbeddingCache,
|
|
42
|
+
contentHash,
|
|
43
|
+
prepareEmbeddingText,
|
|
44
|
+
type EmbeddingConfig,
|
|
45
|
+
} from "./embeddings.js";
|
|
46
|
+
import { hybridSearch } from "./search.js";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Compute a short hash of the docsDir path for project isolation.
|
|
50
|
+
*/
|
|
51
|
+
function projectHash(docsDir: string): string {
|
|
52
|
+
return crypto
|
|
53
|
+
.createHash("sha256")
|
|
54
|
+
.update(path.resolve(docsDir))
|
|
55
|
+
.digest("hex")
|
|
56
|
+
.slice(0, 12);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SqliteBackendOptions {
|
|
60
|
+
depsDir?: string;
|
|
61
|
+
dbPath?: string;
|
|
62
|
+
embedding?: Partial<EmbeddingConfig>;
|
|
63
|
+
skipVec?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class SqliteBackend implements MemoryBackend {
|
|
67
|
+
private db: DatabaseLike;
|
|
68
|
+
private markdown: MarkdownBackend;
|
|
69
|
+
private pipeline: EmbeddingPipeline | null = null;
|
|
70
|
+
private cache: EmbeddingCache | null = null;
|
|
71
|
+
private vecEnabled = false;
|
|
72
|
+
private needsEmbeddingRebuild = false;
|
|
73
|
+
private dbPath: string;
|
|
74
|
+
|
|
75
|
+
constructor(
|
|
76
|
+
private docsDir: string,
|
|
77
|
+
opts?: SqliteBackendOptions,
|
|
78
|
+
) {
|
|
79
|
+
this.markdown = new MarkdownBackend(docsDir);
|
|
80
|
+
|
|
81
|
+
// Determine DB path
|
|
82
|
+
if (opts?.dbPath) {
|
|
83
|
+
this.dbPath = opts.dbPath;
|
|
84
|
+
} else {
|
|
85
|
+
const depsDir = opts?.depsDir ?? this.getDefaultDepsDir();
|
|
86
|
+
const hash = projectHash(docsDir);
|
|
87
|
+
const projectDir = path.join(depsDir, "projects", hash);
|
|
88
|
+
this.dbPath = path.join(projectDir, "db.sqlite");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Open database — defers mkdirSync until after dep check to avoid empty dirs
|
|
92
|
+
this.db = this.openDatabase(opts?.depsDir);
|
|
93
|
+
|
|
94
|
+
// Run migrations
|
|
95
|
+
migrate(this.db);
|
|
96
|
+
|
|
97
|
+
// Store the resolved docsDir so `cf memory list --projects` can display the source path
|
|
98
|
+
const resolvedDir = path.resolve(docsDir);
|
|
99
|
+
if (getMetadata(this.db, "source_dir") !== resolvedDir) {
|
|
100
|
+
setMetadata(this.db, "source_dir", resolvedDir);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Try to set up vector search
|
|
104
|
+
if (!opts?.skipVec) {
|
|
105
|
+
this.vecEnabled = this.setupVec(opts?.depsDir);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Set up embedding pipeline and cache
|
|
109
|
+
if (this.vecEnabled) {
|
|
110
|
+
this.pipeline = new EmbeddingPipeline({
|
|
111
|
+
...opts?.embedding,
|
|
112
|
+
depsDir: opts?.depsDir,
|
|
113
|
+
});
|
|
114
|
+
this.cache = new EmbeddingCache(this.db);
|
|
115
|
+
|
|
116
|
+
// Check if embedding model changed
|
|
117
|
+
const mismatch = checkEmbeddingMismatch(
|
|
118
|
+
this.db,
|
|
119
|
+
this.pipeline.modelName,
|
|
120
|
+
this.pipeline.dims,
|
|
121
|
+
);
|
|
122
|
+
if (mismatch.mismatched) {
|
|
123
|
+
process.stderr.write(
|
|
124
|
+
`[cf-memory] Embedding model changed: ${mismatch.storedModel} (${mismatch.storedDims}d) → ${mismatch.currentModel} (${mismatch.currentDims}d)\n` +
|
|
125
|
+
`[cf-memory] Vector search disabled. Run "cf memory rebuild" to re-embed.\n`,
|
|
126
|
+
);
|
|
127
|
+
this.vecEnabled = false;
|
|
128
|
+
this.needsEmbeddingRebuild = true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private getDefaultDepsDir(): string {
|
|
134
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp";
|
|
135
|
+
return path.join(home, ".coding-friend", "memory");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private openDatabase(depsDir?: string): DatabaseLike {
|
|
139
|
+
const Database = loadDepSync<{ new (path: string): DatabaseLike }>(
|
|
140
|
+
"better-sqlite3",
|
|
141
|
+
depsDir,
|
|
142
|
+
);
|
|
143
|
+
// better-sqlite3 exports the class as default in CJS
|
|
144
|
+
const DbConstructor =
|
|
145
|
+
(Database as unknown as { default: { new (path: string): DatabaseLike } })
|
|
146
|
+
.default ?? Database;
|
|
147
|
+
fs.mkdirSync(path.dirname(this.dbPath), { recursive: true });
|
|
148
|
+
return new DbConstructor(this.dbPath);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private setupVec(depsDir?: string): boolean {
|
|
152
|
+
try {
|
|
153
|
+
const sqliteVec = loadDepSync<{ load: (db: unknown) => void }>(
|
|
154
|
+
"sqlite-vec",
|
|
155
|
+
depsDir,
|
|
156
|
+
);
|
|
157
|
+
const loader =
|
|
158
|
+
(sqliteVec as unknown as { default: { load: (db: unknown) => void } })
|
|
159
|
+
.default ?? sqliteVec;
|
|
160
|
+
loader.load(this.db);
|
|
161
|
+
return createVecTable(this.db);
|
|
162
|
+
} catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
getDbPath(): string {
|
|
168
|
+
return this.dbPath;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
isVecEnabled(): boolean {
|
|
172
|
+
return this.vecEnabled;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
isRebuildNeeded(): boolean {
|
|
176
|
+
return this.needsEmbeddingRebuild;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async store(input: StoreInput): Promise<Memory> {
|
|
180
|
+
// 1. Store to markdown (source of truth)
|
|
181
|
+
const memory = await this.markdown.store(input);
|
|
182
|
+
|
|
183
|
+
// 2. Index in SQLite
|
|
184
|
+
const hash = contentHash(
|
|
185
|
+
prepareEmbeddingText({
|
|
186
|
+
title: memory.frontmatter.title,
|
|
187
|
+
description: memory.frontmatter.description,
|
|
188
|
+
tags: memory.frontmatter.tags,
|
|
189
|
+
content: memory.content,
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
this.upsertRow(memory, hash);
|
|
194
|
+
|
|
195
|
+
// 3. Generate and store embedding (async, non-blocking for store)
|
|
196
|
+
if (this.vecEnabled && this.pipeline) {
|
|
197
|
+
this.embedAndStore(memory.id, memory, hash).catch((err) => {
|
|
198
|
+
process.stderr.write(
|
|
199
|
+
`[cf-memory] Embedding failed for ${memory.id}: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return memory;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async search(input: SearchInput): Promise<SearchResult[]> {
|
|
208
|
+
const query = input.query?.trim();
|
|
209
|
+
|
|
210
|
+
if (!query) {
|
|
211
|
+
const metas = await this.list({ type: input.type, limit: input.limit });
|
|
212
|
+
return metas.map((m) => ({ memory: m, score: 0, matchedOn: [] }));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const limit = input.limit ?? 10;
|
|
216
|
+
|
|
217
|
+
const ranked = await hybridSearch({
|
|
218
|
+
db: this.db,
|
|
219
|
+
query,
|
|
220
|
+
limit,
|
|
221
|
+
pipeline: this.pipeline,
|
|
222
|
+
vecEnabled: this.vecEnabled,
|
|
223
|
+
typeFilter: input.type,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Convert ranked results to SearchResult format
|
|
227
|
+
const results: SearchResult[] = [];
|
|
228
|
+
for (const r of ranked) {
|
|
229
|
+
const row = this.getRow(r.id);
|
|
230
|
+
if (!row) continue;
|
|
231
|
+
|
|
232
|
+
// Apply tag filter
|
|
233
|
+
if (input.tags && input.tags.length > 0) {
|
|
234
|
+
const rowTags = JSON.parse(row.tags as string) as string[];
|
|
235
|
+
const lowerTags = input.tags.map((t) => t.toLowerCase());
|
|
236
|
+
if (
|
|
237
|
+
!lowerTags.some((lt) => rowTags.some((rt) => rt.toLowerCase() === lt))
|
|
238
|
+
) {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const frontmatter = this.rowToFrontmatter(row);
|
|
244
|
+
results.push({
|
|
245
|
+
memory: {
|
|
246
|
+
id: String(row.id),
|
|
247
|
+
slug: String(row.slug),
|
|
248
|
+
category: String(row.category),
|
|
249
|
+
frontmatter,
|
|
250
|
+
excerpt: makeExcerpt(String(row.content)),
|
|
251
|
+
},
|
|
252
|
+
score: r.score,
|
|
253
|
+
matchedOn: r.matchedOn,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return results;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async retrieve(id: string): Promise<Memory | null> {
|
|
261
|
+
// Read from markdown (source of truth)
|
|
262
|
+
return this.markdown.retrieve(id);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async list(input: ListInput): Promise<MemoryMeta[]> {
|
|
266
|
+
let sql = "SELECT * FROM memories WHERE 1=1";
|
|
267
|
+
const params: unknown[] = [];
|
|
268
|
+
|
|
269
|
+
if (input.type) {
|
|
270
|
+
sql += " AND type = ?";
|
|
271
|
+
params.push(input.type);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (input.category) {
|
|
275
|
+
sql += " AND category = ?";
|
|
276
|
+
params.push(input.category);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
sql += " ORDER BY updated DESC";
|
|
280
|
+
|
|
281
|
+
const limit = input.limit ?? 50;
|
|
282
|
+
sql += " LIMIT ?";
|
|
283
|
+
params.push(limit);
|
|
284
|
+
|
|
285
|
+
const stmt = this.db.prepare(sql);
|
|
286
|
+
const rows = (
|
|
287
|
+
stmt as unknown as {
|
|
288
|
+
all(...p: unknown[]): Array<Record<string, unknown>>;
|
|
289
|
+
}
|
|
290
|
+
).all(...params);
|
|
291
|
+
|
|
292
|
+
return rows.map((row) => ({
|
|
293
|
+
id: String(row.id),
|
|
294
|
+
slug: String(row.slug),
|
|
295
|
+
category: String(row.category),
|
|
296
|
+
frontmatter: this.rowToFrontmatter(row),
|
|
297
|
+
excerpt: makeExcerpt(String(row.content)),
|
|
298
|
+
}));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async update(input: UpdateInput): Promise<Memory | null> {
|
|
302
|
+
// 1. Update markdown (source of truth)
|
|
303
|
+
const memory = await this.markdown.update(input);
|
|
304
|
+
if (!memory) return null;
|
|
305
|
+
|
|
306
|
+
// 2. Re-index in SQLite
|
|
307
|
+
const hash = contentHash(
|
|
308
|
+
prepareEmbeddingText({
|
|
309
|
+
title: memory.frontmatter.title,
|
|
310
|
+
description: memory.frontmatter.description,
|
|
311
|
+
tags: memory.frontmatter.tags,
|
|
312
|
+
content: memory.content,
|
|
313
|
+
}),
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
this.upsertRow(memory, hash);
|
|
317
|
+
|
|
318
|
+
// 3. Re-embed
|
|
319
|
+
if (this.vecEnabled && this.pipeline) {
|
|
320
|
+
this.embedAndStore(memory.id, memory, hash).catch((err) => {
|
|
321
|
+
process.stderr.write(
|
|
322
|
+
`[cf-memory] Embedding failed for ${memory.id}: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
323
|
+
);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return memory;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async delete(id: string): Promise<boolean> {
|
|
331
|
+
// 1. Delete from markdown
|
|
332
|
+
const deleted = await this.markdown.delete(id);
|
|
333
|
+
if (!deleted) return false;
|
|
334
|
+
|
|
335
|
+
// 2. Remove from SQLite
|
|
336
|
+
this.db.prepare("DELETE FROM memories WHERE id = ?").run(id);
|
|
337
|
+
|
|
338
|
+
// 3. Remove from vec table
|
|
339
|
+
if (this.vecEnabled) {
|
|
340
|
+
try {
|
|
341
|
+
this.db.prepare("DELETE FROM vec_memories WHERE memory_id = ?").run(id);
|
|
342
|
+
} catch {
|
|
343
|
+
// Ignore vec errors
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async stats(): Promise<MemoryStats> {
|
|
351
|
+
const rows = (
|
|
352
|
+
this.db.prepare(
|
|
353
|
+
"SELECT category, type, COUNT(*) as count FROM memories GROUP BY category, type",
|
|
354
|
+
) as unknown as {
|
|
355
|
+
all(): Array<{ category: string; type: string; count: number }>;
|
|
356
|
+
}
|
|
357
|
+
).all();
|
|
358
|
+
|
|
359
|
+
const byCategory: Record<string, number> = {};
|
|
360
|
+
const byType: Record<string, number> = {};
|
|
361
|
+
let total = 0;
|
|
362
|
+
|
|
363
|
+
for (const row of rows) {
|
|
364
|
+
byCategory[row.category] = (byCategory[row.category] ?? 0) + row.count;
|
|
365
|
+
byType[row.type] = (byType[row.type] ?? 0) + row.count;
|
|
366
|
+
total += row.count;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return { total, byCategory, byType };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Rebuild the SQLite index from markdown files.
|
|
374
|
+
*
|
|
375
|
+
* Two-pass approach:
|
|
376
|
+
* 1. Synchronous transaction: clear + re-insert all rows (atomic)
|
|
377
|
+
* 2. Async pass: generate embeddings (non-transactional, failures logged)
|
|
378
|
+
*/
|
|
379
|
+
async rebuild(): Promise<void> {
|
|
380
|
+
// If embedding model changed, recreate vec table with new dimensions
|
|
381
|
+
if (this.needsEmbeddingRebuild && this.pipeline) {
|
|
382
|
+
try {
|
|
383
|
+
this.db.prepare("DROP TABLE IF EXISTS vec_memories").run();
|
|
384
|
+
createVecTable(this.db, this.pipeline.dims);
|
|
385
|
+
this.vecEnabled = true;
|
|
386
|
+
} catch {
|
|
387
|
+
// Vec setup failed
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Read all markdown files first
|
|
392
|
+
const metas = this.markdown.getAllMeta();
|
|
393
|
+
const memories: Array<{ memory: Memory; hash: string }> = [];
|
|
394
|
+
|
|
395
|
+
for (const meta of metas) {
|
|
396
|
+
const full = await this.markdown.retrieve(meta.id);
|
|
397
|
+
if (!full) continue;
|
|
398
|
+
|
|
399
|
+
const hash = contentHash(
|
|
400
|
+
prepareEmbeddingText({
|
|
401
|
+
title: full.frontmatter.title,
|
|
402
|
+
description: full.frontmatter.description,
|
|
403
|
+
tags: full.frontmatter.tags,
|
|
404
|
+
content: full.content,
|
|
405
|
+
}),
|
|
406
|
+
);
|
|
407
|
+
memories.push({ memory: full, hash });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Pass 1: Atomic transaction for all SQLite rows
|
|
411
|
+
this.db.prepare("BEGIN").run();
|
|
412
|
+
try {
|
|
413
|
+
this.db.prepare("DELETE FROM memories").run();
|
|
414
|
+
if (this.vecEnabled) {
|
|
415
|
+
try {
|
|
416
|
+
this.db.prepare("DELETE FROM vec_memories").run();
|
|
417
|
+
} catch {
|
|
418
|
+
// Ignore if vec table doesn't exist
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
this.db.prepare("DELETE FROM embedding_cache").run();
|
|
422
|
+
|
|
423
|
+
for (const { memory, hash } of memories) {
|
|
424
|
+
this.upsertRow(memory, hash);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
this.db.prepare("COMMIT").run();
|
|
428
|
+
} catch (err) {
|
|
429
|
+
this.db.prepare("ROLLBACK").run();
|
|
430
|
+
throw err;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Pass 2: Async embedding generation (non-transactional)
|
|
434
|
+
if (this.vecEnabled && this.pipeline) {
|
|
435
|
+
for (const { memory, hash } of memories) {
|
|
436
|
+
try {
|
|
437
|
+
await this.embedAndStore(memory.id, memory, hash);
|
|
438
|
+
} catch (err) {
|
|
439
|
+
process.stderr.write(
|
|
440
|
+
`[cf-memory] Embedding failed for ${memory.id}: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Update metadata with current embedding model info
|
|
447
|
+
if (this.pipeline) {
|
|
448
|
+
setMetadata(this.db, "embedding_model", this.pipeline.modelName);
|
|
449
|
+
setMetadata(this.db, "embedding_dims", String(this.pipeline.dims));
|
|
450
|
+
this.needsEmbeddingRebuild = false;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async close(): Promise<void> {
|
|
455
|
+
try {
|
|
456
|
+
(this.db as unknown as { close(): void }).close();
|
|
457
|
+
} catch {
|
|
458
|
+
// Already closed
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// --- Private helpers ---
|
|
463
|
+
|
|
464
|
+
private upsertRow(memory: Memory, hash: string): void {
|
|
465
|
+
const sql = `
|
|
466
|
+
INSERT OR REPLACE INTO memories
|
|
467
|
+
(id, slug, category, title, description, type, tags, importance, created, updated, source, content, content_hash)
|
|
468
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
469
|
+
`;
|
|
470
|
+
|
|
471
|
+
this.db
|
|
472
|
+
.prepare(sql)
|
|
473
|
+
.run(
|
|
474
|
+
memory.id,
|
|
475
|
+
memory.slug,
|
|
476
|
+
memory.category,
|
|
477
|
+
memory.frontmatter.title,
|
|
478
|
+
memory.frontmatter.description,
|
|
479
|
+
memory.frontmatter.type,
|
|
480
|
+
JSON.stringify(memory.frontmatter.tags),
|
|
481
|
+
memory.frontmatter.importance,
|
|
482
|
+
memory.frontmatter.created,
|
|
483
|
+
memory.frontmatter.updated,
|
|
484
|
+
memory.frontmatter.source,
|
|
485
|
+
memory.content,
|
|
486
|
+
hash,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private getRow(id: string): Record<string, unknown> | null {
|
|
491
|
+
return (
|
|
492
|
+
(
|
|
493
|
+
this.db.prepare("SELECT * FROM memories WHERE id = ?") as unknown as {
|
|
494
|
+
get(id: string): Record<string, unknown> | undefined;
|
|
495
|
+
}
|
|
496
|
+
).get(id) ?? null
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private rowToFrontmatter(row: Record<string, unknown>): MemoryFrontmatter {
|
|
501
|
+
return {
|
|
502
|
+
title: String(row.title),
|
|
503
|
+
description: String(row.description),
|
|
504
|
+
type: String(row.type) as MemoryType,
|
|
505
|
+
tags: JSON.parse(String(row.tags ?? "[]")),
|
|
506
|
+
importance: Number(row.importance ?? 3),
|
|
507
|
+
created: String(row.created),
|
|
508
|
+
updated: String(row.updated),
|
|
509
|
+
source: String(row.source ?? "conversation"),
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
private async embedAndStore(
|
|
514
|
+
id: string,
|
|
515
|
+
memory: Memory,
|
|
516
|
+
hash: string,
|
|
517
|
+
): Promise<void> {
|
|
518
|
+
if (!this.pipeline || !this.cache) return;
|
|
519
|
+
|
|
520
|
+
// Check cache first
|
|
521
|
+
let embedding = this.cache.get(hash, this.pipeline.dims);
|
|
522
|
+
if (!embedding) {
|
|
523
|
+
const text = prepareEmbeddingText({
|
|
524
|
+
title: memory.frontmatter.title,
|
|
525
|
+
description: memory.frontmatter.description,
|
|
526
|
+
tags: memory.frontmatter.tags,
|
|
527
|
+
content: memory.content,
|
|
528
|
+
});
|
|
529
|
+
embedding = await this.pipeline.embed(text);
|
|
530
|
+
this.cache.set(hash, embedding, this.pipeline.modelName);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Store in vec table — use byteOffset/byteLength for correct Float32Array handling
|
|
534
|
+
const buffer = Buffer.from(
|
|
535
|
+
embedding.buffer,
|
|
536
|
+
embedding.byteOffset,
|
|
537
|
+
embedding.byteLength,
|
|
538
|
+
);
|
|
539
|
+
try {
|
|
540
|
+
this.db
|
|
541
|
+
.prepare(
|
|
542
|
+
"INSERT OR REPLACE INTO vec_memories (memory_id, embedding) VALUES (?, ?)",
|
|
543
|
+
)
|
|
544
|
+
.run(id, buffer);
|
|
545
|
+
} catch {
|
|
546
|
+
// sqlite-vec error — ignore
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|