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,128 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
SCHEMA_V1,
|
|
4
|
+
SCHEMA_VERSION,
|
|
5
|
+
PRAGMA_SETTINGS,
|
|
6
|
+
EMBEDDING_DIMS,
|
|
7
|
+
SCHEMA_V2_METADATA,
|
|
8
|
+
getVecTableSQL,
|
|
9
|
+
} from "../backends/sqlite/schema.js";
|
|
10
|
+
|
|
11
|
+
describe("Schema constants", () => {
|
|
12
|
+
it("SCHEMA_VERSION is 2", () => {
|
|
13
|
+
expect(SCHEMA_VERSION).toBe(2);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("EMBEDDING_DIMS is 384 (all-MiniLM-L6-v2)", () => {
|
|
17
|
+
expect(EMBEDDING_DIMS).toBe(384);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("SCHEMA_V1 contains all required table creation statements", () => {
|
|
21
|
+
const combined = SCHEMA_V1.join(" ");
|
|
22
|
+
|
|
23
|
+
// Main tables
|
|
24
|
+
expect(combined).toContain("CREATE TABLE IF NOT EXISTS schema_version");
|
|
25
|
+
expect(combined).toContain("CREATE TABLE IF NOT EXISTS memories");
|
|
26
|
+
expect(combined).toContain("CREATE TABLE IF NOT EXISTS embedding_cache");
|
|
27
|
+
|
|
28
|
+
// FTS5 virtual table
|
|
29
|
+
expect(combined).toContain(
|
|
30
|
+
"CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5",
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
// FTS5 sync triggers
|
|
34
|
+
expect(combined).toContain("CREATE TRIGGER IF NOT EXISTS memories_ai");
|
|
35
|
+
expect(combined).toContain("CREATE TRIGGER IF NOT EXISTS memories_ad");
|
|
36
|
+
expect(combined).toContain("CREATE TRIGGER IF NOT EXISTS memories_au");
|
|
37
|
+
|
|
38
|
+
// Indexes
|
|
39
|
+
expect(combined).toContain("CREATE INDEX IF NOT EXISTS idx_memories_type");
|
|
40
|
+
expect(combined).toContain(
|
|
41
|
+
"CREATE INDEX IF NOT EXISTS idx_memories_category",
|
|
42
|
+
);
|
|
43
|
+
expect(combined).toContain(
|
|
44
|
+
"CREATE INDEX IF NOT EXISTS idx_memories_updated",
|
|
45
|
+
);
|
|
46
|
+
expect(combined).toContain(
|
|
47
|
+
"CREATE INDEX IF NOT EXISTS idx_memories_content_hash",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("memories table has all required columns", () => {
|
|
52
|
+
const memoriesTable = SCHEMA_V1.find((s) =>
|
|
53
|
+
s.includes("CREATE TABLE IF NOT EXISTS memories"),
|
|
54
|
+
)!;
|
|
55
|
+
|
|
56
|
+
const requiredColumns = [
|
|
57
|
+
"id TEXT PRIMARY KEY",
|
|
58
|
+
"slug TEXT NOT NULL",
|
|
59
|
+
"category TEXT NOT NULL",
|
|
60
|
+
"title TEXT NOT NULL",
|
|
61
|
+
"description TEXT NOT NULL",
|
|
62
|
+
"type TEXT NOT NULL",
|
|
63
|
+
"tags TEXT NOT NULL",
|
|
64
|
+
"importance INTEGER NOT NULL",
|
|
65
|
+
"created TEXT NOT NULL",
|
|
66
|
+
"updated TEXT NOT NULL",
|
|
67
|
+
"source TEXT NOT NULL",
|
|
68
|
+
"content TEXT NOT NULL",
|
|
69
|
+
"content_hash TEXT NOT NULL",
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
for (const col of requiredColumns) {
|
|
73
|
+
expect(memoriesTable).toContain(col);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("FTS5 table uses porter tokenizer", () => {
|
|
78
|
+
const ftsTable = SCHEMA_V1.find((s) => s.includes("memories_fts"))!;
|
|
79
|
+
expect(ftsTable).toContain("tokenize='porter unicode61'");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("embedding_cache table has required columns", () => {
|
|
83
|
+
const cacheTable = SCHEMA_V1.find((s) =>
|
|
84
|
+
s.includes("CREATE TABLE IF NOT EXISTS embedding_cache"),
|
|
85
|
+
)!;
|
|
86
|
+
expect(cacheTable).toContain("content_hash TEXT PRIMARY KEY");
|
|
87
|
+
expect(cacheTable).toContain("embedding BLOB NOT NULL");
|
|
88
|
+
expect(cacheTable).toContain("model TEXT NOT NULL");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("PRAGMA_SETTINGS", () => {
|
|
93
|
+
it("enables WAL mode", () => {
|
|
94
|
+
expect(PRAGMA_SETTINGS).toContain("PRAGMA journal_mode = WAL");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("sets synchronous to NORMAL", () => {
|
|
98
|
+
expect(PRAGMA_SETTINGS).toContain("PRAGMA synchronous = NORMAL");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("sets cache size", () => {
|
|
102
|
+
const cachePragma = PRAGMA_SETTINGS.find((p) => p.includes("cache_size"));
|
|
103
|
+
expect(cachePragma).toBeDefined();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("getVecTableSQL()", () => {
|
|
108
|
+
it("creates vec0 table with default dims", () => {
|
|
109
|
+
const sql = getVecTableSQL();
|
|
110
|
+
expect(sql).toContain(
|
|
111
|
+
"CREATE VIRTUAL TABLE IF NOT EXISTS vec_memories USING vec0",
|
|
112
|
+
);
|
|
113
|
+
expect(sql).toContain("float[384]");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("supports custom dimensions", () => {
|
|
117
|
+
const sql = getVecTableSQL(768);
|
|
118
|
+
expect(sql).toContain("float[768]");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("SCHEMA_V2_METADATA", () => {
|
|
123
|
+
it("creates metadata table with key-value structure", () => {
|
|
124
|
+
expect(SCHEMA_V2_METADATA).toContain("CREATE TABLE IF NOT EXISTS metadata");
|
|
125
|
+
expect(SCHEMA_V2_METADATA).toContain("key TEXT PRIMARY KEY");
|
|
126
|
+
expect(SCHEMA_V2_METADATA).toContain("value TEXT NOT NULL");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { detectSearchMode, rrfFuse } from "../backends/sqlite/search.js";
|
|
3
|
+
|
|
4
|
+
describe("detectSearchMode()", () => {
|
|
5
|
+
it("returns 'keyword' for quoted strings", () => {
|
|
6
|
+
expect(detectSearchMode('"exact phrase"')).toBe("keyword");
|
|
7
|
+
expect(detectSearchMode("'single quoted'")).toBe("keyword");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("returns 'keyword' for code-like patterns", () => {
|
|
11
|
+
expect(detectSearchMode("auth.middleware")).toBe("keyword");
|
|
12
|
+
expect(detectSearchMode("std::vector")).toBe("keyword");
|
|
13
|
+
expect(detectSearchMode("user->name")).toBe("keyword");
|
|
14
|
+
expect(detectSearchMode("src/lib/auth")).toBe("keyword");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns 'semantic' for questions", () => {
|
|
18
|
+
expect(detectSearchMode("how does auth work")).toBe("semantic");
|
|
19
|
+
expect(detectSearchMode("What is the login flow")).toBe("semantic");
|
|
20
|
+
expect(detectSearchMode("why did we choose JWT")).toBe("semantic");
|
|
21
|
+
expect(detectSearchMode("where is the config stored")).toBe("semantic");
|
|
22
|
+
expect(detectSearchMode("when was this added")).toBe("semantic");
|
|
23
|
+
expect(detectSearchMode("who implemented this")).toBe("semantic");
|
|
24
|
+
expect(detectSearchMode("is this deprecated")).toBe("semantic");
|
|
25
|
+
expect(detectSearchMode("can we use OAuth")).toBe("semantic");
|
|
26
|
+
expect(detectSearchMode("does this support SSO")).toBe("semantic");
|
|
27
|
+
expect(detectSearchMode("should we migrate")).toBe("semantic");
|
|
28
|
+
expect(detectSearchMode("which database to use")).toBe("semantic");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns 'hybrid' for general queries", () => {
|
|
32
|
+
expect(detectSearchMode("authentication")).toBe("hybrid");
|
|
33
|
+
expect(detectSearchMode("JWT tokens")).toBe("hybrid");
|
|
34
|
+
expect(detectSearchMode("database migration")).toBe("hybrid");
|
|
35
|
+
expect(detectSearchMode("CORS fix")).toBe("hybrid");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("handles edge cases", () => {
|
|
39
|
+
expect(detectSearchMode(" authentication ")).toBe("hybrid");
|
|
40
|
+
expect(detectSearchMode("How")).toBe("semantic");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("rrfFuse()", () => {
|
|
45
|
+
it("merges two ranked lists using RRF", () => {
|
|
46
|
+
const list1 = [
|
|
47
|
+
{ id: "a", score: 10, matchedOn: ["title"] },
|
|
48
|
+
{ id: "b", score: 5, matchedOn: ["content"] },
|
|
49
|
+
];
|
|
50
|
+
const list2 = [
|
|
51
|
+
{ id: "b", score: 0.9, matchedOn: ["semantic"] },
|
|
52
|
+
{ id: "c", score: 0.5, matchedOn: ["semantic"] },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const fused = rrfFuse(list1, list2);
|
|
56
|
+
|
|
57
|
+
// "b" appears in both lists, should have highest RRF score
|
|
58
|
+
expect(fused[0].id).toBe("b");
|
|
59
|
+
expect(fused[0].matchedOn).toContain("content");
|
|
60
|
+
expect(fused[0].matchedOn).toContain("semantic");
|
|
61
|
+
|
|
62
|
+
// All three items should be in the result
|
|
63
|
+
expect(fused.map((r) => r.id).sort()).toEqual(["a", "b", "c"]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("handles single list", () => {
|
|
67
|
+
const list = [
|
|
68
|
+
{ id: "a", score: 10, matchedOn: ["title"] },
|
|
69
|
+
{ id: "b", score: 5, matchedOn: ["content"] },
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
const fused = rrfFuse(list);
|
|
73
|
+
expect(fused).toHaveLength(2);
|
|
74
|
+
expect(fused[0].id).toBe("a"); // rank 0 in first list
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("handles empty lists", () => {
|
|
78
|
+
expect(rrfFuse([], [])).toEqual([]);
|
|
79
|
+
expect(rrfFuse([])).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("handles three lists", () => {
|
|
83
|
+
const list1 = [{ id: "a", score: 10, matchedOn: ["title"] }];
|
|
84
|
+
const list2 = [{ id: "a", score: 0.9, matchedOn: ["semantic"] }];
|
|
85
|
+
const list3 = [{ id: "b", score: 5, matchedOn: ["tags"] }];
|
|
86
|
+
|
|
87
|
+
const fused = rrfFuse(list1, list2, list3);
|
|
88
|
+
|
|
89
|
+
// "a" appears in 2 lists, should rank higher than "b" in 1 list
|
|
90
|
+
expect(fused[0].id).toBe("a");
|
|
91
|
+
expect(fused[0].score).toBeGreaterThan(fused[1].score);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("RRF scores are positive", () => {
|
|
95
|
+
const list = [
|
|
96
|
+
{ id: "a", score: 10, matchedOn: ["title"] },
|
|
97
|
+
{ id: "b", score: 5, matchedOn: ["content"] },
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
const fused = rrfFuse(list);
|
|
101
|
+
for (const item of fused) {
|
|
102
|
+
expect(item.score).toBeGreaterThan(0);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("merges matchedOn from multiple lists", () => {
|
|
107
|
+
const list1 = [{ id: "a", score: 10, matchedOn: ["title", "tags"] }];
|
|
108
|
+
const list2 = [{ id: "a", score: 0.9, matchedOn: ["semantic"] }];
|
|
109
|
+
|
|
110
|
+
const fused = rrfFuse(list1, list2);
|
|
111
|
+
expect(fused[0].matchedOn).toContain("title");
|
|
112
|
+
expect(fused[0].matchedOn).toContain("tags");
|
|
113
|
+
expect(fused[0].matchedOn).toContain("semantic");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { applyTemporalDecay } from "../lib/temporal-decay.js";
|
|
3
|
+
|
|
4
|
+
describe("applyTemporalDecay()", () => {
|
|
5
|
+
it("keeps ~100% of score for today's date", () => {
|
|
6
|
+
const today = new Date().toISOString().split("T")[0];
|
|
7
|
+
const result = applyTemporalDecay(10, today);
|
|
8
|
+
// Should be very close to original score
|
|
9
|
+
expect(result).toBeGreaterThan(9.9);
|
|
10
|
+
expect(result).toBeLessThanOrEqual(10);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("decays score for 90-day-old memories", () => {
|
|
14
|
+
const date90ago = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000)
|
|
15
|
+
.toISOString()
|
|
16
|
+
.split("T")[0];
|
|
17
|
+
const result = applyTemporalDecay(10, date90ago);
|
|
18
|
+
// 0.7 + 0.3 * exp(-1) ≈ 0.7 + 0.3 * 0.368 ≈ 0.81
|
|
19
|
+
expect(result).toBeGreaterThan(7.5);
|
|
20
|
+
expect(result).toBeLessThan(8.5);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("keeps at least 70% of score for very old memories", () => {
|
|
24
|
+
const result = applyTemporalDecay(10, "2020-01-01");
|
|
25
|
+
// 0.7 + 0.3 * exp(-very_large) ≈ 0.7
|
|
26
|
+
expect(result).toBeGreaterThanOrEqual(6.9);
|
|
27
|
+
expect(result).toBeLessThan(7.2);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("boosts score based on access count", () => {
|
|
31
|
+
const today = new Date().toISOString().split("T")[0];
|
|
32
|
+
const withoutAccess = applyTemporalDecay(10, today);
|
|
33
|
+
const withAccess = applyTemporalDecay(10, today, 5);
|
|
34
|
+
expect(withAccess).toBeGreaterThan(withoutAccess);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("caps access boost at 10", () => {
|
|
38
|
+
const today = new Date().toISOString().split("T")[0];
|
|
39
|
+
const at10 = applyTemporalDecay(10, today, 10);
|
|
40
|
+
const at100 = applyTemporalDecay(10, today, 100);
|
|
41
|
+
expect(at10).toBe(at100);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("handles zero score", () => {
|
|
45
|
+
const result = applyTemporalDecay(0, "2026-01-01");
|
|
46
|
+
expect(result).toBe(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("handles negative score (BM25 negated)", () => {
|
|
50
|
+
const today = new Date().toISOString().split("T")[0];
|
|
51
|
+
const result = applyTemporalDecay(-5, today);
|
|
52
|
+
expect(result).toBeLessThan(0);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdirSync, rmSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { detectTier, createBackendForTier, TIERS } from "../lib/tier.js";
|
|
6
|
+
import { MarkdownBackend } from "../backends/markdown.js";
|
|
7
|
+
import { DaemonClient } from "../lib/daemon-client.js";
|
|
8
|
+
import type { DaemonPaths } from "../daemon/process.js";
|
|
9
|
+
|
|
10
|
+
let testDir: string;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
testDir = join(tmpdir(), `cf-memory-tier-test-${Date.now()}`);
|
|
14
|
+
mkdirSync(testDir, { recursive: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("detectTier()", () => {
|
|
22
|
+
it("returns Tier 3 (markdown) when no daemon is running and no sqlite deps", async () => {
|
|
23
|
+
const lazyInstall = await import("../lib/lazy-install.js");
|
|
24
|
+
const sqliteSpy = vi
|
|
25
|
+
.spyOn(lazyInstall, "areSqliteDepsAvailable")
|
|
26
|
+
.mockReturnValue(false);
|
|
27
|
+
try {
|
|
28
|
+
const tier = await detectTier();
|
|
29
|
+
// Without a daemon running and no SQLite deps, should detect Tier 3
|
|
30
|
+
expect(tier.name).toBe("markdown");
|
|
31
|
+
expect(tier.number).toBe(3);
|
|
32
|
+
expect(tier.label).toContain("Tier 3");
|
|
33
|
+
} finally {
|
|
34
|
+
sqliteSpy.mockRestore();
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("respects config override to markdown", async () => {
|
|
39
|
+
const tier = await detectTier("markdown");
|
|
40
|
+
expect(tier.name).toBe("markdown");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("respects config override to lite", async () => {
|
|
44
|
+
const tier = await detectTier("lite");
|
|
45
|
+
expect(tier.name).toBe("lite");
|
|
46
|
+
expect(tier.number).toBe(2);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("respects config override to full", async () => {
|
|
50
|
+
const tier = await detectTier("full");
|
|
51
|
+
expect(tier.name).toBe("full");
|
|
52
|
+
expect(tier.number).toBe(1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("auto mode defaults to markdown when no daemon and no sqlite deps", async () => {
|
|
56
|
+
// Mock areSqliteDepsAvailable to return false
|
|
57
|
+
const lazyInstall = await import("../lib/lazy-install.js");
|
|
58
|
+
const sqliteSpy = vi
|
|
59
|
+
.spyOn(lazyInstall, "areSqliteDepsAvailable")
|
|
60
|
+
.mockReturnValue(false);
|
|
61
|
+
try {
|
|
62
|
+
const tier = await detectTier("auto");
|
|
63
|
+
expect(tier.name).toBe("markdown");
|
|
64
|
+
} finally {
|
|
65
|
+
sqliteSpy.mockRestore();
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("auto mode returns full when SQLite deps are available", async () => {
|
|
70
|
+
const lazyInstall = await import("../lib/lazy-install.js");
|
|
71
|
+
const sqliteSpy = vi
|
|
72
|
+
.spyOn(lazyInstall, "areSqliteDepsAvailable")
|
|
73
|
+
.mockReturnValue(true);
|
|
74
|
+
try {
|
|
75
|
+
const tier = await detectTier("auto");
|
|
76
|
+
expect(tier.name).toBe("full");
|
|
77
|
+
expect(tier.number).toBe(1);
|
|
78
|
+
} finally {
|
|
79
|
+
sqliteSpy.mockRestore();
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("auto mode returns lite when daemon is running (no sqlite deps)", async () => {
|
|
84
|
+
// Mock areSqliteDepsAvailable to return false, isDaemonRunning to return true
|
|
85
|
+
const lazyInstall = await import("../lib/lazy-install.js");
|
|
86
|
+
const sqliteSpy = vi
|
|
87
|
+
.spyOn(lazyInstall, "areSqliteDepsAvailable")
|
|
88
|
+
.mockReturnValue(false);
|
|
89
|
+
const daemonProcess = await import("../daemon/process.js");
|
|
90
|
+
const spy = vi
|
|
91
|
+
.spyOn(daemonProcess, "isDaemonRunning")
|
|
92
|
+
.mockResolvedValue(true);
|
|
93
|
+
try {
|
|
94
|
+
const tier = await detectTier("auto");
|
|
95
|
+
expect(tier.name).toBe("lite");
|
|
96
|
+
expect(tier.number).toBe(2);
|
|
97
|
+
} finally {
|
|
98
|
+
spy.mockRestore();
|
|
99
|
+
sqliteSpy.mockRestore();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("createBackendForTier()", () => {
|
|
105
|
+
it("creates MarkdownBackend for markdown tier", async () => {
|
|
106
|
+
const { backend, tier } = await createBackendForTier(testDir, "markdown");
|
|
107
|
+
expect(tier.name).toBe("markdown");
|
|
108
|
+
expect(backend).toBeInstanceOf(MarkdownBackend);
|
|
109
|
+
await backend.close();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("auto tier falls back to markdown when no daemon and no sqlite deps", async () => {
|
|
113
|
+
const lazyInstall = await import("../lib/lazy-install.js");
|
|
114
|
+
const sqliteSpy = vi
|
|
115
|
+
.spyOn(lazyInstall, "areSqliteDepsAvailable")
|
|
116
|
+
.mockReturnValue(false);
|
|
117
|
+
try {
|
|
118
|
+
const { backend, tier } = await createBackendForTier(testDir, "auto");
|
|
119
|
+
expect(tier.name).toBe("markdown");
|
|
120
|
+
expect(backend).toBeInstanceOf(MarkdownBackend);
|
|
121
|
+
await backend.close();
|
|
122
|
+
} finally {
|
|
123
|
+
sqliteSpy.mockRestore();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("auto tier creates SqliteBackend when sqlite deps available", async () => {
|
|
128
|
+
const lazyInstall = await import("../lib/lazy-install.js");
|
|
129
|
+
if (!lazyInstall.areSqliteDepsAvailable()) return; // skip if deps not installed
|
|
130
|
+
const dbPath = join(testDir, "db.sqlite");
|
|
131
|
+
const { backend, tier } = await createBackendForTier(
|
|
132
|
+
testDir,
|
|
133
|
+
"auto",
|
|
134
|
+
undefined,
|
|
135
|
+
{ dbPath },
|
|
136
|
+
);
|
|
137
|
+
expect(tier.name).toBe("full");
|
|
138
|
+
await backend.close();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("full tier falls back to markdown when SQLite deps not available", async () => {
|
|
142
|
+
// Without lazy-installed deps, SqliteBackend will fail to construct
|
|
143
|
+
// and createBackendForTier should fall back to markdown
|
|
144
|
+
const dbPath = join(testDir, "db.sqlite");
|
|
145
|
+
const { backend, tier } = await createBackendForTier(
|
|
146
|
+
testDir,
|
|
147
|
+
"full",
|
|
148
|
+
undefined,
|
|
149
|
+
{ dbPath },
|
|
150
|
+
);
|
|
151
|
+
// Falls back because better-sqlite3 is not installed in test env
|
|
152
|
+
expect(["markdown", "full"]).toContain(tier.name);
|
|
153
|
+
await backend.close();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("lite tier with running daemon returns DaemonClient", async () => {
|
|
157
|
+
// Start a real daemon to test the full path
|
|
158
|
+
const { MiniSearchBackend } = await import("../backends/minisearch.js");
|
|
159
|
+
const { startDaemonServer } = await import("../daemon/process.js");
|
|
160
|
+
|
|
161
|
+
const docsDir = join(testDir, "docs");
|
|
162
|
+
mkdirSync(docsDir, { recursive: true });
|
|
163
|
+
const daemonDir = join(testDir, "daemon");
|
|
164
|
+
mkdirSync(daemonDir, { recursive: true });
|
|
165
|
+
|
|
166
|
+
const paths: DaemonPaths = {
|
|
167
|
+
socketPath: join(daemonDir, "daemon.sock"),
|
|
168
|
+
pidFile: join(daemonDir, "daemon.pid"),
|
|
169
|
+
logFile: join(daemonDir, "daemon.log"),
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const backend = new MiniSearchBackend(docsDir);
|
|
173
|
+
const handle = startDaemonServer(backend, {
|
|
174
|
+
paths,
|
|
175
|
+
idleTimeoutMs: 0,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await new Promise<void>((resolve) => {
|
|
179
|
+
handle.server.once("listening", resolve);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
// Mock getDaemonPaths so createBackendForTier uses our test paths
|
|
184
|
+
const daemonProcess = await import("../daemon/process.js");
|
|
185
|
+
const pathsSpy = vi
|
|
186
|
+
.spyOn(daemonProcess, "getDaemonPaths")
|
|
187
|
+
.mockReturnValue(paths);
|
|
188
|
+
|
|
189
|
+
const { backend: created, tier } = await createBackendForTier(
|
|
190
|
+
docsDir,
|
|
191
|
+
"lite",
|
|
192
|
+
);
|
|
193
|
+
expect(tier.name).toBe("lite");
|
|
194
|
+
expect(created).toBeInstanceOf(DaemonClient);
|
|
195
|
+
await created.close();
|
|
196
|
+
pathsSpy.mockRestore();
|
|
197
|
+
} finally {
|
|
198
|
+
await new Promise<void>((resolve) => {
|
|
199
|
+
handle.server.close(() => resolve());
|
|
200
|
+
});
|
|
201
|
+
rmSync(paths.socketPath, { force: true });
|
|
202
|
+
rmSync(paths.pidFile, { force: true });
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("lite tier falls back to markdown when daemon is unreachable mid-session", async () => {
|
|
207
|
+
// Simulate: tier detection says "lite" but daemon socket is gone
|
|
208
|
+
// Must also mock SQLite deps away so auto tier picks "lite" over "full"
|
|
209
|
+
const lazyInstall = await import("../lib/lazy-install.js");
|
|
210
|
+
const sqliteSpy = vi
|
|
211
|
+
.spyOn(lazyInstall, "areSqliteDepsAvailable")
|
|
212
|
+
.mockReturnValue(false);
|
|
213
|
+
const daemonProcess = await import("../daemon/process.js");
|
|
214
|
+
const spy = vi
|
|
215
|
+
.spyOn(daemonProcess, "isDaemonRunning")
|
|
216
|
+
.mockResolvedValue(true);
|
|
217
|
+
const pathsSpy = vi.spyOn(daemonProcess, "getDaemonPaths").mockReturnValue({
|
|
218
|
+
socketPath: "/tmp/nonexistent-cf-test-sock-" + Date.now(),
|
|
219
|
+
pidFile: "/tmp/nonexistent-cf-test-pid-" + Date.now(),
|
|
220
|
+
logFile: "/tmp/nonexistent-cf-test-log-" + Date.now(),
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const { backend: created, tier } = await createBackendForTier(
|
|
225
|
+
testDir,
|
|
226
|
+
"auto",
|
|
227
|
+
);
|
|
228
|
+
// DaemonClient.ping() fails → falls back to MarkdownBackend
|
|
229
|
+
expect(tier.name).toBe("markdown");
|
|
230
|
+
expect(created).toBeInstanceOf(MarkdownBackend);
|
|
231
|
+
await created.close();
|
|
232
|
+
} finally {
|
|
233
|
+
sqliteSpy.mockRestore();
|
|
234
|
+
spy.mockRestore();
|
|
235
|
+
pathsSpy.mockRestore();
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("createBackendForTier() with embeddingConfig", () => {
|
|
241
|
+
it("accepts an optional embeddingConfig parameter without error", async () => {
|
|
242
|
+
const embeddingConfig = {
|
|
243
|
+
provider: "ollama" as const,
|
|
244
|
+
model: "nomic-embed-text",
|
|
245
|
+
ollamaUrl: "http://localhost:11434",
|
|
246
|
+
};
|
|
247
|
+
// Should not throw — markdown tier ignores embedding config gracefully
|
|
248
|
+
const { backend, tier } = await createBackendForTier(
|
|
249
|
+
testDir,
|
|
250
|
+
"markdown",
|
|
251
|
+
embeddingConfig,
|
|
252
|
+
);
|
|
253
|
+
expect(tier.name).toBe("markdown");
|
|
254
|
+
expect(backend).toBeInstanceOf(MarkdownBackend);
|
|
255
|
+
await backend.close();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("passes embeddingConfig through when full tier falls back to markdown", async () => {
|
|
259
|
+
const embeddingConfig = {
|
|
260
|
+
provider: "ollama" as const,
|
|
261
|
+
model: "nomic-embed-text",
|
|
262
|
+
};
|
|
263
|
+
const dbPath = join(testDir, "db.sqlite");
|
|
264
|
+
// Full tier will fail (no sqlite deps in test env) and fall back
|
|
265
|
+
const { backend, tier } = await createBackendForTier(
|
|
266
|
+
testDir,
|
|
267
|
+
"full",
|
|
268
|
+
embeddingConfig,
|
|
269
|
+
{ dbPath },
|
|
270
|
+
);
|
|
271
|
+
// Falls back because better-sqlite3 is not installed in test env
|
|
272
|
+
expect(["markdown", "full"]).toContain(tier.name);
|
|
273
|
+
await backend.close();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe("TIERS constant", () => {
|
|
278
|
+
it("has all 3 tier definitions", () => {
|
|
279
|
+
expect(Object.keys(TIERS)).toHaveLength(3);
|
|
280
|
+
expect(TIERS.full.number).toBe(1);
|
|
281
|
+
expect(TIERS.lite.number).toBe(2);
|
|
282
|
+
expect(TIERS.markdown.number).toBe(3);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("each tier has name, label, and number", () => {
|
|
286
|
+
for (const tier of Object.values(TIERS)) {
|
|
287
|
+
expect(tier.name).toBeTruthy();
|
|
288
|
+
expect(tier.label).toBeTruthy();
|
|
289
|
+
expect(tier.number).toBeGreaterThanOrEqual(1);
|
|
290
|
+
expect(tier.number).toBeLessThanOrEqual(3);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
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 { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
|
+
import { MarkdownBackend } from "../backends/markdown.js";
|
|
7
|
+
import { registerAllTools } from "../server.js";
|
|
8
|
+
import { registerAllResources } from "../resources/index.js";
|
|
9
|
+
|
|
10
|
+
let testDir: string;
|
|
11
|
+
let server: McpServer;
|
|
12
|
+
let backend: MarkdownBackend;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
testDir = join(tmpdir(), `cf-memory-tools-test-${Date.now()}`);
|
|
16
|
+
mkdirSync(testDir, { recursive: true });
|
|
17
|
+
backend = new MarkdownBackend(testDir);
|
|
18
|
+
server = new McpServer({
|
|
19
|
+
name: "test-memory-server",
|
|
20
|
+
version: "0.0.1",
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("MCP Tool Registration", () => {
|
|
29
|
+
it("registers all 6 tools without error", () => {
|
|
30
|
+
expect(() => registerAllTools(server, backend)).not.toThrow();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("registers all 2 resources without error", () => {
|
|
34
|
+
expect(() => registerAllResources(server, backend)).not.toThrow();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("registers both tools and resources together", () => {
|
|
38
|
+
expect(() => {
|
|
39
|
+
registerAllTools(server, backend);
|
|
40
|
+
registerAllResources(server, backend);
|
|
41
|
+
}).not.toThrow();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("MemoryBackend interface compliance", () => {
|
|
46
|
+
it("has all 8 required methods", () => {
|
|
47
|
+
expect(typeof backend.store).toBe("function");
|
|
48
|
+
expect(typeof backend.search).toBe("function");
|
|
49
|
+
expect(typeof backend.retrieve).toBe("function");
|
|
50
|
+
expect(typeof backend.list).toBe("function");
|
|
51
|
+
expect(typeof backend.update).toBe("function");
|
|
52
|
+
expect(typeof backend.delete).toBe("function");
|
|
53
|
+
expect(typeof backend.stats).toBe("function");
|
|
54
|
+
expect(typeof backend.close).toBe("function");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("Type validation", () => {
|
|
59
|
+
it("MEMORY_TYPES has exactly 5 types", async () => {
|
|
60
|
+
const { MEMORY_TYPES } = await import("../lib/types.js");
|
|
61
|
+
expect(MEMORY_TYPES).toHaveLength(5);
|
|
62
|
+
expect(MEMORY_TYPES).toContain("fact");
|
|
63
|
+
expect(MEMORY_TYPES).toContain("preference");
|
|
64
|
+
expect(MEMORY_TYPES).toContain("context");
|
|
65
|
+
expect(MEMORY_TYPES).toContain("episode");
|
|
66
|
+
expect(MEMORY_TYPES).toContain("procedure");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("MEMORY_CATEGORIES maps all types to folders", async () => {
|
|
70
|
+
const { MEMORY_CATEGORIES, MEMORY_TYPES } = await import("../lib/types.js");
|
|
71
|
+
for (const type of MEMORY_TYPES) {
|
|
72
|
+
expect(MEMORY_CATEGORIES[type]).toBeTruthy();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("CATEGORY_TO_TYPE is inverse of MEMORY_CATEGORIES", async () => {
|
|
77
|
+
const { MEMORY_CATEGORIES, CATEGORY_TO_TYPE } =
|
|
78
|
+
await import("../lib/types.js");
|
|
79
|
+
for (const [type, category] of Object.entries(MEMORY_CATEGORIES)) {
|
|
80
|
+
expect(CATEGORY_TO_TYPE[category]).toBe(type);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|