@astrocyteai/local 0.1.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.
@@ -0,0 +1,209 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { ContextTree } from "../src/context-tree.js";
6
+
7
+ let tmpDir: string;
8
+ let tree: ContextTree;
9
+
10
+ beforeEach(() => {
11
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "astrocyte-test-"));
12
+ tree = new ContextTree(tmpDir);
13
+ });
14
+
15
+ afterEach(() => {
16
+ fs.rmSync(tmpDir, { recursive: true, force: true });
17
+ });
18
+
19
+ describe("ContextTree", () => {
20
+ describe("store", () => {
21
+ it("stores a memory and returns a MemoryEntry", () => {
22
+ const entry = tree.store({
23
+ content: "Calvin prefers dark mode",
24
+ bank_id: "test",
25
+ domain: "preferences",
26
+ tags: ["ui"],
27
+ });
28
+
29
+ expect(entry.id).toBeTruthy();
30
+ expect(entry.id.length).toBe(12);
31
+ expect(entry.bank_id).toBe("test");
32
+ expect(entry.text).toBe("Calvin prefers dark mode");
33
+ expect(entry.domain).toBe("preferences");
34
+ expect(entry.tags).toEqual(["ui"]);
35
+ expect(entry.memory_layer).toBe("fact");
36
+ expect(entry.fact_type).toBe("world");
37
+ expect(entry.recall_count).toBe(0);
38
+ });
39
+
40
+ it("creates markdown file with YAML frontmatter", () => {
41
+ const entry = tree.store({
42
+ content: "Test content",
43
+ bank_id: "test",
44
+ });
45
+
46
+ const fullPath = path.join(tmpDir, "memory", entry.file_path);
47
+ expect(fs.existsSync(fullPath)).toBe(true);
48
+
49
+ const raw = fs.readFileSync(fullPath, "utf-8");
50
+ expect(raw).toContain("---");
51
+ expect(raw).toContain(`id: ${entry.id}`);
52
+ expect(raw).toContain("Test content");
53
+ });
54
+
55
+ it("uses general domain by default", () => {
56
+ const entry = tree.store({ content: "Hello", bank_id: "test" });
57
+ expect(entry.domain).toBe("general");
58
+ });
59
+
60
+ it("handles filename collisions", () => {
61
+ const e1 = tree.store({ content: "Same title", bank_id: "test" });
62
+ const e2 = tree.store({ content: "Same title", bank_id: "test" });
63
+
64
+ expect(e1.file_path).not.toBe(e2.file_path);
65
+ expect(e1.id).not.toBe(e2.id);
66
+ });
67
+
68
+ it("stores metadata", () => {
69
+ const entry = tree.store({
70
+ content: "With metadata",
71
+ bank_id: "test",
72
+ metadata: { importance: 5, verified: true },
73
+ });
74
+
75
+ const read = tree.read(entry.id);
76
+ expect(read).not.toBeNull();
77
+ expect(read!.metadata.importance).toBe(5);
78
+ expect(read!.metadata.verified).toBe(true);
79
+ });
80
+ });
81
+
82
+ describe("read", () => {
83
+ it("reads an existing entry by ID", () => {
84
+ const entry = tree.store({ content: "Readable", bank_id: "test" });
85
+ const read = tree.read(entry.id);
86
+
87
+ expect(read).not.toBeNull();
88
+ expect(read!.id).toBe(entry.id);
89
+ expect(read!.text).toBe("Readable");
90
+ });
91
+
92
+ it("returns null for non-existent ID", () => {
93
+ expect(tree.read("nonexistent")).toBeNull();
94
+ });
95
+ });
96
+
97
+ describe("update", () => {
98
+ it("updates content of existing entry", () => {
99
+ const entry = tree.store({ content: "Original", bank_id: "test" });
100
+ const updated = tree.update(entry.id, "Modified");
101
+
102
+ expect(updated).not.toBeNull();
103
+ expect(updated!.text).toBe("Modified");
104
+ expect(updated!.updated_at).not.toBe(entry.created_at);
105
+ });
106
+
107
+ it("returns null for non-existent ID", () => {
108
+ expect(tree.update("nonexistent", "nope")).toBeNull();
109
+ });
110
+ });
111
+
112
+ describe("delete", () => {
113
+ it("deletes an existing entry", () => {
114
+ const entry = tree.store({ content: "Delete me", bank_id: "test" });
115
+ expect(tree.delete(entry.id)).toBe(true);
116
+ expect(tree.read(entry.id)).toBeNull();
117
+ });
118
+
119
+ it("returns false for non-existent ID", () => {
120
+ expect(tree.delete("nonexistent")).toBe(false);
121
+ });
122
+
123
+ it("cleans up empty domain directories", () => {
124
+ const entry = tree.store({
125
+ content: "Lonely",
126
+ bank_id: "test",
127
+ domain: "temporary",
128
+ });
129
+ const domainDir = path.join(tmpDir, "memory", "temporary");
130
+ expect(fs.existsSync(domainDir)).toBe(true);
131
+
132
+ tree.delete(entry.id);
133
+ expect(fs.existsSync(domainDir)).toBe(false);
134
+ });
135
+ });
136
+
137
+ describe("recordRecall", () => {
138
+ it("increments recall count and sets last_recalled_at", () => {
139
+ const entry = tree.store({ content: "Recalled", bank_id: "test" });
140
+ expect(entry.recall_count).toBe(0);
141
+
142
+ tree.recordRecall(entry.id);
143
+ const read = tree.read(entry.id);
144
+ expect(read!.recall_count).toBe(1);
145
+ expect(read!.last_recalled_at).toBeTruthy();
146
+ });
147
+ });
148
+
149
+ describe("listDomains", () => {
150
+ it("lists all domains", () => {
151
+ tree.store({ content: "A", bank_id: "test", domain: "alpha" });
152
+ tree.store({ content: "B", bank_id: "test", domain: "beta" });
153
+
154
+ const domains = tree.listDomains();
155
+ expect(domains).toContain("alpha");
156
+ expect(domains).toContain("beta");
157
+ });
158
+
159
+ it("filters by bank_id", () => {
160
+ tree.store({ content: "A", bank_id: "bank1", domain: "shared" });
161
+ tree.store({ content: "B", bank_id: "bank2", domain: "private" });
162
+
163
+ const domains = tree.listDomains("bank1");
164
+ expect(domains).toContain("shared");
165
+ expect(domains).not.toContain("private");
166
+ });
167
+ });
168
+
169
+ describe("listEntries", () => {
170
+ it("lists entries in a domain", () => {
171
+ tree.store({ content: "One", bank_id: "test", domain: "stuff" });
172
+ tree.store({ content: "Two", bank_id: "test", domain: "stuff" });
173
+ tree.store({ content: "Other", bank_id: "test", domain: "other" });
174
+
175
+ const entries = tree.listEntries("test", "stuff");
176
+ expect(entries.length).toBe(2);
177
+ });
178
+ });
179
+
180
+ describe("scanAll", () => {
181
+ it("returns all entries", () => {
182
+ tree.store({ content: "A", bank_id: "test", domain: "x" });
183
+ tree.store({ content: "B", bank_id: "test", domain: "y" });
184
+
185
+ expect(tree.scanAll().length).toBe(2);
186
+ });
187
+
188
+ it("filters by bank_id", () => {
189
+ tree.store({ content: "A", bank_id: "bank1" });
190
+ tree.store({ content: "B", bank_id: "bank2" });
191
+
192
+ expect(tree.scanAll("bank1").length).toBe(1);
193
+ });
194
+ });
195
+
196
+ describe("count", () => {
197
+ it("counts all memories", () => {
198
+ tree.store({ content: "A", bank_id: "test" });
199
+ tree.store({ content: "B", bank_id: "test" });
200
+ expect(tree.count()).toBe(2);
201
+ });
202
+
203
+ it("counts by bank", () => {
204
+ tree.store({ content: "A", bank_id: "bank1" });
205
+ tree.store({ content: "B", bank_id: "bank2" });
206
+ expect(tree.count("bank1")).toBe(1);
207
+ });
208
+ });
209
+ });
@@ -0,0 +1,142 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { parseResponse, curateLocalRetain } from "../src/curated-retain.js";
6
+ import type { LLMProvider } from "../src/curated-retain.js";
7
+ import { ContextTree } from "../src/context-tree.js";
8
+ import { SearchEngine } from "../src/search.js";
9
+
10
+ describe("parseResponse", () => {
11
+ it("parses valid JSON", () => {
12
+ const response =
13
+ '{"action": "add", "domain": "preferences", "content": "test", "memory_layer": "fact", "reasoning": "new"}';
14
+ const decision = parseResponse(response, "original");
15
+ expect(decision.action).toBe("add");
16
+ expect(decision.domain).toBe("preferences");
17
+ expect(decision.memory_layer).toBe("fact");
18
+ });
19
+
20
+ it("parses JSON in code block", () => {
21
+ const response =
22
+ '```json\n{"action": "merge", "domain": "arch", "content": "merged", "memory_layer": "observation", "reasoning": "similar", "target_id": "abc123"}\n```';
23
+ const decision = parseResponse(response, "original");
24
+ expect(decision.action).toBe("merge");
25
+ expect(decision.target_id).toBe("abc123");
26
+ });
27
+
28
+ it("falls back on invalid JSON", () => {
29
+ const decision = parseResponse("not json", "original");
30
+ expect(decision.action).toBe("add");
31
+ expect(decision.domain).toBe("general");
32
+ expect(decision.content).toBe("original");
33
+ });
34
+
35
+ it("normalizes domain name", () => {
36
+ const response =
37
+ '{"action": "add", "domain": "My Domain / Sub", "content": "test", "memory_layer": "fact", "reasoning": ""}';
38
+ const decision = parseResponse(response, "original");
39
+ expect(decision.domain).toBe("my-domain---sub");
40
+ });
41
+
42
+ it("handles skip action", () => {
43
+ const response =
44
+ '{"action": "skip", "domain": "", "content": "", "memory_layer": "fact", "reasoning": "redundant"}';
45
+ const decision = parseResponse(response, "original");
46
+ expect(decision.action).toBe("skip");
47
+ });
48
+
49
+ it("handles delete action with target_id", () => {
50
+ const response =
51
+ '{"action": "delete", "domain": "", "content": "", "memory_layer": "fact", "reasoning": "contradicts", "target_id": "old123"}';
52
+ const decision = parseResponse(response, "original");
53
+ expect(decision.action).toBe("delete");
54
+ expect(decision.target_id).toBe("old123");
55
+ });
56
+
57
+ it("sanitizes invalid action to add", () => {
58
+ const response =
59
+ '{"action": "invalid_action", "domain": "test", "content": "test", "memory_layer": "fact", "reasoning": ""}';
60
+ const decision = parseResponse(response, "original");
61
+ expect(decision.action).toBe("add");
62
+ });
63
+
64
+ it("sanitizes invalid memory_layer to fact", () => {
65
+ const response =
66
+ '{"action": "add", "domain": "test", "content": "test", "memory_layer": "invalid", "reasoning": ""}';
67
+ const decision = parseResponse(response, "original");
68
+ expect(decision.memory_layer).toBe("fact");
69
+ });
70
+ });
71
+
72
+ describe("curateLocalRetain", () => {
73
+ let tmpDir: string;
74
+ let tree: ContextTree;
75
+ let search: SearchEngine;
76
+
77
+ beforeEach(() => {
78
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "astrocyte-curated-test-"));
79
+ tree = new ContextTree(tmpDir);
80
+ search = new SearchEngine(path.join(tmpDir, "_search.db"));
81
+ });
82
+
83
+ afterEach(() => {
84
+ search.close();
85
+ fs.rmSync(tmpDir, { recursive: true, force: true });
86
+ });
87
+
88
+ it("returns skip when LLM says skip", async () => {
89
+ const llm: LLMProvider = {
90
+ complete: async () => ({
91
+ text: '{"action": "skip", "domain": "general", "content": "", "memory_layer": "fact", "reasoning": "redundant"}',
92
+ }),
93
+ };
94
+
95
+ const decision = await curateLocalRetain({
96
+ content: "redundant info",
97
+ bankId: "test",
98
+ tree,
99
+ search,
100
+ llmProvider: llm,
101
+ });
102
+ expect(decision.action).toBe("skip");
103
+ });
104
+
105
+ it("classifies domain and layer on add", async () => {
106
+ const llm: LLMProvider = {
107
+ complete: async () => ({
108
+ text: '{"action": "add", "domain": "architecture", "content": "PostgreSQL is the primary database", "memory_layer": "fact", "reasoning": "new technical info"}',
109
+ }),
110
+ };
111
+
112
+ const decision = await curateLocalRetain({
113
+ content: "We use PostgreSQL",
114
+ bankId: "test",
115
+ tree,
116
+ search,
117
+ llmProvider: llm,
118
+ });
119
+ expect(decision.action).toBe("add");
120
+ expect(decision.domain).toBe("architecture");
121
+ expect(decision.memory_layer).toBe("fact");
122
+ });
123
+
124
+ it("falls back to add/general on LLM failure", async () => {
125
+ const llm: LLMProvider = {
126
+ complete: async () => {
127
+ throw new Error("LLM unavailable");
128
+ },
129
+ };
130
+
131
+ const decision = await curateLocalRetain({
132
+ content: "should still store",
133
+ bankId: "test",
134
+ tree,
135
+ search,
136
+ llmProvider: llm,
137
+ });
138
+ expect(decision.action).toBe("add");
139
+ expect(decision.domain).toBe("general");
140
+ expect(decision.reasoning).toContain("failed");
141
+ });
142
+ });
@@ -0,0 +1,163 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
7
+ import { createMcpServer } from "../src/mcp-server.js";
8
+
9
+ let tmpDir: string;
10
+ let client: Client;
11
+
12
+ beforeEach(async () => {
13
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "astrocyte-mcp-test-"));
14
+ const mcpServer = createMcpServer({ root: tmpDir, defaultBank: "test" });
15
+
16
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
17
+ client = new Client({ name: "test-client", version: "1.0.0" });
18
+
19
+ await mcpServer.connect(serverTransport);
20
+ await client.connect(clientTransport);
21
+ });
22
+
23
+ afterEach(async () => {
24
+ await client.close();
25
+ fs.rmSync(tmpDir, { recursive: true, force: true });
26
+ });
27
+
28
+ describe("MCP Server", () => {
29
+ it("lists all tools", async () => {
30
+ const result = await client.listTools();
31
+ const names = result.tools.map((t) => t.name);
32
+
33
+ expect(names).toContain("memory_retain");
34
+ expect(names).toContain("memory_recall");
35
+ expect(names).toContain("memory_browse");
36
+ expect(names).toContain("memory_forget");
37
+ expect(names).toContain("memory_banks");
38
+ expect(names).toContain("memory_health");
39
+ });
40
+
41
+ describe("memory_retain", () => {
42
+ it("stores content and returns result", async () => {
43
+ const result = await client.callTool({
44
+ name: "memory_retain",
45
+ arguments: {
46
+ content: "TypeScript is my preferred language",
47
+ tags: ["preference"],
48
+ },
49
+ });
50
+
51
+ const text = (result.content as Array<{ type: string; text: string }>)[0].text;
52
+ const data = JSON.parse(text);
53
+ expect(data.stored).toBe(true);
54
+ expect(data.memory_id).toBeTruthy();
55
+ expect(data.domain).toBe("preference");
56
+ });
57
+ });
58
+
59
+ describe("memory_recall", () => {
60
+ it("finds stored memories", async () => {
61
+ // Store first
62
+ await client.callTool({
63
+ name: "memory_retain",
64
+ arguments: { content: "I prefer vim keybindings" },
65
+ });
66
+
67
+ // Search
68
+ const result = await client.callTool({
69
+ name: "memory_recall",
70
+ arguments: { query: "vim" },
71
+ });
72
+
73
+ const text = (result.content as Array<{ type: string; text: string }>)[0].text;
74
+ const data = JSON.parse(text);
75
+ expect(data.total).toBeGreaterThanOrEqual(1);
76
+ expect(data.hits[0].text).toContain("vim");
77
+ });
78
+ });
79
+
80
+ describe("memory_browse", () => {
81
+ it("lists domains at root", async () => {
82
+ await client.callTool({
83
+ name: "memory_retain",
84
+ arguments: { content: "In preferences", domain: "preferences" },
85
+ });
86
+
87
+ const result = await client.callTool({
88
+ name: "memory_browse",
89
+ arguments: {},
90
+ });
91
+
92
+ const text = (result.content as Array<{ type: string; text: string }>)[0].text;
93
+ const data = JSON.parse(text);
94
+ expect(data.domains).toContain("preferences");
95
+ expect(data.total_memories).toBeGreaterThanOrEqual(1);
96
+ });
97
+
98
+ it("lists entries in a domain", async () => {
99
+ await client.callTool({
100
+ name: "memory_retain",
101
+ arguments: { content: "Memory in arch", domain: "architecture" },
102
+ });
103
+
104
+ const result = await client.callTool({
105
+ name: "memory_browse",
106
+ arguments: { path: "architecture" },
107
+ });
108
+
109
+ const text = (result.content as Array<{ type: string; text: string }>)[0].text;
110
+ const data = JSON.parse(text);
111
+ expect(data.entries.length).toBeGreaterThanOrEqual(1);
112
+ });
113
+ });
114
+
115
+ describe("memory_forget", () => {
116
+ it("deletes memories by ID", async () => {
117
+ const retainResult = await client.callTool({
118
+ name: "memory_retain",
119
+ arguments: { content: "Delete me soon" },
120
+ });
121
+ const retainData = JSON.parse(
122
+ (retainResult.content as Array<{ type: string; text: string }>)[0].text
123
+ );
124
+
125
+ const result = await client.callTool({
126
+ name: "memory_forget",
127
+ arguments: { memory_ids: [retainData.memory_id] },
128
+ });
129
+
130
+ const text = (result.content as Array<{ type: string; text: string }>)[0].text;
131
+ const data = JSON.parse(text);
132
+ expect(data.deleted_count).toBe(1);
133
+ });
134
+ });
135
+
136
+ describe("memory_banks", () => {
137
+ it("lists available banks", async () => {
138
+ const result = await client.callTool({
139
+ name: "memory_banks",
140
+ arguments: {},
141
+ });
142
+
143
+ const text = (result.content as Array<{ type: string; text: string }>)[0].text;
144
+ const data = JSON.parse(text);
145
+ expect(data.banks).toContain("test");
146
+ expect(data.default).toBe("test");
147
+ });
148
+ });
149
+
150
+ describe("memory_health", () => {
151
+ it("reports health status", async () => {
152
+ const result = await client.callTool({
153
+ name: "memory_health",
154
+ arguments: {},
155
+ });
156
+
157
+ const text = (result.content as Array<{ type: string; text: string }>)[0].text;
158
+ const data = JSON.parse(text);
159
+ expect(data.healthy).toBe(true);
160
+ expect(data.total_memories).toBeDefined();
161
+ });
162
+ });
163
+ });
@@ -0,0 +1,188 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { ContextTree } from "../src/context-tree.js";
6
+ import { SearchEngine } from "../src/search.js";
7
+
8
+ let tmpDir: string;
9
+ let tree: ContextTree;
10
+ let search: SearchEngine;
11
+
12
+ beforeEach(() => {
13
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "astrocyte-search-test-"));
14
+ tree = new ContextTree(tmpDir);
15
+ search = new SearchEngine(path.join(tmpDir, "_search.db"));
16
+ });
17
+
18
+ afterEach(() => {
19
+ search.close();
20
+ fs.rmSync(tmpDir, { recursive: true, force: true });
21
+ });
22
+
23
+ describe("SearchEngine", () => {
24
+ // ── Test Vector 1: Basic keyword match ──
25
+ describe("basic keyword match", () => {
26
+ it("finds memories by keyword", () => {
27
+ const entry = tree.store({
28
+ content: "Calvin prefers dark mode in all applications",
29
+ bank_id: "test",
30
+ tags: ["preference"],
31
+ });
32
+ search.addDocument(entry);
33
+
34
+ const hits = search.search("dark mode", "test");
35
+ expect(hits.length).toBeGreaterThanOrEqual(1);
36
+ expect(hits[0].text).toContain("dark mode");
37
+ });
38
+ });
39
+
40
+ // ── Test Vector 2: Stemming ──
41
+ describe("stemming", () => {
42
+ it("matches stemmed forms (deploy → deployment)", () => {
43
+ const entry = tree.store({
44
+ content: "The deploy pipeline uses GitHub Actions",
45
+ bank_id: "test",
46
+ });
47
+ search.addDocument(entry);
48
+
49
+ const hits = search.search("deploying", "test");
50
+ expect(hits.length).toBeGreaterThanOrEqual(1);
51
+ });
52
+ });
53
+
54
+ // ── Test Vector 3: Tag filtering ──
55
+ describe("tag filtering", () => {
56
+ it("filters by tags", () => {
57
+ const a = tree.store({
58
+ content: "Memory A content",
59
+ bank_id: "test",
60
+ tags: ["alpha"],
61
+ });
62
+ const b = tree.store({
63
+ content: "Memory B content",
64
+ bank_id: "test",
65
+ tags: ["beta"],
66
+ });
67
+ search.addDocument(a);
68
+ search.addDocument(b);
69
+
70
+ const hits = search.search("Memory", "test", { tags: ["alpha"] });
71
+ expect(hits.length).toBe(1);
72
+ expect(hits[0].text).toContain("Memory A");
73
+ });
74
+ });
75
+
76
+ // ── Test Vector 4: Bank isolation ──
77
+ describe("bank isolation", () => {
78
+ it("isolates memories by bank", () => {
79
+ const entry = tree.store({
80
+ content: "Secret information",
81
+ bank_id: "bank-1",
82
+ });
83
+ search.addDocument(entry);
84
+
85
+ const hits = search.search("Secret", "bank-2");
86
+ expect(hits.length).toBe(0);
87
+ });
88
+ });
89
+
90
+ // ── Test Vector 5: Wildcard ──
91
+ describe("wildcard query", () => {
92
+ it("returns all memories with * query", () => {
93
+ const a = tree.store({ content: "Memory 1", bank_id: "test" });
94
+ const b = tree.store({ content: "Memory 2", bank_id: "test" });
95
+ search.addDocument(a);
96
+ search.addDocument(b);
97
+
98
+ const hits = search.search("*", "test");
99
+ expect(hits.length).toBe(2);
100
+ });
101
+ });
102
+
103
+ describe("buildIndex", () => {
104
+ it("rebuilds index from context tree", () => {
105
+ tree.store({ content: "Entry one", bank_id: "test" });
106
+ tree.store({ content: "Entry two", bank_id: "test" });
107
+
108
+ const count = search.buildIndex(tree);
109
+ expect(count).toBe(2);
110
+
111
+ const hits = search.search("Entry", "test");
112
+ expect(hits.length).toBe(2);
113
+ });
114
+
115
+ it("rebuilds for specific bank", () => {
116
+ tree.store({ content: "Bank A entry", bank_id: "a" });
117
+ tree.store({ content: "Bank B entry", bank_id: "b" });
118
+
119
+ search.buildIndex(tree, "a");
120
+ const hitsA = search.search("entry", "a");
121
+ expect(hitsA.length).toBe(1);
122
+ });
123
+ });
124
+
125
+ describe("addDocument / removeDocument", () => {
126
+ it("adds and removes documents incrementally", () => {
127
+ const entry = tree.store({ content: "Temporary", bank_id: "test" });
128
+ search.addDocument(entry);
129
+
130
+ let hits = search.search("Temporary", "test");
131
+ expect(hits.length).toBe(1);
132
+
133
+ search.removeDocument(entry.id);
134
+ hits = search.search("Temporary", "test");
135
+ expect(hits.length).toBe(0);
136
+ });
137
+ });
138
+
139
+ describe("score normalization", () => {
140
+ it("returns scores between 0 and 1", () => {
141
+ const a = tree.store({
142
+ content: "TypeScript is a great programming language",
143
+ bank_id: "test",
144
+ });
145
+ const b = tree.store({
146
+ content: "Python is also a good language for scripting",
147
+ bank_id: "test",
148
+ });
149
+ search.addDocument(a);
150
+ search.addDocument(b);
151
+
152
+ const hits = search.search("programming language", "test");
153
+ for (const h of hits) {
154
+ expect(h.score).toBeGreaterThanOrEqual(0);
155
+ expect(h.score).toBeLessThanOrEqual(1);
156
+ }
157
+ });
158
+ });
159
+
160
+ describe("layer filtering", () => {
161
+ it("filters by memory layer", () => {
162
+ const fact = tree.store({
163
+ content: "A factual memory",
164
+ bank_id: "test",
165
+ memory_layer: "fact",
166
+ });
167
+ const obs = tree.store({
168
+ content: "An observed memory",
169
+ bank_id: "test",
170
+ memory_layer: "observation",
171
+ });
172
+ search.addDocument(fact);
173
+ search.addDocument(obs);
174
+
175
+ const hits = search.search("memory", "test", { layers: ["fact"] });
176
+ expect(hits.length).toBe(1);
177
+ expect(hits[0].memory_layer).toBe("fact");
178
+ });
179
+ });
180
+
181
+ describe("escapeFtsQuery", () => {
182
+ it("strips special FTS5 characters", () => {
183
+ expect(SearchEngine.escapeFtsQuery('"hello" (world)')).toBe("hello world");
184
+ expect(SearchEngine.escapeFtsQuery("test:query")).toBe("test query");
185
+ expect(SearchEngine.escapeFtsQuery("")).toBe("");
186
+ });
187
+ });
188
+ });