@vheins/local-memory-mcp 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.
Files changed (196) hide show
  1. package/DASHBOARD.md +129 -0
  2. package/HYBRID_SEARCH.md +204 -0
  3. package/IMPLEMENTATION.md +159 -0
  4. package/README.md +175 -0
  5. package/dist/capabilities.d.ts +22 -0
  6. package/dist/capabilities.d.ts.map +1 -0
  7. package/dist/capabilities.js +23 -0
  8. package/dist/capabilities.js.map +1 -0
  9. package/dist/dashboard/dashboard.test.d.ts +2 -0
  10. package/dist/dashboard/dashboard.test.d.ts.map +1 -0
  11. package/dist/dashboard/dashboard.test.js +362 -0
  12. package/dist/dashboard/dashboard.test.js.map +1 -0
  13. package/dist/dashboard/public/app.js +1187 -0
  14. package/dist/dashboard/public/chart.js +0 -0
  15. package/dist/dashboard/public/index.html +967 -0
  16. package/dist/dashboard/server.d.ts +3 -0
  17. package/dist/dashboard/server.d.ts.map +1 -0
  18. package/dist/dashboard/server.js +297 -0
  19. package/dist/dashboard/server.js.map +1 -0
  20. package/dist/mcp/client.d.ts +34 -0
  21. package/dist/mcp/client.d.ts.map +1 -0
  22. package/dist/mcp/client.js +181 -0
  23. package/dist/mcp/client.js.map +1 -0
  24. package/dist/mcp/client.test.d.ts +2 -0
  25. package/dist/mcp/client.test.d.ts.map +1 -0
  26. package/dist/mcp/client.test.js +130 -0
  27. package/dist/mcp/client.test.js.map +1 -0
  28. package/dist/prompts/registry.d.ts +39 -0
  29. package/dist/prompts/registry.d.ts.map +1 -0
  30. package/dist/prompts/registry.js +90 -0
  31. package/dist/prompts/registry.js.map +1 -0
  32. package/dist/resources/index.d.ts +17 -0
  33. package/dist/resources/index.d.ts.map +1 -0
  34. package/dist/resources/index.js +100 -0
  35. package/dist/resources/index.js.map +1 -0
  36. package/dist/resources/index.test.d.ts +2 -0
  37. package/dist/resources/index.test.d.ts.map +1 -0
  38. package/dist/resources/index.test.js +96 -0
  39. package/dist/resources/index.test.js.map +1 -0
  40. package/dist/router.d.ts +4 -0
  41. package/dist/router.d.ts.map +1 -0
  42. package/dist/router.js +60 -0
  43. package/dist/router.js.map +1 -0
  44. package/dist/router.test.d.ts +2 -0
  45. package/dist/router.test.d.ts.map +1 -0
  46. package/dist/router.test.js +113 -0
  47. package/dist/router.test.js.map +1 -0
  48. package/dist/search_memory_example.d.ts +3 -0
  49. package/dist/search_memory_example.d.ts.map +1 -0
  50. package/dist/search_memory_example.js +56 -0
  51. package/dist/search_memory_example.js.map +1 -0
  52. package/dist/server.d.ts +3 -0
  53. package/dist/server.d.ts.map +1 -0
  54. package/dist/server.js +91 -0
  55. package/dist/server.js.map +1 -0
  56. package/dist/storage/sqlite.d.ts +95 -0
  57. package/dist/storage/sqlite.d.ts.map +1 -0
  58. package/dist/storage/sqlite.js +537 -0
  59. package/dist/storage/sqlite.js.map +1 -0
  60. package/dist/storage/sqlite.test.d.ts +2 -0
  61. package/dist/storage/sqlite.test.d.ts.map +1 -0
  62. package/dist/storage/sqlite.test.js +358 -0
  63. package/dist/storage/sqlite.test.js.map +1 -0
  64. package/dist/storage/vectors.stub.d.ts +12 -0
  65. package/dist/storage/vectors.stub.d.ts.map +1 -0
  66. package/dist/storage/vectors.stub.js +88 -0
  67. package/dist/storage/vectors.stub.js.map +1 -0
  68. package/dist/store_memory_example.d.ts +3 -0
  69. package/dist/store_memory_example.d.ts.map +1 -0
  70. package/dist/store_memory_example.js +69 -0
  71. package/dist/store_memory_example.js.map +1 -0
  72. package/dist/test_quotes_client.d.ts +3 -0
  73. package/dist/test_quotes_client.d.ts.map +1 -0
  74. package/dist/test_quotes_client.js +72 -0
  75. package/dist/test_quotes_client.js.map +1 -0
  76. package/dist/tools/memory.delete.d.ts +9 -0
  77. package/dist/tools/memory.delete.d.ts.map +1 -0
  78. package/dist/tools/memory.delete.js +22 -0
  79. package/dist/tools/memory.delete.js.map +1 -0
  80. package/dist/tools/memory.recap.d.ts +4 -0
  81. package/dist/tools/memory.recap.d.ts.map +1 -0
  82. package/dist/tools/memory.recap.js +42 -0
  83. package/dist/tools/memory.recap.js.map +1 -0
  84. package/dist/tools/memory.search.d.ts +5 -0
  85. package/dist/tools/memory.search.d.ts.map +1 -0
  86. package/dist/tools/memory.search.js +192 -0
  87. package/dist/tools/memory.search.js.map +1 -0
  88. package/dist/tools/memory.search.test.d.ts +2 -0
  89. package/dist/tools/memory.search.test.d.ts.map +1 -0
  90. package/dist/tools/memory.search.test.js +181 -0
  91. package/dist/tools/memory.search.test.js.map +1 -0
  92. package/dist/tools/memory.store.d.ts +5 -0
  93. package/dist/tools/memory.store.d.ts.map +1 -0
  94. package/dist/tools/memory.store.js +41 -0
  95. package/dist/tools/memory.store.js.map +1 -0
  96. package/dist/tools/memory.summarize.d.ts +4 -0
  97. package/dist/tools/memory.summarize.d.ts.map +1 -0
  98. package/dist/tools/memory.summarize.js +13 -0
  99. package/dist/tools/memory.summarize.js.map +1 -0
  100. package/dist/tools/memory.update.d.ts +5 -0
  101. package/dist/tools/memory.update.d.ts.map +1 -0
  102. package/dist/tools/memory.update.js +31 -0
  103. package/dist/tools/memory.update.js.map +1 -0
  104. package/dist/tools/schemas.d.ts +334 -0
  105. package/dist/tools/schemas.d.ts.map +1 -0
  106. package/dist/tools/schemas.js +251 -0
  107. package/dist/tools/schemas.js.map +1 -0
  108. package/dist/types.d.ts +31 -0
  109. package/dist/types.d.ts.map +1 -0
  110. package/dist/types.js +3 -0
  111. package/dist/types.js.map +1 -0
  112. package/dist/utils/git-scope.d.ts +8 -0
  113. package/dist/utils/git-scope.d.ts.map +1 -0
  114. package/dist/utils/git-scope.js +38 -0
  115. package/dist/utils/git-scope.js.map +1 -0
  116. package/dist/utils/logger.d.ts +7 -0
  117. package/dist/utils/logger.d.ts.map +1 -0
  118. package/dist/utils/logger.js +40 -0
  119. package/dist/utils/logger.js.map +1 -0
  120. package/dist/utils/logger.test.d.ts +2 -0
  121. package/dist/utils/logger.test.d.ts.map +1 -0
  122. package/dist/utils/logger.test.js +84 -0
  123. package/dist/utils/logger.test.js.map +1 -0
  124. package/dist/utils/mcp-response.d.ts +44 -0
  125. package/dist/utils/mcp-response.d.ts.map +1 -0
  126. package/dist/utils/mcp-response.js +81 -0
  127. package/dist/utils/mcp-response.js.map +1 -0
  128. package/dist/utils/normalize.d.ts +4 -0
  129. package/dist/utils/normalize.d.ts.map +1 -0
  130. package/dist/utils/normalize.js +51 -0
  131. package/dist/utils/normalize.js.map +1 -0
  132. package/dist/utils/normalize.test.d.ts +2 -0
  133. package/dist/utils/normalize.test.d.ts.map +1 -0
  134. package/dist/utils/normalize.test.js +159 -0
  135. package/dist/utils/normalize.test.js.map +1 -0
  136. package/dist/utils/query-expander.d.ts +2 -0
  137. package/dist/utils/query-expander.d.ts.map +1 -0
  138. package/dist/utils/query-expander.js +50 -0
  139. package/dist/utils/query-expander.js.map +1 -0
  140. package/dist/utils/query-expander.test.d.ts +2 -0
  141. package/dist/utils/query-expander.test.d.ts.map +1 -0
  142. package/dist/utils/query-expander.test.js +35 -0
  143. package/dist/utils/query-expander.test.js.map +1 -0
  144. package/docs/PRD.md +199 -0
  145. package/docs/PROMPT-agent.md +139 -0
  146. package/docs/SPEC-git-scope.md +172 -0
  147. package/docs/SPEC-heuristics.md +199 -0
  148. package/docs/SPEC-server.md +243 -0
  149. package/docs/SPEC-skeleton.md +255 -0
  150. package/docs/SPEC-sqlite-schema.md +183 -0
  151. package/docs/SPEC-tool-schema.md +201 -0
  152. package/docs/SPEC-vector-search.md +198 -0
  153. package/docs/TEST-scenarios.md +179 -0
  154. package/package.json +43 -0
  155. package/scripts/update-null-titles-ai.mjs +272 -0
  156. package/scripts/update-titles-batch.mjs +71 -0
  157. package/scripts/update-titles.mjs +66 -0
  158. package/seed-data.mjs +151 -0
  159. package/src/capabilities.ts +22 -0
  160. package/src/dashboard/dashboard.test.ts +546 -0
  161. package/src/dashboard/public/app.js +1187 -0
  162. package/src/dashboard/public/chart.js +0 -0
  163. package/src/dashboard/public/index.html +967 -0
  164. package/src/dashboard/server.ts +347 -0
  165. package/src/mcp/client.test.ts +164 -0
  166. package/src/mcp/client.ts +212 -0
  167. package/src/prompts/registry.ts +89 -0
  168. package/src/resources/index.test.ts +132 -0
  169. package/src/resources/index.ts +113 -0
  170. package/src/router.test.ts +145 -0
  171. package/src/router.ts +80 -0
  172. package/src/server.ts +99 -0
  173. package/src/storage/sqlite.test.ts +504 -0
  174. package/src/storage/sqlite.ts +688 -0
  175. package/src/storage/vectors.stub.ts +101 -0
  176. package/src/tools/memory.delete.ts +37 -0
  177. package/src/tools/memory.recap.ts +61 -0
  178. package/src/tools/memory.search.test.ts +276 -0
  179. package/src/tools/memory.search.ts +244 -0
  180. package/src/tools/memory.store.ts +56 -0
  181. package/src/tools/memory.summarize.ts +23 -0
  182. package/src/tools/memory.update.ts +46 -0
  183. package/src/tools/schemas.ts +261 -0
  184. package/src/types.ts +36 -0
  185. package/src/utils/git-scope.ts +42 -0
  186. package/src/utils/logger.test.ts +125 -0
  187. package/src/utils/logger.ts +53 -0
  188. package/src/utils/mcp-response.ts +116 -0
  189. package/src/utils/normalize.test.ts +203 -0
  190. package/src/utils/normalize.ts +53 -0
  191. package/src/utils/query-expander.test.ts +40 -0
  192. package/src/utils/query-expander.ts +60 -0
  193. package/storage/.gitkeep +5 -0
  194. package/test.sh +48 -0
  195. package/tsconfig.json +21 -0
  196. package/vitest.config.ts +10 -0
