synabun 1.3.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 (187) hide show
  1. package/CHANGELOG.md +665 -0
  2. package/LICENSE +191 -0
  3. package/README.md +853 -0
  4. package/hooks/claude-code/post-plan.mjs +391 -0
  5. package/hooks/claude-code/post-remember.mjs +239 -0
  6. package/hooks/claude-code/pre-compact.mjs +241 -0
  7. package/hooks/claude-code/pre-websearch.mjs +90 -0
  8. package/hooks/claude-code/prompt-submit.mjs +861 -0
  9. package/hooks/claude-code/session-start.mjs +494 -0
  10. package/hooks/claude-code/shared.mjs +368 -0
  11. package/hooks/claude-code/stop.mjs +646 -0
  12. package/hooks/claude-code/test-user-learning.mjs +531 -0
  13. package/mcp-server/package.json +38 -0
  14. package/mcp-server/run.mjs +10 -0
  15. package/mcp-server/src/config.ts +70 -0
  16. package/mcp-server/src/http.ts +93 -0
  17. package/mcp-server/src/index.ts +203 -0
  18. package/mcp-server/src/preload-http.ts +25 -0
  19. package/mcp-server/src/preload.ts +29 -0
  20. package/mcp-server/src/scripts/migrate-qdrant-to-sqlite.ts +506 -0
  21. package/mcp-server/src/services/categories.ts +429 -0
  22. package/mcp-server/src/services/discord.ts +702 -0
  23. package/mcp-server/src/services/file-checksums.ts +33 -0
  24. package/mcp-server/src/services/local-embeddings.ts +77 -0
  25. package/mcp-server/src/services/neural-interface.ts +344 -0
  26. package/mcp-server/src/services/sqlite.ts +823 -0
  27. package/mcp-server/src/tools/browser-advanced.ts +123 -0
  28. package/mcp-server/src/tools/browser-interact.ts +255 -0
  29. package/mcp-server/src/tools/browser-navigate.ts +77 -0
  30. package/mcp-server/src/tools/browser-observe.ts +1330 -0
  31. package/mcp-server/src/tools/browser.ts +107 -0
  32. package/mcp-server/src/tools/card-tools.ts +193 -0
  33. package/mcp-server/src/tools/card.ts +22 -0
  34. package/mcp-server/src/tools/category.ts +379 -0
  35. package/mcp-server/src/tools/discord-channel.ts +274 -0
  36. package/mcp-server/src/tools/discord-guild.ts +188 -0
  37. package/mcp-server/src/tools/discord-member.ts +158 -0
  38. package/mcp-server/src/tools/discord-message.ts +231 -0
  39. package/mcp-server/src/tools/discord-onboarding.ts +228 -0
  40. package/mcp-server/src/tools/discord-role.ts +209 -0
  41. package/mcp-server/src/tools/discord-thread.ts +110 -0
  42. package/mcp-server/src/tools/discord-webhook.ts +176 -0
  43. package/mcp-server/src/tools/discord.ts +40 -0
  44. package/mcp-server/src/tools/forget.ts +38 -0
  45. package/mcp-server/src/tools/git-tools.ts +141 -0
  46. package/mcp-server/src/tools/git.ts +13 -0
  47. package/mcp-server/src/tools/image-tools.ts +98 -0
  48. package/mcp-server/src/tools/image.ts +17 -0
  49. package/mcp-server/src/tools/leonardo-browser-tools.ts +281 -0
  50. package/mcp-server/src/tools/leonardo.ts +28 -0
  51. package/mcp-server/src/tools/loop.ts +348 -0
  52. package/mcp-server/src/tools/memories.ts +118 -0
  53. package/mcp-server/src/tools/recall.ts +323 -0
  54. package/mcp-server/src/tools/reflect.ts +145 -0
  55. package/mcp-server/src/tools/remember.ts +115 -0
  56. package/mcp-server/src/tools/response.ts +26 -0
  57. package/mcp-server/src/tools/restore.ts +34 -0
  58. package/mcp-server/src/tools/sync.ts +114 -0
  59. package/mcp-server/src/tools/tictactoe-tools.ts +98 -0
  60. package/mcp-server/src/tools/tictactoe.ts +13 -0
  61. package/mcp-server/src/tools/utils.ts +25 -0
  62. package/mcp-server/src/tools/whiteboard-tools.ts +260 -0
  63. package/mcp-server/src/tools/whiteboard.ts +22 -0
  64. package/mcp-server/src/tui.ts +940 -0
  65. package/mcp-server/src/types.ts +85 -0
  66. package/mcp-server/tsconfig.json +19 -0
  67. package/neural-interface/package.json +27 -0
  68. package/neural-interface/public/apple-touch-icon.png +0 -0
  69. package/neural-interface/public/category-logos/claude.svg +1 -0
  70. package/neural-interface/public/category-logos/gemini-color.svg +1 -0
  71. package/neural-interface/public/category-logos/openai.svg +1 -0
  72. package/neural-interface/public/category-logos/synabun.png +0 -0
  73. package/neural-interface/public/claude-chat.css +878 -0
  74. package/neural-interface/public/claude-chat.html +92 -0
  75. package/neural-interface/public/claude-chat.js +1168 -0
  76. package/neural-interface/public/favicon-16x16.png +0 -0
  77. package/neural-interface/public/favicon-32x32.png +0 -0
  78. package/neural-interface/public/gallery.svg +6 -0
  79. package/neural-interface/public/icon-192.png +0 -0
  80. package/neural-interface/public/icon-512.png +0 -0
  81. package/neural-interface/public/index-legacy.html +14677 -0
  82. package/neural-interface/public/index.html +212 -0
  83. package/neural-interface/public/index2d-legacy.html +8193 -0
  84. package/neural-interface/public/index2d.html +127 -0
  85. package/neural-interface/public/invite.html +351 -0
  86. package/neural-interface/public/leonardoai.svg +1 -0
  87. package/neural-interface/public/logo-synabun.png +0 -0
  88. package/neural-interface/public/logoHD.png +0 -0
  89. package/neural-interface/public/manifest.json +14 -0
  90. package/neural-interface/public/offline.html +299 -0
  91. package/neural-interface/public/onboarding.html +2335 -0
  92. package/neural-interface/public/openclaw-logo-text.png +0 -0
  93. package/neural-interface/public/pin-svgrepo-com.svg +25 -0
  94. package/neural-interface/public/sessionicon.svg +8 -0
  95. package/neural-interface/public/shared/ansi-parser.js +305 -0
  96. package/neural-interface/public/shared/api.js +1043 -0
  97. package/neural-interface/public/shared/colors.js +153 -0
  98. package/neural-interface/public/shared/constants.js +141 -0
  99. package/neural-interface/public/shared/html-shell.js +777 -0
  100. package/neural-interface/public/shared/html-term-renderer.js +1022 -0
  101. package/neural-interface/public/shared/i18n.js +147 -0
  102. package/neural-interface/public/shared/registry.js +82 -0
  103. package/neural-interface/public/shared/state.js +86 -0
  104. package/neural-interface/public/shared/storage.js +150 -0
  105. package/neural-interface/public/shared/styles.css +21212 -0
  106. package/neural-interface/public/shared/ui-automation-studio.js +3573 -0
  107. package/neural-interface/public/shared/ui-bookmarks.js +231 -0
  108. package/neural-interface/public/shared/ui-claude-panel.js +6333 -0
  109. package/neural-interface/public/shared/ui-command-runner.js +534 -0
  110. package/neural-interface/public/shared/ui-cost-widget.js +602 -0
  111. package/neural-interface/public/shared/ui-detail.js +1454 -0
  112. package/neural-interface/public/shared/ui-explorer.js +1356 -0
  113. package/neural-interface/public/shared/ui-file-explorer.js +2750 -0
  114. package/neural-interface/public/shared/ui-games.js +411 -0
  115. package/neural-interface/public/shared/ui-help.js +107 -0
  116. package/neural-interface/public/shared/ui-image-gallery.js +461 -0
  117. package/neural-interface/public/shared/ui-invite.js +519 -0
  118. package/neural-interface/public/shared/ui-keybinds.js +452 -0
  119. package/neural-interface/public/shared/ui-layouts.js +297 -0
  120. package/neural-interface/public/shared/ui-link.js +731 -0
  121. package/neural-interface/public/shared/ui-loading.js +255 -0
  122. package/neural-interface/public/shared/ui-menubar.js +499 -0
  123. package/neural-interface/public/shared/ui-multiselect.js +93 -0
  124. package/neural-interface/public/shared/ui-navbar.js +222 -0
  125. package/neural-interface/public/shared/ui-notifications.js +287 -0
  126. package/neural-interface/public/shared/ui-panels.js +309 -0
  127. package/neural-interface/public/shared/ui-resume.js +653 -0
  128. package/neural-interface/public/shared/ui-search.js +145 -0
  129. package/neural-interface/public/shared/ui-sessions.js +531 -0
  130. package/neural-interface/public/shared/ui-settings.js +5987 -0
  131. package/neural-interface/public/shared/ui-sidebar.js +1905 -0
  132. package/neural-interface/public/shared/ui-skills.js +2805 -0
  133. package/neural-interface/public/shared/ui-stats.js +38 -0
  134. package/neural-interface/public/shared/ui-sync.js +201 -0
  135. package/neural-interface/public/shared/ui-terminal.js +6385 -0
  136. package/neural-interface/public/shared/ui-tooltip.js +123 -0
  137. package/neural-interface/public/shared/ui-trash.js +439 -0
  138. package/neural-interface/public/shared/ui-tutorial-draw.js +875 -0
  139. package/neural-interface/public/shared/ui-tutorial-steps.js +2276 -0
  140. package/neural-interface/public/shared/ui-tutorial.js +1117 -0
  141. package/neural-interface/public/shared/ui-update.js +244 -0
  142. package/neural-interface/public/shared/ui-whiteboard.js +3262 -0
  143. package/neural-interface/public/shared/ui-workspaces.js +722 -0
  144. package/neural-interface/public/shared/utils.js +360 -0
  145. package/neural-interface/public/shared/vterm-buffer.js +1102 -0
  146. package/neural-interface/public/sw.js +52 -0
  147. package/neural-interface/public/synabun.png +0 -0
  148. package/neural-interface/public/toolbar-album.svg +8 -0
  149. package/neural-interface/public/toolbar-question.svg +6 -0
  150. package/neural-interface/public/toolbar-sessions.svg +6 -0
  151. package/neural-interface/public/variant/2d/background.js +113 -0
  152. package/neural-interface/public/variant/2d/context-menu.js +133 -0
  153. package/neural-interface/public/variant/2d/gfx.js +45 -0
  154. package/neural-interface/public/variant/2d/graph.js +1635 -0
  155. package/neural-interface/public/variant/2d/hulls.js +207 -0
  156. package/neural-interface/public/variant/2d/lasso.js +135 -0
  157. package/neural-interface/public/variant/2d/layout.js +594 -0
  158. package/neural-interface/public/variant/2d/main.js +521 -0
  159. package/neural-interface/public/variant/2d/minimap.js +312 -0
  160. package/neural-interface/public/variant/2d/settings-gfx.js +98 -0
  161. package/neural-interface/public/variant/3d/background.js +884 -0
  162. package/neural-interface/public/variant/3d/camera.js +661 -0
  163. package/neural-interface/public/variant/3d/gfx.js +116 -0
  164. package/neural-interface/public/variant/3d/graph.js +2399 -0
  165. package/neural-interface/public/variant/3d/main.js +500 -0
  166. package/neural-interface/public/variant/3d/settings-gfx.js +228 -0
  167. package/neural-interface/scripts/rebuild-pty.js +100 -0
  168. package/neural-interface/server.js +15000 -0
  169. package/neural-interface/templates/CLAUDE-template.md +211 -0
  170. package/package.json +53 -0
  171. package/postinstall.js +135 -0
  172. package/setup.js +260 -0
  173. package/skills/leonardo/SKILL.md +159 -0
  174. package/skills/leonardo/icon.svg +1 -0
  175. package/skills/leonardo/modules/browser-guide.md +378 -0
  176. package/skills/leonardo/modules/image-prompter.md +277 -0
  177. package/skills/leonardo/modules/model-advisor.md +100 -0
  178. package/skills/leonardo/modules/prompt-library.md +110 -0
  179. package/skills/leonardo/modules/style-guide.md +186 -0
  180. package/skills/leonardo/modules/video-prompter.md +311 -0
  181. package/skills/synabun/SKILL.md +185 -0
  182. package/skills/synabun/icon.png +0 -0
  183. package/skills/synabun/modules/audit.md +374 -0
  184. package/skills/synabun/modules/changelog.md +194 -0
  185. package/skills/synabun/modules/idea.md +144 -0
  186. package/skills/synabun/modules/memorize.md +188 -0
  187. package/skills/synabun/synabunicon.png +0 -0
