coding-friend-cli 1.16.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-D4EWPGBL.js → chunk-C5LYVVEI.js} +1 -1
- package/dist/{chunk-X5WEODUD.js → chunk-CYQU33FY.js} +1 -0
- package/dist/{chunk-QNLL3ZDF.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-AIZJJ5D2.js → config-LZFXXOI4.js} +276 -14
- package/dist/{dev-WJ5QQ35B.js → dev-R3IYWZ3M.js} +2 -2
- package/dist/{disable-JDVOQNZG.js → disable-R6K5YJN4.js} +2 -2
- package/dist/{enable-JBJ4Q2S7.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-FZ3GG53E.js → init-YK6YRTOT.js} +102 -6
- package/dist/{install-I3GOS56Q.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-JN5YIKKM.js → uninstall-3PSUDGI4.js} +3 -3
- package/dist/{update-OWS4IJTG.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 -0
- package/lib/learn-host/package.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
getSchemaVersion,
|
|
4
|
+
migrate,
|
|
5
|
+
applyPragmas,
|
|
6
|
+
getMetadata,
|
|
7
|
+
setMetadata,
|
|
8
|
+
checkEmbeddingMismatch,
|
|
9
|
+
type DatabaseLike,
|
|
10
|
+
} from "../backends/sqlite/migrations.js";
|
|
11
|
+
import { SCHEMA_VERSION } from "../backends/sqlite/schema.js";
|
|
12
|
+
import { DEFAULT_TRANSFORMERS_MODEL } from "../backends/sqlite/embeddings.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* In-memory mock database for testing schema logic without better-sqlite3.
|
|
16
|
+
*
|
|
17
|
+
* Tracks SQL statements and supports basic prepare/get/run.
|
|
18
|
+
* Note: The `exec` method here is better-sqlite3's SQL execution,
|
|
19
|
+
* NOT child_process.exec — there is no shell involvement.
|
|
20
|
+
*/
|
|
21
|
+
function createMockDb(): DatabaseLike & {
|
|
22
|
+
statements: string[];
|
|
23
|
+
tables: Map<string, Array<Record<string, unknown>>>;
|
|
24
|
+
} {
|
|
25
|
+
const statements: string[] = [];
|
|
26
|
+
const tables = new Map<string, Array<Record<string, unknown>>>();
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
statements,
|
|
30
|
+
tables,
|
|
31
|
+
// better-sqlite3 Database.exec() — runs SQL, not shell commands
|
|
32
|
+
exec(sql: string) {
|
|
33
|
+
statements.push(sql);
|
|
34
|
+
|
|
35
|
+
// Simulate table creation and data insertion
|
|
36
|
+
if (sql.includes("INSERT INTO schema_version")) {
|
|
37
|
+
const match = sql.match(/VALUES\s*\((\d+)\)/);
|
|
38
|
+
if (match) {
|
|
39
|
+
tables.set("schema_version", [{ version: parseInt(match[1], 10) }]);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
pragma(pragma: string) {
|
|
44
|
+
statements.push(`PRAGMA ${pragma}`);
|
|
45
|
+
return undefined;
|
|
46
|
+
},
|
|
47
|
+
prepare(sql: string) {
|
|
48
|
+
return {
|
|
49
|
+
get(..._params: unknown[]) {
|
|
50
|
+
if (sql.includes("schema_version")) {
|
|
51
|
+
const rows = tables.get("schema_version");
|
|
52
|
+
return rows?.[0];
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
},
|
|
56
|
+
run(...params: unknown[]) {
|
|
57
|
+
statements.push(sql);
|
|
58
|
+
// Simulate schema_version UPDATE from migrations
|
|
59
|
+
if (sql.includes("UPDATE schema_version SET version")) {
|
|
60
|
+
const version = params[0] as number;
|
|
61
|
+
tables.set("schema_version", [{ version }]);
|
|
62
|
+
}
|
|
63
|
+
return {};
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe("getSchemaVersion()", () => {
|
|
71
|
+
it("returns 0 when table doesn't exist", () => {
|
|
72
|
+
const db = createMockDb();
|
|
73
|
+
// Override prepare to throw (simulating missing table)
|
|
74
|
+
db.prepare = () => ({
|
|
75
|
+
get() {
|
|
76
|
+
throw new Error("no such table: schema_version");
|
|
77
|
+
},
|
|
78
|
+
run() {
|
|
79
|
+
return {};
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(getSchemaVersion(db)).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns version from schema_version table", () => {
|
|
87
|
+
const db = createMockDb();
|
|
88
|
+
db.tables.set("schema_version", [{ version: 1 }]);
|
|
89
|
+
expect(getSchemaVersion(db)).toBe(1);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("applyPragmas()", () => {
|
|
94
|
+
it("runs all PRAGMA settings", () => {
|
|
95
|
+
const db = createMockDb();
|
|
96
|
+
applyPragmas(db);
|
|
97
|
+
|
|
98
|
+
expect(db.statements.some((s) => s.includes("journal_mode"))).toBe(true);
|
|
99
|
+
expect(db.statements.some((s) => s.includes("synchronous"))).toBe(true);
|
|
100
|
+
expect(db.statements.some((s) => s.includes("cache_size"))).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("migrate()", () => {
|
|
105
|
+
it("applies schema from version 0", () => {
|
|
106
|
+
const db = createMockDb();
|
|
107
|
+
const result = migrate(db);
|
|
108
|
+
|
|
109
|
+
expect(result.version).toBe(SCHEMA_VERSION);
|
|
110
|
+
expect(result.migrated).toBe(true);
|
|
111
|
+
|
|
112
|
+
// Should have run schema creation SQL
|
|
113
|
+
expect(db.statements.some((s) => s.includes("CREATE TABLE"))).toBe(true);
|
|
114
|
+
expect(db.statements.some((s) => s.includes("memories_fts"))).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("is idempotent — running twice produces same result", () => {
|
|
118
|
+
const db = createMockDb();
|
|
119
|
+
|
|
120
|
+
const first = migrate(db);
|
|
121
|
+
expect(first.migrated).toBe(true);
|
|
122
|
+
|
|
123
|
+
// Now schema_version exists with version 1
|
|
124
|
+
const second = migrate(db);
|
|
125
|
+
expect(second.version).toBe(SCHEMA_VERSION);
|
|
126
|
+
expect(second.migrated).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("skips migration when already at current version", () => {
|
|
130
|
+
const db = createMockDb();
|
|
131
|
+
db.tables.set("schema_version", [{ version: SCHEMA_VERSION }]);
|
|
132
|
+
|
|
133
|
+
const result = migrate(db);
|
|
134
|
+
expect(result.migrated).toBe(false);
|
|
135
|
+
expect(result.version).toBe(SCHEMA_VERSION);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("migrates from v1 to v2 creating metadata table", () => {
|
|
139
|
+
const db = createMockDb();
|
|
140
|
+
// Simulate existing v1 database
|
|
141
|
+
db.tables.set("schema_version", [{ version: 1 }]);
|
|
142
|
+
|
|
143
|
+
const result = migrate(db);
|
|
144
|
+
expect(result.version).toBe(SCHEMA_VERSION);
|
|
145
|
+
expect(result.migrated).toBe(true);
|
|
146
|
+
|
|
147
|
+
// Should have created metadata table
|
|
148
|
+
expect(
|
|
149
|
+
db.statements.some((s) =>
|
|
150
|
+
s.includes("CREATE TABLE IF NOT EXISTS metadata"),
|
|
151
|
+
),
|
|
152
|
+
).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Enhanced mock database that supports metadata table operations.
|
|
158
|
+
*/
|
|
159
|
+
function createMetadataMockDb(): DatabaseLike & {
|
|
160
|
+
metadata: Map<string, string>;
|
|
161
|
+
} {
|
|
162
|
+
const metadata = new Map<string, string>();
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
metadata,
|
|
166
|
+
// better-sqlite3 Database.exec() - runs SQL, not shell commands
|
|
167
|
+
exec(_sql: string) {
|
|
168
|
+
// Handle metadata table creation (no-op in mock)
|
|
169
|
+
},
|
|
170
|
+
pragma() {
|
|
171
|
+
return undefined;
|
|
172
|
+
},
|
|
173
|
+
prepare(sql: string) {
|
|
174
|
+
return {
|
|
175
|
+
get(...params: unknown[]) {
|
|
176
|
+
if (sql.includes("SELECT value FROM metadata WHERE key = ?")) {
|
|
177
|
+
const key = params[0] as string;
|
|
178
|
+
const val = metadata.get(key);
|
|
179
|
+
return val !== undefined ? { value: val } : undefined;
|
|
180
|
+
}
|
|
181
|
+
return undefined;
|
|
182
|
+
},
|
|
183
|
+
run(...params: unknown[]) {
|
|
184
|
+
if (sql.includes("INSERT OR REPLACE INTO metadata")) {
|
|
185
|
+
const key = params[0] as string;
|
|
186
|
+
const value = params[1] as string;
|
|
187
|
+
metadata.set(key, value);
|
|
188
|
+
}
|
|
189
|
+
return {};
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
describe("getMetadata() and setMetadata()", () => {
|
|
197
|
+
it("returns null for missing key", () => {
|
|
198
|
+
const db = createMetadataMockDb();
|
|
199
|
+
expect(getMetadata(db, "nonexistent")).toBeNull();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("stores and retrieves a value", () => {
|
|
203
|
+
const db = createMetadataMockDb();
|
|
204
|
+
setMetadata(db, "embedding_model", "nomic-embed-text");
|
|
205
|
+
expect(getMetadata(db, "embedding_model")).toBe("nomic-embed-text");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("overwrites existing value", () => {
|
|
209
|
+
const db = createMetadataMockDb();
|
|
210
|
+
setMetadata(db, "embedding_dims", "384");
|
|
211
|
+
setMetadata(db, "embedding_dims", "768");
|
|
212
|
+
expect(getMetadata(db, "embedding_dims")).toBe("768");
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("checkEmbeddingMismatch()", () => {
|
|
217
|
+
it("returns mismatched=false when model and dims match", () => {
|
|
218
|
+
const db = createMetadataMockDb();
|
|
219
|
+
setMetadata(db, "embedding_model", DEFAULT_TRANSFORMERS_MODEL);
|
|
220
|
+
setMetadata(db, "embedding_dims", "384");
|
|
221
|
+
|
|
222
|
+
const result = checkEmbeddingMismatch(db, DEFAULT_TRANSFORMERS_MODEL, 384);
|
|
223
|
+
expect(result.mismatched).toBe(false);
|
|
224
|
+
expect(result.storedModel).toBe(DEFAULT_TRANSFORMERS_MODEL);
|
|
225
|
+
expect(result.storedDims).toBe(384);
|
|
226
|
+
expect(result.currentModel).toBe(DEFAULT_TRANSFORMERS_MODEL);
|
|
227
|
+
expect(result.currentDims).toBe(384);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("returns mismatched=true when model changed", () => {
|
|
231
|
+
const db = createMetadataMockDb();
|
|
232
|
+
setMetadata(db, "embedding_model", DEFAULT_TRANSFORMERS_MODEL);
|
|
233
|
+
setMetadata(db, "embedding_dims", "384");
|
|
234
|
+
|
|
235
|
+
const result = checkEmbeddingMismatch(db, "nomic-embed-text", 768);
|
|
236
|
+
expect(result.mismatched).toBe(true);
|
|
237
|
+
expect(result.storedModel).toBe(DEFAULT_TRANSFORMERS_MODEL);
|
|
238
|
+
expect(result.storedDims).toBe(384);
|
|
239
|
+
expect(result.currentModel).toBe("nomic-embed-text");
|
|
240
|
+
expect(result.currentDims).toBe(768);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("returns mismatched=true when only dims changed", () => {
|
|
244
|
+
const db = createMetadataMockDb();
|
|
245
|
+
setMetadata(db, "embedding_model", "custom-model");
|
|
246
|
+
setMetadata(db, "embedding_dims", "384");
|
|
247
|
+
|
|
248
|
+
const result = checkEmbeddingMismatch(db, "custom-model", 768);
|
|
249
|
+
expect(result.mismatched).toBe(true);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("returns mismatched=false when no metadata exists (fresh db)", () => {
|
|
253
|
+
const db = createMetadataMockDb();
|
|
254
|
+
// No metadata set -- fresh database
|
|
255
|
+
const result = checkEmbeddingMismatch(db, "all-MiniLM-L6-v2", 384);
|
|
256
|
+
expect(result.mismatched).toBe(false);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("returns mismatched=false when only partial metadata exists", () => {
|
|
260
|
+
const db = createMetadataMockDb();
|
|
261
|
+
// Only model set, no dims -- treat as fresh
|
|
262
|
+
setMetadata(db, "embedding_model", DEFAULT_TRANSFORMERS_MODEL);
|
|
263
|
+
const result = checkEmbeddingMismatch(db, DEFAULT_TRANSFORMERS_MODEL, 384);
|
|
264
|
+
expect(result.mismatched).toBe(false);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("v1→v2 migration default matches DEFAULT_TRANSFORMERS_MODEL (no false positive)", () => {
|
|
268
|
+
// Regression: migration used to store "all-MiniLM-L6-v2" but pipeline
|
|
269
|
+
// resolves to "Xenova/all-MiniLM-L6-v2", causing a spurious mismatch warning
|
|
270
|
+
const db = createMockDb();
|
|
271
|
+
// Simulate fresh v0 database → migrate to v2
|
|
272
|
+
migrate(db);
|
|
273
|
+
|
|
274
|
+
// The migration should have stored the full qualified model name
|
|
275
|
+
// that matches what EmbeddingPipeline.modelName returns
|
|
276
|
+
const metaDb = createMetadataMockDb();
|
|
277
|
+
// Simulate what migrateV1ToV2 stores
|
|
278
|
+
setMetadata(metaDb, "embedding_model", "Xenova/all-MiniLM-L6-v2");
|
|
279
|
+
setMetadata(metaDb, "embedding_dims", "384");
|
|
280
|
+
|
|
281
|
+
const result = checkEmbeddingMismatch(
|
|
282
|
+
metaDb,
|
|
283
|
+
DEFAULT_TRANSFORMERS_MODEL,
|
|
284
|
+
384,
|
|
285
|
+
);
|
|
286
|
+
expect(result.mismatched).toBe(false);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdirSync, rmSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { MiniSearchBackend } from "../backends/minisearch.js";
|
|
6
|
+
import type { StoreInput } from "../lib/types.js";
|
|
7
|
+
|
|
8
|
+
let testDir: string;
|
|
9
|
+
let backend: MiniSearchBackend;
|
|
10
|
+
let counter = 0;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
testDir = join(tmpdir(), `cf-memory-mini-test-${Date.now()}-${++counter}`);
|
|
14
|
+
mkdirSync(testDir, { recursive: true });
|
|
15
|
+
backend = new MiniSearchBackend(testDir);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const sampleInput: StoreInput = {
|
|
23
|
+
title: "API Authentication Pattern",
|
|
24
|
+
description: "Auth module uses JWT tokens stored in httpOnly cookies",
|
|
25
|
+
type: "fact",
|
|
26
|
+
tags: ["auth", "jwt", "api"],
|
|
27
|
+
content: "# API Auth\n\nThe project uses JWT tokens with refresh rotation.",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe("MiniSearchBackend", () => {
|
|
31
|
+
describe("store()", () => {
|
|
32
|
+
it("stores a memory and indexes it", async () => {
|
|
33
|
+
const memory = await backend.store(sampleInput);
|
|
34
|
+
expect(memory.id).toBe("features/api-authentication-pattern");
|
|
35
|
+
expect(memory.frontmatter.title).toBe("API Authentication Pattern");
|
|
36
|
+
|
|
37
|
+
// Verify it's searchable
|
|
38
|
+
const results = await backend.search({ query: "authentication" });
|
|
39
|
+
expect(results.length).toBeGreaterThan(0);
|
|
40
|
+
expect(results[0].memory.frontmatter.title).toContain("Authentication");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("search()", () => {
|
|
45
|
+
beforeEach(async () => {
|
|
46
|
+
await backend.store(sampleInput);
|
|
47
|
+
await backend.store({
|
|
48
|
+
title: "Database Migration Guide",
|
|
49
|
+
description: "How to run database migrations with Prisma",
|
|
50
|
+
type: "procedure",
|
|
51
|
+
tags: ["database", "prisma", "migration"],
|
|
52
|
+
content: "# Migrations\n\nRun npx prisma migrate dev.",
|
|
53
|
+
});
|
|
54
|
+
await backend.store({
|
|
55
|
+
title: "CORS Bug Fix",
|
|
56
|
+
description: "Fixed CORS issue on /api/upload endpoint",
|
|
57
|
+
type: "episode",
|
|
58
|
+
tags: ["cors", "api", "bug"],
|
|
59
|
+
content: "# CORS Fix\n\nAdded missing Origin header to allowlist.",
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("finds by title match", async () => {
|
|
64
|
+
const results = await backend.search({ query: "authentication" });
|
|
65
|
+
expect(results.length).toBeGreaterThan(0);
|
|
66
|
+
expect(results[0].memory.frontmatter.title).toContain("Authentication");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("finds by tag match", async () => {
|
|
70
|
+
const results = await backend.search({ query: "prisma" });
|
|
71
|
+
expect(results.length).toBeGreaterThan(0);
|
|
72
|
+
expect(results[0].memory.frontmatter.title).toBe(
|
|
73
|
+
"Database Migration Guide",
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("finds by content match", async () => {
|
|
78
|
+
const results = await backend.search({ query: "allowlist" });
|
|
79
|
+
expect(results.length).toBeGreaterThan(0);
|
|
80
|
+
expect(results[0].memory.frontmatter.title).toContain("CORS");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("fuzzy search finds results that substring grep misses", async () => {
|
|
84
|
+
// Typo: "autentication" (missing 'h')
|
|
85
|
+
const results = await backend.search({ query: "autentication" });
|
|
86
|
+
expect(results.length).toBeGreaterThan(0);
|
|
87
|
+
expect(results[0].memory.frontmatter.title).toContain("Authentication");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("BM25 ranking: both exact and partial find the right result", async () => {
|
|
91
|
+
const exactResults = await backend.search({ query: "authentication" });
|
|
92
|
+
const partialResults = await backend.search({ query: "auth" });
|
|
93
|
+
|
|
94
|
+
// Both should find the auth memory
|
|
95
|
+
expect(exactResults.length).toBeGreaterThan(0);
|
|
96
|
+
expect(partialResults.length).toBeGreaterThan(0);
|
|
97
|
+
|
|
98
|
+
const exactHit = exactResults.find((r) =>
|
|
99
|
+
r.memory.frontmatter.title.includes("Authentication"),
|
|
100
|
+
);
|
|
101
|
+
const partialHit = partialResults.find((r) =>
|
|
102
|
+
r.memory.frontmatter.title.includes("Authentication"),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
expect(exactHit).toBeDefined();
|
|
106
|
+
expect(partialHit).toBeDefined();
|
|
107
|
+
// Both should have positive scores
|
|
108
|
+
expect(exactHit!.score).toBeGreaterThan(0);
|
|
109
|
+
expect(partialHit!.score).toBeGreaterThan(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("boost: title/tags matches rank above content-only matches", async () => {
|
|
113
|
+
// "api" is in both title/tags of CORS and content of auth
|
|
114
|
+
const results = await backend.search({ query: "api" });
|
|
115
|
+
expect(results.length).toBeGreaterThanOrEqual(2);
|
|
116
|
+
// The first result should have matched on title or tags, not just content
|
|
117
|
+
expect(results[0].matchedOn.length).toBeGreaterThan(0);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("filters by type", async () => {
|
|
121
|
+
const results = await backend.search({
|
|
122
|
+
query: "api",
|
|
123
|
+
type: "episode",
|
|
124
|
+
});
|
|
125
|
+
for (const r of results) {
|
|
126
|
+
expect(r.memory.frontmatter.type).toBe("episode");
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("filters by tags", async () => {
|
|
131
|
+
const results = await backend.search({
|
|
132
|
+
query: "api",
|
|
133
|
+
tags: ["cors"],
|
|
134
|
+
});
|
|
135
|
+
expect(results.length).toBe(1);
|
|
136
|
+
expect(results[0].memory.frontmatter.title).toContain("CORS");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("respects limit", async () => {
|
|
140
|
+
const results = await backend.search({ query: "api", limit: 1 });
|
|
141
|
+
expect(results.length).toBe(1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("returns empty for no matches", async () => {
|
|
145
|
+
const results = await backend.search({
|
|
146
|
+
query: "zzz_nonexistent_zzz",
|
|
147
|
+
});
|
|
148
|
+
expect(results).toEqual([]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("empty query returns list results", async () => {
|
|
152
|
+
const results = await backend.search({ query: "" });
|
|
153
|
+
expect(results.length).toBe(3);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("index rebuild after mutations", () => {
|
|
158
|
+
it("search finds newly stored memory", async () => {
|
|
159
|
+
await backend.store(sampleInput);
|
|
160
|
+
|
|
161
|
+
const before = await backend.search({ query: "migration" });
|
|
162
|
+
expect(before.length).toBe(0);
|
|
163
|
+
|
|
164
|
+
await backend.store({
|
|
165
|
+
title: "DB Migration",
|
|
166
|
+
description: "How to migrate",
|
|
167
|
+
type: "procedure",
|
|
168
|
+
tags: ["db"],
|
|
169
|
+
content: "Migration steps.",
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const after = await backend.search({ query: "migration" });
|
|
173
|
+
expect(after.length).toBeGreaterThan(0);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("search reflects updates", async () => {
|
|
177
|
+
const memory = await backend.store(sampleInput);
|
|
178
|
+
|
|
179
|
+
await backend.update({
|
|
180
|
+
id: memory.id,
|
|
181
|
+
title: "Updated Authentication Pattern v2",
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const results = await backend.search({ query: "v2" });
|
|
185
|
+
expect(results.length).toBeGreaterThan(0);
|
|
186
|
+
expect(results[0].memory.frontmatter.title).toContain("v2");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("search excludes deleted memory", async () => {
|
|
190
|
+
const memory = await backend.store(sampleInput);
|
|
191
|
+
|
|
192
|
+
const before = await backend.search({ query: "authentication" });
|
|
193
|
+
expect(before.length).toBe(1);
|
|
194
|
+
|
|
195
|
+
await backend.delete(memory.id);
|
|
196
|
+
|
|
197
|
+
const after = await backend.search({ query: "authentication" });
|
|
198
|
+
expect(after.length).toBe(0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("rebuild() refreshes the full index", async () => {
|
|
202
|
+
await backend.store(sampleInput);
|
|
203
|
+
await backend.store({
|
|
204
|
+
title: "Second Memory",
|
|
205
|
+
description: "Test",
|
|
206
|
+
type: "fact",
|
|
207
|
+
tags: [],
|
|
208
|
+
content: "Content",
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
await backend.rebuild();
|
|
212
|
+
|
|
213
|
+
const results = await backend.search({ query: "authentication" });
|
|
214
|
+
expect(results.length).toBe(1);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("CRUD cycle", () => {
|
|
219
|
+
it("store → retrieve → update → delete", async () => {
|
|
220
|
+
const uniqueInput = {
|
|
221
|
+
...sampleInput,
|
|
222
|
+
title: `CRUD Test Memory ${Date.now()}`,
|
|
223
|
+
};
|
|
224
|
+
const stored = await backend.store(uniqueInput);
|
|
225
|
+
expect(stored.id).toBeTruthy();
|
|
226
|
+
|
|
227
|
+
const retrieved = await backend.retrieve(stored.id);
|
|
228
|
+
expect(retrieved).not.toBeNull();
|
|
229
|
+
expect(retrieved!.frontmatter.title).toBe(uniqueInput.title);
|
|
230
|
+
|
|
231
|
+
const updated = await backend.update({
|
|
232
|
+
id: stored.id,
|
|
233
|
+
title: "Updated Auth",
|
|
234
|
+
});
|
|
235
|
+
expect(updated!.frontmatter.title).toBe("Updated Auth");
|
|
236
|
+
|
|
237
|
+
const deleted = await backend.delete(stored.id);
|
|
238
|
+
expect(deleted).toBe(true);
|
|
239
|
+
|
|
240
|
+
const gone = await backend.retrieve(stored.id);
|
|
241
|
+
expect(gone).toBeNull();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe("stats()", () => {
|
|
246
|
+
it("returns correct counts", async () => {
|
|
247
|
+
await backend.store(sampleInput);
|
|
248
|
+
await backend.store({
|
|
249
|
+
title: "A Bug",
|
|
250
|
+
description: "Bug fix",
|
|
251
|
+
type: "episode",
|
|
252
|
+
tags: [],
|
|
253
|
+
content: "Bug",
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const stats = await backend.stats();
|
|
257
|
+
expect(stats.total).toBe(2);
|
|
258
|
+
expect(stats.byType.fact).toBe(1);
|
|
259
|
+
expect(stats.byType.episode).toBe(1);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
isOllamaRunning,
|
|
4
|
+
listOllamaModels,
|
|
5
|
+
hasOllamaEmbeddingModel,
|
|
6
|
+
detectEmbeddingProvider,
|
|
7
|
+
} from "../lib/ollama.js";
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.restoreAllMocks();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("isOllamaRunning()", () => {
|
|
14
|
+
it("returns false when Ollama is not running", async () => {
|
|
15
|
+
// Use a port that's very unlikely to be in use
|
|
16
|
+
const result = await isOllamaRunning("http://localhost:19999");
|
|
17
|
+
expect(result).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("handles network errors gracefully", async () => {
|
|
21
|
+
const result = await isOllamaRunning("http://invalid-host-xyz:11434");
|
|
22
|
+
expect(result).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("listOllamaModels()", () => {
|
|
27
|
+
it("returns empty array when Ollama is not running", async () => {
|
|
28
|
+
const models = await listOllamaModels("http://localhost:19999");
|
|
29
|
+
expect(models).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("hasOllamaEmbeddingModel()", () => {
|
|
34
|
+
it("returns false when Ollama is not running", async () => {
|
|
35
|
+
const has = await hasOllamaEmbeddingModel(
|
|
36
|
+
"all-minilm:l6-v2",
|
|
37
|
+
"http://localhost:19999",
|
|
38
|
+
);
|
|
39
|
+
expect(has).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("detectEmbeddingProvider()", () => {
|
|
44
|
+
it("falls back to transformers when Ollama is not available", async () => {
|
|
45
|
+
const result = await detectEmbeddingProvider("http://localhost:19999");
|
|
46
|
+
expect(result.provider).toBe("transformers");
|
|
47
|
+
});
|
|
48
|
+
});
|