@@ -0,0 +1,688 @@
1
+ import Database from "better-sqlite3";
2
+ import path from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { MemoryEntry, MemoryScope } from "../types.js";
5
+ import { tokenize } from "../utils/normalize.js";
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const DB_PATH = path.join(__dirname, "../../storage/memory.db");
9
+
10
+ export class SQLiteStore {
11
+ private db: Database.Database;
12
+
13
+ constructor(dbPath?: string) {
14
+ this.db = new Database(dbPath ?? DB_PATH);
15
+ this.migrate();
16
+ }
17
+
18
+ private migrate() {
19
+ // Create base tables
20
+ this.db.exec(`
21
+ CREATE TABLE IF NOT EXISTS memories (
22
+ id TEXT PRIMARY KEY,
23
+ repo TEXT NOT NULL,
24
+ type TEXT NOT NULL CHECK (type IN (
25
+ 'code_fact',
26
+ 'decision',
27
+ 'mistake',
28
+ 'pattern'
29
+ )),
30
+ title TEXT,
31
+ content TEXT NOT NULL,
32
+ importance INTEGER NOT NULL CHECK (importance BETWEEN 1 AND 5),
33
+ folder TEXT,
34
+ language TEXT,
35
+ created_at TEXT NOT NULL,
36
+ updated_at TEXT NOT NULL,
37
+ hit_count INTEGER NOT NULL DEFAULT 0,
38
+ recall_count INTEGER NOT NULL DEFAULT 0,
39
+ last_used_at TEXT
40
+ );
41
+
42
+ CREATE INDEX IF NOT EXISTS idx_memories_repo ON memories(repo);
43
+ CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type);
44
+ CREATE INDEX IF NOT EXISTS idx_memories_importance ON memories(importance);
45
+ CREATE INDEX IF NOT EXISTS idx_memories_hit_count ON memories(hit_count);
46
+ CREATE INDEX IF NOT EXISTS idx_memories_created_at ON memories(created_at);
47
+ CREATE INDEX IF NOT EXISTS idx_memories_updated_at ON memories(updated_at);
48
+ CREATE INDEX IF NOT EXISTS idx_memories_repo_created_at ON memories(repo, created_at DESC);
49
+ CREATE INDEX IF NOT EXISTS idx_memories_repo_hit_count ON memories(repo, hit_count DESC);
50
+ CREATE INDEX IF NOT EXISTS idx_memories_title ON memories(title);
51
+
52
+ CREATE TABLE IF NOT EXISTS memory_summary (
53
+ repo TEXT PRIMARY KEY,
54
+ summary TEXT NOT NULL,
55
+ updated_at TEXT NOT NULL
56
+ );
57
+
58
+ CREATE TABLE IF NOT EXISTS memory_vectors (
59
+ memory_id TEXT PRIMARY KEY,
60
+ vector TEXT NOT NULL,
61
+ updated_at TEXT NOT NULL,
62
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE
63
+ );
64
+ `);
65
+
66
+ // Add title column if it doesn't exist (for existing databases)
67
+ try {
68
+ this.db.exec(`ALTER TABLE memories ADD COLUMN title TEXT`);
69
+ } catch (e) {
70
+ // Column already exists, ignore
71
+ }
72
+
73
+ this.db.exec(`
74
+ CREATE TABLE IF NOT EXISTS memories_archive (
75
+ id TEXT PRIMARY KEY,
76
+ repo TEXT NOT NULL,
77
+ type TEXT NOT NULL,
78
+ content TEXT NOT NULL,
79
+ importance INTEGER NOT NULL,
80
+ folder TEXT,
81
+ language TEXT,
82
+ created_at TEXT NOT NULL,
83
+ updated_at TEXT NOT NULL,
84
+ hit_count INTEGER NOT NULL DEFAULT 0,
85
+ recall_count INTEGER NOT NULL DEFAULT 0,
86
+ last_used_at TEXT,
87
+ expires_at TEXT,
88
+ archived_at TEXT NOT NULL
89
+ );
90
+
91
+ CREATE TABLE IF NOT EXISTS action_log (
92
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
93
+ action TEXT NOT NULL CHECK (action IN ('search', 'read', 'write', 'update', 'delete')),
94
+ query TEXT,
95
+ memory_id TEXT,
96
+ repo TEXT NOT NULL,
97
+ result_count INTEGER NOT NULL DEFAULT 0,
98
+ created_at TEXT NOT NULL
99
+ );
100
+
101
+ CREATE INDEX IF NOT EXISTS idx_action_log_repo ON action_log(repo);
102
+ CREATE INDEX IF NOT EXISTS idx_action_log_created_at ON action_log(created_at);
103
+ `);
104
+
105
+ // Sub-task 2.1: Use PRAGMA table_info to safely add columns
106
+ const existingColumns = (
107
+ this.db.prepare("PRAGMA table_info(memories)").all() as Array<{ name: string }>
108
+ ).map((col) => col.name);
109
+
110
+ const columnsToAdd: Array<{ name: string; definition: string }> = [
111
+ { name: "hit_count", definition: "ALTER TABLE memories ADD COLUMN hit_count INTEGER NOT NULL DEFAULT 0" },
112
+ { name: "recall_count", definition: "ALTER TABLE memories ADD COLUMN recall_count INTEGER NOT NULL DEFAULT 0" },
113
+ { name: "last_used_at", definition: "ALTER TABLE memories ADD COLUMN last_used_at TEXT" },
114
+ { name: "expires_at", definition: "ALTER TABLE memories ADD COLUMN expires_at TEXT" },
115
+ ];
116
+
117
+ for (const col of columnsToAdd) {
118
+ if (!existingColumns.includes(col.name)) {
119
+ this.db.exec(col.definition);
120
+ }
121
+ }
122
+ }
123
+
124
+ insert(entry: MemoryEntry): void {
125
+ if (!entry.title) {
126
+ throw new Error("Title is required for memory entry");
127
+ }
128
+
129
+ const stmt = this.db.prepare(`
130
+ INSERT INTO memories (
131
+ id, repo, type, title, content, importance, folder, language,
132
+ created_at, updated_at, hit_count, recall_count, last_used_at, expires_at
133
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, NULL, ?)
134
+ `);
135
+
136
+ stmt.run(
137
+ entry.id,
138
+ entry.scope.repo,
139
+ entry.type,
140
+ entry.title,
141
+ entry.content,
142
+ entry.importance,
143
+ entry.scope.folder || null,
144
+ entry.scope.language || null,
145
+ entry.created_at,
146
+ entry.updated_at,
147
+ entry.expires_at ?? null
148
+ );
149
+ }
150
+
151
+ update(id: string, updates: { title?: string; content?: string; importance?: number }): void {
152
+ const fields: string[] = [];
153
+ const values: unknown[] = [];
154
+
155
+ if (updates.title !== undefined) {
156
+ if (updates.title.length < 3) {
157
+ throw new Error("Title must be at least 3 characters");
158
+ }
159
+ fields.push("title = ?");
160
+ values.push(updates.title);
161
+ }
162
+
163
+ if (updates.content !== undefined) {
164
+ fields.push("content = ?");
165
+ values.push(updates.content);
166
+ }
167
+
168
+ if (updates.importance !== undefined) {
169
+ fields.push("importance = ?");
170
+ values.push(updates.importance);
171
+ }
172
+
173
+ if (fields.length === 0) {
174
+ return; // Nothing to update
175
+ }
176
+
177
+ fields.push("updated_at = ?");
178
+ values.push(new Date().toISOString());
179
+
180
+ values.push(id);
181
+
182
+ const stmt = this.db.prepare(`
183
+ UPDATE memories
184
+ SET ${fields.join(", ")}
185
+ WHERE id = ?
186
+ `);
187
+
188
+ stmt.run(...values);
189
+ }
190
+
191
+ // Sub-task 2.4: Exclude expired memories from searchByRepo
192
+ searchByRepo(
193
+ repo: string,
194
+ options: {
195
+ types?: string[];
196
+ minImportance?: number;
197
+ limit?: number;
198
+ } = {}
199
+ ): MemoryEntry[] {
200
+ let query = `SELECT * FROM memories WHERE repo = ?
201
+ AND (expires_at IS NULL OR expires_at > datetime('now'))`;
202
+ const params: unknown[] = [repo];
203
+
204
+ if (options.types && options.types.length > 0) {
205
+ const placeholders = options.types.map(() => "?").join(", ");
206
+ query += ` AND type IN (${placeholders})`;
207
+ params.push(...options.types);
208
+ }
209
+
210
+ if (options.minImportance !== undefined) {
211
+ query += " AND importance >= ?";
212
+ params.push(options.minImportance);
213
+ }
214
+
215
+ query += " ORDER BY importance DESC, created_at DESC";
216
+
217
+ if (options.limit !== undefined) {
218
+ query += " LIMIT ?";
219
+ params.push(options.limit);
220
+ }
221
+
222
+ const stmt = this.db.prepare(query);
223
+ const rows = stmt.all(...params) as any[];
224
+
225
+ return rows.map((row) => this.rowToMemoryEntry(row));
226
+ }
227
+
228
+ getById(id: string): MemoryEntry | null {
229
+ const stmt = this.db.prepare("SELECT * FROM memories WHERE id = ?");
230
+ const row = stmt.get(id) as any;
231
+ if (!row) return null;
232
+ return this.rowToMemoryEntry(row);
233
+ }
234
+
235
+ getByIdWithStats(id: string): (MemoryEntry & { recall_rate: number }) | null {
236
+ const stmt = this.db.prepare(`
237
+ SELECT *,
238
+ CASE WHEN hit_count > 0 THEN CAST(recall_count AS REAL) / hit_count ELSE 0 END AS recall_rate
239
+ FROM memories
240
+ WHERE id = ?
241
+ `);
242
+ const row = stmt.get(id) as any;
243
+ if (!row) return null;
244
+
245
+ return {
246
+ ...this.rowToMemoryEntry(row),
247
+ recall_rate: row.recall_rate ?? 0,
248
+ };
249
+ }
250
+
251
+ listRecent(limit: number = 10): Array<{ id: string; type: string; repo: string }> {
252
+ const stmt = this.db.prepare(`
253
+ SELECT id, type, repo
254
+ FROM memories
255
+ ORDER BY created_at DESC
256
+ LIMIT ?
257
+ `);
258
+ return stmt.all(limit) as any[];
259
+ }
260
+
261
+ getSummary(repo: string): { summary: string; updated_at: string } | null {
262
+ const stmt = this.db.prepare("SELECT summary, updated_at FROM memory_summary WHERE repo = ?");
263
+ return stmt.get(repo) as any;
264
+ }
265
+
266
+ upsertSummary(repo: string, summary: string): void {
267
+ const stmt = this.db.prepare(`
268
+ INSERT INTO memory_summary (repo, summary, updated_at)
269
+ VALUES (?, ?, ?)
270
+ ON CONFLICT(repo) DO UPDATE SET
271
+ summary = excluded.summary,
272
+ updated_at = excluded.updated_at
273
+ `);
274
+ stmt.run(repo, summary, new Date().toISOString());
275
+ }
276
+
277
+ delete(id: string): void {
278
+ const stmt = this.db.prepare("DELETE FROM memories WHERE id = ?");
279
+ stmt.run(id);
280
+ }
281
+
282
+ // Sub-task 2.5: Renamed from listAllRepos to listRepos
283
+ listRepos(): string[] {
284
+ const stmt = this.db.prepare("SELECT DISTINCT repo FROM memories ORDER BY repo");
285
+ const rows = stmt.all() as any[];
286
+ return rows.map((row) => row.repo);
287
+ }
288
+
289
+ listRepoNavigation(): Array<{ repo: string; memory_count: number; last_updated_at: string | null }> {
290
+ const stmt = this.db.prepare(`
291
+ SELECT
292
+ repo,
293
+ COUNT(*) AS memory_count,
294
+ MAX(COALESCE(updated_at, created_at)) AS last_updated_at
295
+ FROM memories
296
+ GROUP BY repo
297
+ ORDER BY last_updated_at DESC, repo ASC
298
+ `);
299
+
300
+ return stmt.all() as Array<{ repo: string; memory_count: number; last_updated_at: string | null }>;
301
+ }
302
+
303
+ incrementHitCount(id: string): void {
304
+ const stmt = this.db.prepare(`
305
+ UPDATE memories
306
+ SET hit_count = hit_count + 1,
307
+ last_used_at = ?
308
+ WHERE id = ?
309
+ `);
310
+ stmt.run(new Date().toISOString(), id);
311
+ }
312
+
313
+ incrementRecallCount(id: string): void {
314
+ const stmt = this.db.prepare(`
315
+ UPDATE memories
316
+ SET recall_count = recall_count + 1,
317
+ last_used_at = ?
318
+ WHERE id = ?
319
+ `);
320
+ stmt.run(new Date().toISOString(), id);
321
+ }
322
+
323
+ getStats(repo?: string): {
324
+ total: number;
325
+ byType: Record<string, number>;
326
+ unused: number;
327
+ } {
328
+ let query = "SELECT type, COUNT(*) as count FROM memories";
329
+ const params: unknown[] = [];
330
+
331
+ if (repo) {
332
+ query += " WHERE repo = ?";
333
+ params.push(repo);
334
+ }
335
+
336
+ query += " GROUP BY type";
337
+
338
+ const stmt = this.db.prepare(query);
339
+ const rows = stmt.all(...params) as any[];
340
+
341
+ const byType: Record<string, number> = {};
342
+ let total = 0;
343
+
344
+ for (const row of rows) {
345
+ byType[row.type] = row.count;
346
+ total += row.count;
347
+ }
348
+
349
+ let unusedQuery = "SELECT COUNT(*) as count FROM memories WHERE hit_count = 0";
350
+ if (repo) {
351
+ unusedQuery += " AND repo = ?";
352
+ }
353
+ const unusedStmt = this.db.prepare(unusedQuery);
354
+ const unusedRow = (repo ? unusedStmt.get(repo) : unusedStmt.get()) as any;
355
+ const unused = unusedRow?.count || 0;
356
+
357
+ return { total, byType, unused };
358
+ }
359
+
360
+ // Sub-task 2.2: Public method for Memory_Recap
361
+ getRecentMemories(repo: string, limit: number, offset: number = 0): MemoryEntry[] {
362
+ const stmt = this.db.prepare(`
363
+ SELECT * FROM memories
364
+ WHERE repo = ?
365
+ ORDER BY created_at DESC
366
+ LIMIT ? OFFSET ?
367
+ `);
368
+ const rows = stmt.all(repo, limit, offset) as any[];
369
+ return rows.map((row) => this.rowToMemoryEntry(row));
370
+ }
371
+
372
+ // Sub-task 2.2: Public method for total count
373
+ getTotalCount(repo: string): number {
374
+ const stmt = this.db.prepare("SELECT COUNT(*) as count FROM memories WHERE repo = ?");
375
+ const row = stmt.get(repo) as any;
376
+ return row?.count ?? 0;
377
+ }
378
+
379
+ // Sub-task 2.3: searchBySimilarity with pre-filter SQL + exclude expired
380
+ searchBySimilarity(
381
+ query: string,
382
+ repo: string,
383
+ limit: number = 10
384
+ ): Array<MemoryEntry & { similarity: number }> {
385
+ const queryVector = this.computeVector(query);
386
+ const now = new Date().toISOString();
387
+
388
+ // Pre-filter: fetch at most limit*10 candidates, exclude expired
389
+ const stmt = this.db.prepare(`
390
+ SELECT * FROM memories
391
+ WHERE repo = ?
392
+ AND (expires_at IS NULL OR expires_at > ?)
393
+ ORDER BY importance DESC, created_at DESC
394
+ LIMIT ?
395
+ `);
396
+ const candidates = stmt.all(repo, now, limit * 10) as any[];
397
+
398
+ const withSimilarity = candidates.map((row) => {
399
+ const memory = this.rowToMemoryEntry(row);
400
+ const memoryVector = this.computeVector(memory.content);
401
+ const similarity = this.cosineSimilarity(queryVector, memoryVector);
402
+ return {
403
+ id: row.id,
404
+ type: row.type,
405
+ title: row.title || undefined,
406
+ content: row.content,
407
+ importance: row.importance,
408
+ scope: {
409
+ repo: row.repo,
410
+ folder: row.folder || undefined,
411
+ language: row.language || undefined,
412
+ },
413
+ created_at: row.created_at,
414
+ updated_at: row.updated_at,
415
+ hit_count: row.hit_count ?? 0,
416
+ recall_count: row.recall_count ?? 0,
417
+ last_used_at: row.last_used_at ?? null,
418
+ expires_at: row.expires_at ?? null,
419
+ similarity
420
+ };
421
+ });
422
+
423
+ return withSimilarity
424
+ .sort((a, b) => b.similarity - a.similarity)
425
+ .slice(0, limit);
426
+ }
427
+
428
+ // Sub-task 2.5: Archive expired memories
429
+ archiveExpiredMemories(): number {
430
+ const now = new Date().toISOString();
431
+
432
+ const insertArchive = this.db.prepare(`
433
+ INSERT OR IGNORE INTO memories_archive
434
+ (id, repo, type, content, importance, folder, language,
435
+ created_at, updated_at, hit_count, recall_count, last_used_at, expires_at, archived_at)
436
+ SELECT id, repo, type, content, importance, folder, language,
437
+ created_at, updated_at, hit_count, recall_count, last_used_at, expires_at, ?
438
+ FROM memories
439
+ WHERE expires_at IS NOT NULL AND expires_at <= ?
440
+ `);
441
+
442
+ const deleteExpired = this.db.prepare(`
443
+ DELETE FROM memories
444
+ WHERE expires_at IS NOT NULL AND expires_at <= ?
445
+ `);
446
+
447
+ const archive = this.db.transaction(() => {
448
+ insertArchive.run(now, now);
449
+ const result = deleteExpired.run(now);
450
+ return result.changes;
451
+ });
452
+
453
+ return archive() as number;
454
+ }
455
+
456
+ // Get all memories with stats (for dashboard)
457
+ getAllMemoriesWithStats(repo?: string): Array<
458
+ MemoryEntry & {
459
+ hit_count: number;
460
+ recall_count: number;
461
+ recall_rate: number;
462
+ last_used_at: string | null;
463
+ }
464
+ > {
465
+ let query = "SELECT * FROM memories";
466
+ const params: unknown[] = [];
467
+
468
+ if (repo) {
469
+ query += " WHERE repo = ?";
470
+ params.push(repo);
471
+ }
472
+
473
+ query += " ORDER BY hit_count DESC, importance DESC, created_at DESC";
474
+
475
+ const stmt = this.db.prepare(query);
476
+ const rows = stmt.all(...params) as any[];
477
+
478
+ return rows.map((row) => ({
479
+ ...this.rowToMemoryEntry(row),
480
+ hit_count: row.hit_count || 0,
481
+ recall_count: row.recall_count || 0,
482
+ recall_rate: row.hit_count > 0 ? row.recall_count / row.hit_count : 0,
483
+ last_used_at: row.last_used_at ?? null,
484
+ }));
485
+ }
486
+
487
+ listMemoriesForDashboard(options: {
488
+ repo: string;
489
+ type?: string;
490
+ search?: string;
491
+ minImportance?: number;
492
+ maxImportance?: number;
493
+ sortBy?: string;
494
+ sortOrder?: "asc" | "desc";
495
+ limit?: number;
496
+ offset?: number;
497
+ }): {
498
+ items: Array<
499
+ MemoryEntry & {
500
+ hit_count: number;
501
+ recall_count: number;
502
+ recall_rate: number;
503
+ last_used_at: string | null;
504
+ }
505
+ >;
506
+ total: number;
507
+ } {
508
+ const where: string[] = ["repo = ?"];
509
+ const params: unknown[] = [options.repo];
510
+
511
+ if (options.type) {
512
+ where.push("type = ?");
513
+ params.push(options.type);
514
+ }
515
+
516
+ if (options.search) {
517
+ where.push("(LOWER(COALESCE(title, '')) LIKE ? OR LOWER(content) LIKE ? OR LOWER(id) LIKE ?)");
518
+ const term = `%${options.search.toLowerCase()}%`;
519
+ params.push(term, term, term);
520
+ }
521
+
522
+ if (options.minImportance !== undefined) {
523
+ where.push("importance >= ?");
524
+ params.push(options.minImportance);
525
+ }
526
+
527
+ if (options.maxImportance !== undefined) {
528
+ where.push("importance <= ?");
529
+ params.push(options.maxImportance);
530
+ }
531
+
532
+ const whereSql = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
533
+
534
+ const sortableColumns: Record<string, string> = {
535
+ id: "id",
536
+ title: "COALESCE(title, content)",
537
+ type: "type",
538
+ importance: "importance",
539
+ hit_count: "hit_count",
540
+ recall_rate: "recall_rate",
541
+ created_at: "created_at",
542
+ updated_at: "updated_at",
543
+ };
544
+
545
+ const sortBy = sortableColumns[options.sortBy ?? "hit_count"] ?? sortableColumns.hit_count;
546
+ const sortOrder = options.sortOrder === "asc" ? "ASC" : "DESC";
547
+ const limit = options.limit ?? 25;
548
+ const offset = options.offset ?? 0;
549
+
550
+ const countStmt = this.db.prepare(`SELECT COUNT(*) AS count FROM memories ${whereSql}`);
551
+ const total = (countStmt.get(...params) as { count: number } | undefined)?.count ?? 0;
552
+
553
+ const listStmt = this.db.prepare(`
554
+ SELECT *,
555
+ CASE WHEN hit_count > 0 THEN CAST(recall_count AS REAL) / hit_count ELSE 0 END AS recall_rate
556
+ FROM memories
557
+ ${whereSql}
558
+ ORDER BY ${sortBy} ${sortOrder}, created_at DESC
559
+ LIMIT ? OFFSET ?
560
+ `);
561
+
562
+ const rows = listStmt.all(...params, limit, offset) as any[];
563
+
564
+ return {
565
+ items: rows.map((row) => ({
566
+ ...this.rowToMemoryEntry(row),
567
+ hit_count: row.hit_count || 0,
568
+ recall_count: row.recall_count || 0,
569
+ recall_rate: row.recall_rate ?? 0,
570
+ last_used_at: row.last_used_at ?? null,
571
+ })),
572
+ total,
573
+ };
574
+ }
575
+
576
+ upsertVectorEmbedding(memoryId: string, vector: number[] | string[]): void {
577
+ const stmt = this.db.prepare(`
578
+ INSERT INTO memory_vectors (memory_id, vector, updated_at)
579
+ VALUES (?, ?, ?)
580
+ ON CONFLICT(memory_id) DO UPDATE SET
581
+ vector = excluded.vector,
582
+ updated_at = excluded.updated_at
583
+ `);
584
+ stmt.run(memoryId, JSON.stringify(vector), new Date().toISOString());
585
+ }
586
+
587
+ getVectorEmbedding(memoryId: string): number[] | string[] | null {
588
+ const stmt = this.db.prepare("SELECT vector FROM memory_vectors WHERE memory_id = ?");
589
+ const row = stmt.get(memoryId) as any;
590
+ if (!row) return null;
591
+ try {
592
+ return JSON.parse(row.vector);
593
+ } catch {
594
+ return null;
595
+ }
596
+ }
597
+
598
+ close(): void {
599
+ this.db.close();
600
+ }
601
+
602
+ // Sub-task 2.3: Use tokenize() from normalize.ts (single source of truth)
603
+ private computeVector(text: string): Record<string, number> {
604
+ const tokens = tokenize(text);
605
+ const vector: Record<string, number> = {};
606
+ for (const token of tokens) {
607
+ vector[token] = (vector[token] || 0) + 1;
608
+ }
609
+ return vector;
610
+ }
611
+
612
+ private cosineSimilarity(
613
+ v1: Record<string, number>,
614
+ v2: Record<string, number>
615
+ ): number {
616
+ const keys1 = Object.keys(v1);
617
+ const keys2 = Object.keys(v2);
618
+
619
+ if (keys1.length === 0 || keys2.length === 0) return 0;
620
+
621
+ let dotProduct = 0;
622
+ for (const key of keys1) {
623
+ if (v2[key]) {
624
+ dotProduct += v1[key] * v2[key];
625
+ }
626
+ }
627
+
628
+ const mag1 = Math.sqrt(keys1.reduce((sum, key) => sum + v1[key] * v1[key], 0));
629
+ const mag2 = Math.sqrt(keys2.reduce((sum, key) => sum + v2[key] * v2[key], 0));
630
+
631
+ if (mag1 === 0 || mag2 === 0) return 0;
632
+
633
+ return dotProduct / (mag1 * mag2);
634
+ }
635
+
636
+ private rowToMemoryEntry(row: any): MemoryEntry {
637
+ return {
638
+ id: row.id,
639
+ type: row.type,
640
+ title: row.title || undefined,
641
+ content: row.content,
642
+ importance: row.importance,
643
+ scope: {
644
+ repo: row.repo,
645
+ folder: row.folder || undefined,
646
+ language: row.language || undefined,
647
+ },
648
+ created_at: row.created_at,
649
+ updated_at: row.updated_at,
650
+ hit_count: row.hit_count ?? 0,
651
+ recall_count: row.recall_count ?? 0,
652
+ last_used_at: row.last_used_at ?? null,
653
+ expires_at: row.expires_at ?? null,
654
+ };
655
+ }
656
+
657
+ logAction(action: 'search' | 'read' | 'write' | 'update' | 'delete', repo: string, options?: { query?: string; memoryId?: string; resultCount?: number }) {
658
+ const stmt = this.db.prepare(`
659
+ INSERT INTO action_log (action, query, memory_id, repo, result_count, created_at)
660
+ VALUES (?, ?, ?, ?, ?, ?)
661
+ `);
662
+ stmt.run(
663
+ action,
664
+ options?.query || null,
665
+ options?.memoryId || null,
666
+ repo,
667
+ options?.resultCount || 0,
668
+ new Date().toISOString()
669
+ );
670
+ }
671
+
672
+ getRecentActions(repo?: string, limit = 20): Array<{ action: string; query?: string; memory_id?: string; result_count?: number; created_at: string }> {
673
+ let sql = `SELECT action, query, memory_id, result_count, created_at FROM action_log`;
674
+ const params: string[] = [];
675
+
676
+ if (repo) {
677
+ sql += ` WHERE repo = ?`;
678
+ params.push(repo);
679
+ }
680
+
681
+ sql += ` ORDER BY created_at DESC, id DESC LIMIT ?`;
682
+ params.push(String(limit));
683
+
684
+ const stmt = this.db.prepare(sql);
685
+ const rows = repo ? stmt.all(repo, String(limit)) : stmt.all(String(limit));
686
+ return rows as Array<{ action: string; query?: string; memory_id?: string; created_at: string }>;
687
+ }
688
+ }