@@ -0,0 +1,823 @@
1
+ /**
2
+ * SQLite storage layer.
3
+ * Uses Node.js built-in node:sqlite (available since Node 22.5.0).
4
+ * Vectors stored as Float32Array BLOBs, cosine similarity computed in JS.
5
+ */
6
+
7
+ import { DatabaseSync } from 'node:sqlite';
8
+ import path from 'path';
9
+ import fs from 'fs';
10
+ import { config } from '../config.js';
11
+ import { getAllCategories } from './categories.js';
12
+ import type { MemoryPayload, MemoryStats, SessionChunkPayload } from '../types.js';
13
+
14
+ type SQLValue = null | number | bigint | string | Uint8Array;
15
+
16
+ // Re-export for backward compat (was used by categories.ts)
17
+ export const CATEGORIES_POINT_ID = '00000000-0000-0000-0000-000000000000';
18
+
19
+ let db: DatabaseSync | null = null;
20
+
21
+ function getDbPath(): string {
22
+ const envPath = process.env.SQLITE_DB_PATH;
23
+ const defaultPath = path.join(config.dataDir, 'memory.db');
24
+ if (!envPath) return defaultPath;
25
+ // If the env path's parent directory doesn't exist (e.g. Windows path on Mac),
26
+ // fall back to the local default so cross-OS restores work instantly
27
+ if (!fs.existsSync(path.dirname(envPath))) return defaultPath;
28
+ return envPath;
29
+ }
30
+
31
+ function getDb(): DatabaseSync {
32
+ if (!db) {
33
+ db = new DatabaseSync(getDbPath());
34
+ db.exec('PRAGMA journal_mode = WAL');
35
+ db.exec('PRAGMA foreign_keys = ON');
36
+ db.exec('PRAGMA busy_timeout = 5000');
37
+ }
38
+ return db;
39
+ }
40
+
41
+ // --- Vector encoding/decoding ---
42
+
43
+ function encodeVector(vector: number[]): Uint8Array {
44
+ const f32 = new Float32Array(vector);
45
+ return new Uint8Array(f32.buffer);
46
+ }
47
+
48
+ function decodeVector(blob: Uint8Array): number[] {
49
+ const f32 = new Float32Array(blob.buffer, blob.byteOffset, blob.byteLength / 4);
50
+ return Array.from(f32);
51
+ }
52
+
53
+ /**
54
+ * Cosine similarity for normalized vectors (equals dot product).
55
+ * Vectors from Transformers.js with normalize:true are unit-length.
56
+ */
57
+ function cosineSimilarity(a: number[], b: number[]): number {
58
+ let dot = 0;
59
+ for (let i = 0; i < a.length; i++) {
60
+ dot += a[i] * b[i];
61
+ }
62
+ return dot;
63
+ }
64
+
65
+ // --- Filter translation ---
66
+
67
+ interface FilterCondition {
68
+ key: string;
69
+ match?: { value: string | number };
70
+ range?: { gte?: number; lte?: number; gt?: number; lt?: number };
71
+ is_empty?: { key: string };
72
+ }
73
+
74
+ interface MemoryFilter {
75
+ must?: FilterCondition[];
76
+ must_not?: FilterCondition[];
77
+ should?: FilterCondition[];
78
+ }
79
+
80
+ /**
81
+ * Translates JSON filters to SQL WHERE clauses.
82
+ * Supports: exact match, range, tags (JSON array contains), is_empty.
83
+ */
84
+ function translateFilter(filter?: Record<string, unknown>): { where: string; params: SQLValue[] } {
85
+ if (!filter) return { where: '', params: [] };
86
+
87
+ const f = filter as MemoryFilter;
88
+ const clauses: string[] = [];
89
+ const params: SQLValue[] = [];
90
+
91
+ for (const cond of f.must || []) {
92
+ if (cond.match) {
93
+ if (cond.key === 'tags') {
94
+ clauses.push(`EXISTS (SELECT 1 FROM json_each(tags) WHERE json_each.value = ?)`);
95
+ params.push(cond.match.value);
96
+ } else {
97
+ clauses.push(`${sanitizeColumn(cond.key)} = ?`);
98
+ params.push(cond.match.value);
99
+ }
100
+ } else if (cond.range) {
101
+ const col = sanitizeColumn(cond.key);
102
+ if (cond.range.gte !== undefined) { clauses.push(`${col} >= ?`); params.push(cond.range.gte); }
103
+ if (cond.range.lte !== undefined) { clauses.push(`${col} <= ?`); params.push(cond.range.lte); }
104
+ if (cond.range.gt !== undefined) { clauses.push(`${col} > ?`); params.push(cond.range.gt); }
105
+ if (cond.range.lt !== undefined) { clauses.push(`${col} < ?`); params.push(cond.range.lt); }
106
+ } else if (cond.is_empty) {
107
+ clauses.push(`(${sanitizeColumn(cond.is_empty.key)} IS NULL OR ${sanitizeColumn(cond.is_empty.key)} = '')`);
108
+ }
109
+ }
110
+
111
+ for (const cond of f.must_not || []) {
112
+ if (cond.match) {
113
+ clauses.push(`(${sanitizeColumn(cond.key)} IS NULL OR ${sanitizeColumn(cond.key)} != ?)`);
114
+ params.push(cond.match.value);
115
+ }
116
+ }
117
+
118
+ return {
119
+ where: clauses.length > 0 ? ' AND ' + clauses.join(' AND ') : '',
120
+ params,
121
+ };
122
+ }
123
+
124
+ /** Allowlist of valid column names to prevent SQL injection */
125
+ const VALID_COLUMNS = new Set([
126
+ 'id', 'content', 'category', 'subcategory', 'project', 'tags', 'importance',
127
+ 'source', 'created_at', 'updated_at', 'accessed_at', 'access_count',
128
+ 'related_files', 'related_memory_ids', 'file_checksums', 'trashed_at',
129
+ 'source_session_chunks', '_type',
130
+ // session_chunks columns
131
+ 'session_id', 'git_branch', 'cwd', 'chunk_index', 'start_timestamp',
132
+ 'end_timestamp', 'tools_used', 'files_modified', 'files_read',
133
+ 'user_messages', 'turn_count', 'dedup_memory_id', 'indexed_at', 'summary',
134
+ ]);
135
+
136
+ function sanitizeColumn(name: string): string {
137
+ if (!VALID_COLUMNS.has(name)) {
138
+ throw new Error(`Invalid column name: ${name}`);
139
+ }
140
+ return name;
141
+ }
142
+
143
+ // --- Schema initialization ---
144
+
145
+ const SCHEMA_SQL = `
146
+ CREATE TABLE IF NOT EXISTS memories (
147
+ id TEXT PRIMARY KEY,
148
+ vector BLOB NOT NULL,
149
+ content TEXT NOT NULL,
150
+ category TEXT NOT NULL,
151
+ subcategory TEXT,
152
+ project TEXT NOT NULL,
153
+ tags TEXT NOT NULL DEFAULT '[]',
154
+ importance INTEGER NOT NULL DEFAULT 5,
155
+ source TEXT NOT NULL DEFAULT 'self-discovered',
156
+ created_at TEXT NOT NULL,
157
+ updated_at TEXT NOT NULL,
158
+ accessed_at TEXT NOT NULL,
159
+ access_count INTEGER NOT NULL DEFAULT 0,
160
+ related_files TEXT,
161
+ related_memory_ids TEXT,
162
+ file_checksums TEXT,
163
+ trashed_at TEXT,
164
+ source_session_chunks TEXT
165
+ );
166
+
167
+ CREATE TABLE IF NOT EXISTS session_chunks (
168
+ id TEXT PRIMARY KEY,
169
+ vector BLOB NOT NULL,
170
+ content TEXT NOT NULL,
171
+ summary TEXT,
172
+ session_id TEXT,
173
+ project TEXT,
174
+ git_branch TEXT,
175
+ cwd TEXT,
176
+ chunk_index INTEGER DEFAULT 0,
177
+ start_timestamp TEXT,
178
+ end_timestamp TEXT,
179
+ tools_used TEXT DEFAULT '[]',
180
+ files_modified TEXT DEFAULT '[]',
181
+ files_read TEXT DEFAULT '[]',
182
+ user_messages TEXT DEFAULT '[]',
183
+ turn_count INTEGER DEFAULT 0,
184
+ related_memory_ids TEXT DEFAULT '[]',
185
+ dedup_memory_id TEXT,
186
+ indexed_at TEXT
187
+ );
188
+
189
+ CREATE TABLE IF NOT EXISTS categories (
190
+ name TEXT PRIMARY KEY,
191
+ description TEXT NOT NULL,
192
+ created_at TEXT NOT NULL,
193
+ parent TEXT,
194
+ color TEXT,
195
+ is_parent INTEGER DEFAULT 0
196
+ );
197
+
198
+ CREATE INDEX IF NOT EXISTS idx_mem_category ON memories(category);
199
+ CREATE INDEX IF NOT EXISTS idx_mem_project ON memories(project);
200
+ CREATE INDEX IF NOT EXISTS idx_mem_importance ON memories(importance);
201
+ CREATE INDEX IF NOT EXISTS idx_mem_trashed ON memories(trashed_at);
202
+ CREATE INDEX IF NOT EXISTS idx_mem_created ON memories(created_at);
203
+ CREATE INDEX IF NOT EXISTS idx_mem_source ON memories(source);
204
+ CREATE INDEX IF NOT EXISTS idx_sc_session ON session_chunks(session_id);
205
+ CREATE INDEX IF NOT EXISTS idx_sc_project ON session_chunks(project);
206
+ CREATE INDEX IF NOT EXISTS idx_sc_branch ON session_chunks(git_branch);
207
+
208
+ CREATE TABLE IF NOT EXISTS kv_config (
209
+ key TEXT PRIMARY KEY,
210
+ value TEXT NOT NULL
211
+ );
212
+ `;
213
+
214
+ // FTS5 created separately since CREATE VIRTUAL TABLE IF NOT EXISTS
215
+ // can fail silently on some SQLite versions
216
+ const FTS_SQL = `
217
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
218
+ content, category, project, tags,
219
+ content=memories, content_rowid=rowid,
220
+ tokenize='porter unicode61'
221
+ );
222
+ `;
223
+
224
+ // --- Public API: Collection initialization ---
225
+
226
+ export async function ensureCollection(): Promise<void> {
227
+ const d = getDb();
228
+ d.exec(SCHEMA_SQL);
229
+ try {
230
+ d.exec(FTS_SQL);
231
+ } catch {
232
+ // FTS5 may already exist or not be available
233
+ }
234
+ }
235
+
236
+ export async function ensureSessionCollection(): Promise<void> {
237
+ // No-op: session_chunks table is created in ensureCollection()
238
+ }
239
+
240
+ /** Combined init function for cleaner startup */
241
+ export async function ensureDatabase(): Promise<void> {
242
+ await ensureCollection();
243
+ }
244
+
245
+ // --- Public API: Memory operations ---
246
+
247
+ export async function upsertMemory(
248
+ id: string,
249
+ vector: number[],
250
+ payload: MemoryPayload
251
+ ): Promise<void> {
252
+ const d = getDb();
253
+ const stmt = d.prepare(`
254
+ INSERT OR REPLACE INTO memories
255
+ (id, vector, content, category, subcategory, project, tags, importance, source,
256
+ created_at, updated_at, accessed_at, access_count, related_files,
257
+ related_memory_ids, file_checksums, trashed_at, source_session_chunks)
258
+ VALUES
259
+ (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
260
+ `);
261
+ stmt.run(
262
+ id,
263
+ encodeVector(vector),
264
+ payload.content,
265
+ payload.category,
266
+ payload.subcategory ?? null,
267
+ payload.project,
268
+ JSON.stringify(payload.tags || []),
269
+ payload.importance ?? 5,
270
+ payload.source || 'self-discovered',
271
+ payload.created_at,
272
+ payload.updated_at,
273
+ payload.accessed_at,
274
+ payload.access_count ?? 0,
275
+ payload.related_files ? JSON.stringify(payload.related_files) : null,
276
+ payload.related_memory_ids ? JSON.stringify(payload.related_memory_ids) : null,
277
+ payload.file_checksums ? JSON.stringify(payload.file_checksums) : null,
278
+ payload.trashed_at ?? null,
279
+ payload.source_session_chunks ? JSON.stringify(payload.source_session_chunks) : null,
280
+ );
281
+
282
+ // Update FTS index
283
+ try {
284
+ d.prepare(`INSERT OR REPLACE INTO memories_fts(rowid, content, category, project, tags)
285
+ SELECT rowid, content, category, project, tags FROM memories WHERE id = ?`).run(id);
286
+ } catch {
287
+ // FTS update failure is non-fatal
288
+ }
289
+ }
290
+
291
+ export async function searchMemories(
292
+ vector: number[],
293
+ limit: number,
294
+ filter?: Record<string, unknown>,
295
+ scoreThreshold?: number
296
+ ) {
297
+ const d = getDb();
298
+ const threshold = scoreThreshold ?? 0.3;
299
+
300
+ // Build WHERE clause from filter
301
+ const { where, params } = translateFilter(filter);
302
+
303
+ // Fetch all candidate rows (non-trashed) with their vectors
304
+ const rows = d.prepare(`
305
+ SELECT id, vector, content, category, subcategory, project, tags, importance, source,
306
+ created_at, updated_at, accessed_at, access_count, related_files,
307
+ related_memory_ids, file_checksums, trashed_at, source_session_chunks
308
+ FROM memories
309
+ WHERE trashed_at IS NULL${where}
310
+ `).all(...params) as Array<Record<string, unknown>>;
311
+
312
+ // Compute cosine similarity and rank
313
+ const scored = rows.map((row) => {
314
+ const rowVector = decodeVector(row.vector as Uint8Array);
315
+ const score = cosineSimilarity(vector, rowVector);
316
+ return { row, score };
317
+ }).filter((r) => r.score >= threshold);
318
+
319
+ scored.sort((a, b) => b.score - a.score);
320
+
321
+ return scored.slice(0, limit).map((r) => ({
322
+ id: r.row.id as string,
323
+ score: r.score,
324
+ payload: rowToPayload(r.row),
325
+ }));
326
+ }
327
+
328
+ export async function getMemory(id: string) {
329
+ const d = getDb();
330
+ const row = d.prepare(`
331
+ SELECT id, content, category, subcategory, project, tags, importance, source,
332
+ created_at, updated_at, accessed_at, access_count, related_files,
333
+ related_memory_ids, file_checksums, trashed_at, source_session_chunks
334
+ FROM memories WHERE id = ?
335
+ `).get(id) as Record<string, unknown> | undefined;
336
+
337
+ if (!row) return null;
338
+ return { id: row.id as string, payload: rowToPayload(row) };
339
+ }
340
+
341
+ export async function updatePayload(
342
+ id: string,
343
+ payload: Partial<MemoryPayload>
344
+ ): Promise<void> {
345
+ const d = getDb();
346
+ const sets: string[] = [];
347
+ const params: SQLValue[] = [];
348
+
349
+ for (const [key, value] of Object.entries(payload)) {
350
+ if (key === 'id' || key === 'vector') continue;
351
+ const col = sanitizeColumn(key);
352
+ if (Array.isArray(value) || (typeof value === 'object' && value !== null)) {
353
+ sets.push(`${col} = ?`);
354
+ params.push(JSON.stringify(value));
355
+ } else {
356
+ sets.push(`${col} = ?`);
357
+ params.push((value ?? null) as SQLValue);
358
+ }
359
+ }
360
+
361
+ if (sets.length === 0) return;
362
+ params.push(id);
363
+ d.prepare(`UPDATE memories SET ${sets.join(', ')} WHERE id = ?`).run(...params);
364
+
365
+ // Update FTS if content changed
366
+ if (payload.content) {
367
+ try {
368
+ d.prepare(`INSERT OR REPLACE INTO memories_fts(rowid, content, category, project, tags)
369
+ SELECT rowid, content, category, project, tags FROM memories WHERE id = ?`).run(id);
370
+ } catch { /* non-fatal */ }
371
+ }
372
+ }
373
+
374
+ export async function updateVector(
375
+ id: string,
376
+ vector: number[],
377
+ payload: MemoryPayload
378
+ ): Promise<void> {
379
+ // Full upsert — replaces both vector and payload
380
+ await upsertMemory(id, vector, payload);
381
+ }
382
+
383
+ export async function updatePayloadByFilter(
384
+ filter: Record<string, unknown>,
385
+ payload: Partial<MemoryPayload>
386
+ ): Promise<void> {
387
+ const d = getDb();
388
+ const { where, params: filterParams } = translateFilter(filter);
389
+
390
+ const sets: string[] = [];
391
+ const setParams: SQLValue[] = [];
392
+
393
+ for (const [key, value] of Object.entries(payload)) {
394
+ if (key === 'id' || key === 'vector') continue;
395
+ const col = sanitizeColumn(key);
396
+ if (Array.isArray(value) || (typeof value === 'object' && value !== null)) {
397
+ sets.push(`${col} = ?`);
398
+ setParams.push(JSON.stringify(value));
399
+ } else {
400
+ sets.push(`${col} = ?`);
401
+ setParams.push((value ?? null) as SQLValue);
402
+ }
403
+ }
404
+
405
+ if (sets.length === 0) return;
406
+ d.prepare(`UPDATE memories SET ${sets.join(', ')} WHERE trashed_at IS NULL${where}`)
407
+ .run(...setParams, ...filterParams);
408
+ }
409
+
410
+ export async function deleteMemory(id: string): Promise<void> {
411
+ const d = getDb();
412
+ d.prepare('DELETE FROM memories WHERE id = ?').run(id);
413
+ try {
414
+ d.prepare('DELETE FROM memories_fts WHERE rowid = (SELECT rowid FROM memories WHERE id = ?)').run(id);
415
+ } catch { /* non-fatal */ }
416
+ }
417
+
418
+ export async function softDeleteMemory(id: string): Promise<void> {
419
+ await updatePayload(id, { trashed_at: new Date().toISOString() } as Partial<MemoryPayload>);
420
+ }
421
+
422
+ export async function restoreMemory(id: string): Promise<void> {
423
+ const d = getDb();
424
+ d.prepare('UPDATE memories SET trashed_at = NULL WHERE id = ?').run(id);
425
+ }
426
+
427
+ export async function scrollMemories(
428
+ filter?: Record<string, unknown>,
429
+ limit: number = 20,
430
+ offset?: string
431
+ ) {
432
+ const d = getDb();
433
+ const { where, params } = translateFilter(filter);
434
+ const numericOffset = offset ? parseInt(offset, 10) : 0;
435
+
436
+ const rows = d.prepare(`
437
+ SELECT id, content, category, subcategory, project, tags, importance, source,
438
+ created_at, updated_at, accessed_at, access_count, related_files,
439
+ related_memory_ids, file_checksums, trashed_at, source_session_chunks
440
+ FROM memories
441
+ WHERE trashed_at IS NULL${where}
442
+ ORDER BY created_at DESC
443
+ LIMIT ? OFFSET ?
444
+ `).all(...params, limit, numericOffset) as Array<Record<string, unknown>>;
445
+
446
+ const points = rows.map((row) => ({
447
+ id: row.id as string,
448
+ payload: rowToPayload(row),
449
+ }));
450
+
451
+ // Determine next offset
452
+ const nextOffset = rows.length === limit ? String(numericOffset + limit) : null;
453
+
454
+ return { points, next_page_offset: nextOffset };
455
+ }
456
+
457
+ export async function countMemories(filter?: Record<string, unknown>): Promise<number> {
458
+ const d = getDb();
459
+ const { where, params } = translateFilter(filter);
460
+ const result = d.prepare(`
461
+ SELECT COUNT(*) as cnt FROM memories WHERE trashed_at IS NULL${where}
462
+ `).get(...params) as { cnt: number };
463
+ return result.cnt;
464
+ }
465
+
466
+ export async function getMemoryStats(): Promise<MemoryStats> {
467
+ const d = getDb();
468
+
469
+ const totalRow = d.prepare('SELECT COUNT(*) as cnt FROM memories WHERE trashed_at IS NULL').get() as { cnt: number };
470
+ const total = totalRow.cnt;
471
+
472
+ // Per-category counts using SQL aggregate
473
+ const catRows = d.prepare(`
474
+ SELECT category, COUNT(*) as cnt FROM memories WHERE trashed_at IS NULL GROUP BY category
475
+ `).all() as Array<{ category: string; cnt: number }>;
476
+ const by_category: Record<string, number> = {};
477
+ // Initialize with all known categories at 0
478
+ for (const cat of getAllCategories()) {
479
+ by_category[cat] = 0;
480
+ }
481
+ for (const row of catRows) {
482
+ by_category[row.category] = row.cnt;
483
+ }
484
+
485
+ // Per-project counts
486
+ const projRows = d.prepare(`
487
+ SELECT project, COUNT(*) as cnt FROM memories WHERE trashed_at IS NULL GROUP BY project
488
+ `).all() as Array<{ project: string; cnt: number }>;
489
+ const by_project: Record<string, number> = {};
490
+ for (const row of projRows) {
491
+ by_project[row.project || 'global'] = row.cnt;
492
+ }
493
+
494
+ // Oldest/newest
495
+ const dateRow = d.prepare(`
496
+ SELECT MIN(created_at) as oldest, MAX(created_at) as newest FROM memories WHERE trashed_at IS NULL
497
+ `).get() as { oldest: string | null; newest: string | null };
498
+
499
+ return {
500
+ total,
501
+ by_category,
502
+ by_project,
503
+ oldest: dateRow.oldest ?? undefined,
504
+ newest: dateRow.newest ?? undefined,
505
+ };
506
+ }
507
+
508
+ // --- FTS5 full-text search ---
509
+
510
+ /**
511
+ * Search memories using FTS5 full-text search (keyword fallback).
512
+ * Returns results scored by BM25 relevance. Used when vector similarity
513
+ * scores are low (exact identifiers, error codes, proper nouns).
514
+ */
515
+ export async function searchMemoriesFTS(
516
+ query: string,
517
+ limit: number,
518
+ filter?: Record<string, unknown>,
519
+ excludeIds?: Set<string>
520
+ ): Promise<Array<{ id: string; score: number; payload: MemoryPayload }>> {
521
+ const d = getDb();
522
+
523
+ // Sanitize query for FTS5: escape double quotes, wrap terms
524
+ const ftsQuery = query
525
+ .replace(/"/g, '""')
526
+ .split(/\s+/)
527
+ .filter(t => t.length > 1)
528
+ .map(t => `"${t}"`)
529
+ .join(' OR ');
530
+
531
+ if (!ftsQuery) return [];
532
+
533
+ const { where, params } = translateFilter(filter);
534
+
535
+ try {
536
+ const rows = d.prepare(`
537
+ SELECT m.id, m.content, m.category, m.subcategory, m.project, m.tags,
538
+ m.importance, m.source, m.created_at, m.updated_at, m.accessed_at,
539
+ m.access_count, m.related_files, m.related_memory_ids,
540
+ m.file_checksums, m.trashed_at, m.source_session_chunks,
541
+ rank
542
+ FROM memories_fts fts
543
+ JOIN memories m ON m.rowid = fts.rowid
544
+ WHERE memories_fts MATCH ? AND m.trashed_at IS NULL${where}
545
+ ORDER BY rank
546
+ LIMIT ?
547
+ `).all(ftsQuery, ...params, limit) as Array<Record<string, unknown>>;
548
+
549
+ return rows
550
+ .filter(row => !excludeIds || !excludeIds.has(row.id as string))
551
+ .map(row => {
552
+ // FTS5 rank is negative (lower = better match). Normalize to 0-1 range.
553
+ const rawRank = Math.abs(row.rank as number);
554
+ const normalizedScore = Math.min(1, rawRank / 10);
555
+ return {
556
+ id: row.id as string,
557
+ score: normalizedScore,
558
+ payload: rowToPayload(row),
559
+ };
560
+ });
561
+ } catch {
562
+ // FTS5 not available or query error — return empty
563
+ return [];
564
+ }
565
+ }
566
+
567
+ // --- Session Chunks ---
568
+
569
+ export async function searchSessionChunks(
570
+ vector: number[],
571
+ limit: number,
572
+ filter?: Record<string, unknown>,
573
+ scoreThreshold = 0.3
574
+ ) {
575
+ const d = getDb();
576
+ const { where, params } = translateFilter(filter);
577
+
578
+ const rows = d.prepare(`
579
+ SELECT id, vector, content, summary, session_id, project, git_branch, cwd,
580
+ chunk_index, start_timestamp, end_timestamp, tools_used, files_modified,
581
+ files_read, user_messages, turn_count, related_memory_ids, dedup_memory_id, indexed_at
582
+ FROM session_chunks
583
+ WHERE 1=1${where}
584
+ `).all(...params) as Array<Record<string, unknown>>;
585
+
586
+ const scored = rows.map((row) => {
587
+ const rowVector = decodeVector(row.vector as Uint8Array);
588
+ const score = cosineSimilarity(vector, rowVector);
589
+ return { row, score };
590
+ }).filter((r) => r.score >= scoreThreshold);
591
+
592
+ scored.sort((a, b) => b.score - a.score);
593
+
594
+ return scored.slice(0, limit).map((r) => ({
595
+ id: r.row.id as string,
596
+ score: r.score,
597
+ payload: rowToSessionChunkPayload(r.row),
598
+ }));
599
+ }
600
+
601
+ export async function upsertSessionChunks(
602
+ points: Array<{ id: string; vector: number[]; payload: SessionChunkPayload }>
603
+ ): Promise<void> {
604
+ const d = getDb();
605
+ const stmt = d.prepare(`
606
+ INSERT OR REPLACE INTO session_chunks
607
+ (id, vector, content, summary, session_id, project, git_branch, cwd,
608
+ chunk_index, start_timestamp, end_timestamp, tools_used, files_modified,
609
+ files_read, user_messages, turn_count, related_memory_ids, dedup_memory_id, indexed_at)
610
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
611
+ `);
612
+
613
+ // Manual transaction since node:sqlite doesn't have db.transaction()
614
+ d.exec('BEGIN');
615
+ try {
616
+ for (const p of points) {
617
+ stmt.run(
618
+ p.id,
619
+ encodeVector(p.vector),
620
+ p.payload.content,
621
+ p.payload.summary || null,
622
+ p.payload.session_id || null,
623
+ p.payload.project || null,
624
+ p.payload.git_branch ?? null,
625
+ p.payload.cwd ?? null,
626
+ p.payload.chunk_index ?? 0,
627
+ p.payload.start_timestamp || null,
628
+ p.payload.end_timestamp || null,
629
+ JSON.stringify(p.payload.tools_used || []),
630
+ JSON.stringify(p.payload.files_modified || []),
631
+ JSON.stringify(p.payload.files_read || []),
632
+ JSON.stringify(p.payload.user_messages || []),
633
+ p.payload.turn_count ?? 0,
634
+ JSON.stringify(p.payload.related_memory_ids || []),
635
+ p.payload.dedup_memory_id ?? null,
636
+ p.payload.indexed_at || new Date().toISOString(),
637
+ );
638
+ }
639
+ d.exec('COMMIT');
640
+ } catch (err) {
641
+ d.exec('ROLLBACK');
642
+ throw err;
643
+ }
644
+ }
645
+
646
+ export async function scrollSessionChunks(
647
+ filter?: Record<string, unknown>,
648
+ limit: number = 20,
649
+ offset?: string
650
+ ) {
651
+ const d = getDb();
652
+ const { where, params } = translateFilter(filter);
653
+ const numericOffset = offset ? parseInt(offset, 10) : 0;
654
+
655
+ const rows = d.prepare(`
656
+ SELECT id, content, summary, session_id, project, git_branch, cwd,
657
+ chunk_index, start_timestamp, end_timestamp, tools_used, files_modified,
658
+ files_read, user_messages, turn_count, related_memory_ids, dedup_memory_id, indexed_at
659
+ FROM session_chunks
660
+ WHERE 1=1${where}
661
+ ORDER BY indexed_at DESC
662
+ LIMIT ? OFFSET ?
663
+ `).all(...params, limit, numericOffset) as Array<Record<string, unknown>>;
664
+
665
+ const points = rows.map((row) => ({
666
+ id: row.id as string,
667
+ payload: rowToSessionChunkPayload(row),
668
+ }));
669
+
670
+ const nextOffset = rows.length === limit ? String(numericOffset + limit) : null;
671
+ return { points, next_page_offset: nextOffset };
672
+ }
673
+
674
+ export async function countSessionChunks(filter?: Record<string, unknown>): Promise<number> {
675
+ const d = getDb();
676
+ const { where, params } = translateFilter(filter);
677
+ const result = d.prepare(`SELECT COUNT(*) as cnt FROM session_chunks WHERE 1=1${where}`)
678
+ .get(...params) as { cnt: number };
679
+ return result.cnt;
680
+ }
681
+
682
+ // --- Categories stored in SQLite ---
683
+
684
+ interface StoredCategory {
685
+ name: string;
686
+ description: string;
687
+ created_at: string;
688
+ parent?: string;
689
+ color?: string;
690
+ is_parent?: boolean;
691
+ }
692
+
693
+ export async function getCategories(): Promise<StoredCategory[] | null> {
694
+ try {
695
+ const d = getDb();
696
+ const rows = d.prepare('SELECT name, description, created_at, parent, color, is_parent FROM categories ORDER BY name')
697
+ .all() as Array<Record<string, unknown>>;
698
+ if (rows.length === 0) return null;
699
+ return rows.map((row) => ({
700
+ name: row.name as string,
701
+ description: row.description as string,
702
+ created_at: row.created_at as string,
703
+ parent: row.parent as string | undefined,
704
+ color: row.color as string | undefined,
705
+ is_parent: row.is_parent === 1 ? true : undefined,
706
+ }));
707
+ } catch {
708
+ return null;
709
+ }
710
+ }
711
+
712
+ export async function saveCategories(categories: StoredCategory[]): Promise<void> {
713
+ const d = getDb();
714
+ const insertStmt = d.prepare(`
715
+ INSERT OR REPLACE INTO categories (name, description, created_at, parent, color, is_parent)
716
+ VALUES (?, ?, ?, ?, ?, ?)
717
+ `);
718
+
719
+ // Manual transaction
720
+ d.exec('BEGIN');
721
+ try {
722
+ // Get existing names to detect deletions
723
+ const existing = new Set(
724
+ (d.prepare('SELECT name FROM categories').all() as Array<{ name: string }>).map((r) => r.name)
725
+ );
726
+ const incoming = new Set(categories.map((c) => c.name));
727
+
728
+ // Delete removed categories
729
+ for (const name of existing) {
730
+ if (!incoming.has(name)) {
731
+ d.prepare('DELETE FROM categories WHERE name = ?').run(name);
732
+ }
733
+ }
734
+
735
+ // Upsert all incoming
736
+ for (const cat of categories) {
737
+ insertStmt.run(
738
+ cat.name,
739
+ cat.description,
740
+ cat.created_at,
741
+ cat.parent ?? null,
742
+ cat.color ?? null,
743
+ cat.is_parent ? 1 : 0,
744
+ );
745
+ }
746
+ d.exec('COMMIT');
747
+ } catch (err) {
748
+ d.exec('ROLLBACK');
749
+ throw err;
750
+ }
751
+ }
752
+
753
+ // --- Helpers ---
754
+
755
+ function parseJsonOrDefault<T>(value: unknown, defaultValue: T): T {
756
+ if (value == null || value === '') return defaultValue;
757
+ if (typeof value === 'string') {
758
+ try { return JSON.parse(value) as T; } catch { return defaultValue; }
759
+ }
760
+ return value as T;
761
+ }
762
+
763
+ function rowToPayload(row: Record<string, unknown>): MemoryPayload {
764
+ return {
765
+ content: row.content as string,
766
+ category: row.category as string,
767
+ subcategory: row.subcategory as string | undefined,
768
+ project: row.project as string,
769
+ tags: parseJsonOrDefault<string[]>(row.tags, []),
770
+ importance: (row.importance as number) ?? 5,
771
+ source: (row.source as MemoryPayload['source']) || 'self-discovered',
772
+ created_at: row.created_at as string,
773
+ updated_at: row.updated_at as string,
774
+ accessed_at: row.accessed_at as string,
775
+ access_count: (row.access_count as number) ?? 0,
776
+ related_files: parseJsonOrDefault<string[] | undefined>(row.related_files, undefined),
777
+ related_memory_ids: parseJsonOrDefault<string[] | undefined>(row.related_memory_ids, undefined),
778
+ file_checksums: parseJsonOrDefault<Record<string, string> | undefined>(row.file_checksums, undefined),
779
+ trashed_at: row.trashed_at as string | null | undefined,
780
+ source_session_chunks: parseJsonOrDefault(row.source_session_chunks, undefined),
781
+ };
782
+ }
783
+
784
+ function rowToSessionChunkPayload(row: Record<string, unknown>): SessionChunkPayload {
785
+ return {
786
+ content: row.content as string,
787
+ summary: (row.summary as string) || '',
788
+ session_id: (row.session_id as string) || '',
789
+ project: (row.project as string) || '',
790
+ git_branch: row.git_branch as string | null,
791
+ cwd: row.cwd as string | null,
792
+ chunk_index: (row.chunk_index as number) ?? 0,
793
+ start_timestamp: (row.start_timestamp as string) || '',
794
+ end_timestamp: (row.end_timestamp as string) || '',
795
+ tools_used: parseJsonOrDefault<string[]>(row.tools_used, []),
796
+ files_modified: parseJsonOrDefault<string[]>(row.files_modified, []),
797
+ files_read: parseJsonOrDefault<string[]>(row.files_read, []),
798
+ user_messages: parseJsonOrDefault<string[]>(row.user_messages, []),
799
+ turn_count: (row.turn_count as number) ?? 0,
800
+ related_memory_ids: parseJsonOrDefault<string[]>(row.related_memory_ids, []),
801
+ dedup_memory_id: row.dedup_memory_id as string | null,
802
+ indexed_at: (row.indexed_at as string) || '',
803
+ };
804
+ }
805
+
806
+ /**
807
+ * Close the database connection. Call on process exit.
808
+ */
809
+ export function closeDatabase(): void {
810
+ if (db) {
811
+ db.close();
812
+ db = null;
813
+ }
814
+ }
815
+
816
+ /**
817
+ * Reopen the database at the current SQLITE_DB_PATH.
818
+ * Call after updating process.env.SQLITE_DB_PATH to switch databases.
819
+ */
820
+ export async function reopenDatabase(): Promise<void> {
821
+ closeDatabase();
822
+ await ensureDatabase();
823
+ }