@swarmclawai/swarmclaw 0.2.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 (319) hide show
  1. package/README.md +577 -0
  2. package/bin/server-cmd.js +359 -0
  3. package/bin/swarmclaw.js +29 -0
  4. package/bin/swarmclaw.mjs +1504 -0
  5. package/next.config.ts +33 -0
  6. package/package.json +112 -0
  7. package/postcss.config.mjs +7 -0
  8. package/public/branding/swarmclaw-org-avatar.png +0 -0
  9. package/public/branding/swarmclaw-org-avatar.svg +58 -0
  10. package/public/file.svg +1 -0
  11. package/public/globe.svg +1 -0
  12. package/public/next.svg +1 -0
  13. package/public/screenshots/agents.png +0 -0
  14. package/public/screenshots/connectors.png +0 -0
  15. package/public/screenshots/dashboard.png +0 -0
  16. package/public/screenshots/new-session-openclaw.png +0 -0
  17. package/public/screenshots/providers.png +0 -0
  18. package/public/screenshots/schedules.png +0 -0
  19. package/public/screenshots/tasks.png +0 -0
  20. package/public/vercel.svg +1 -0
  21. package/public/window.svg +1 -0
  22. package/src/app/api/agents/[id]/route.ts +30 -0
  23. package/src/app/api/agents/[id]/thread/route.ts +66 -0
  24. package/src/app/api/agents/generate/route.ts +42 -0
  25. package/src/app/api/agents/route.ts +33 -0
  26. package/src/app/api/auth/route.ts +25 -0
  27. package/src/app/api/claude-skills/route.ts +42 -0
  28. package/src/app/api/clawhub/install/route.ts +39 -0
  29. package/src/app/api/clawhub/search/route.ts +11 -0
  30. package/src/app/api/connectors/[id]/route.ts +79 -0
  31. package/src/app/api/connectors/route.ts +60 -0
  32. package/src/app/api/credentials/[id]/route.ts +14 -0
  33. package/src/app/api/credentials/route.ts +31 -0
  34. package/src/app/api/daemon/health-check/route.ts +11 -0
  35. package/src/app/api/daemon/route.ts +22 -0
  36. package/src/app/api/dirs/pick/route.ts +60 -0
  37. package/src/app/api/dirs/route.ts +29 -0
  38. package/src/app/api/documents/[id]/route.ts +47 -0
  39. package/src/app/api/documents/route.ts +93 -0
  40. package/src/app/api/files/serve/route.ts +69 -0
  41. package/src/app/api/generate/info/route.ts +12 -0
  42. package/src/app/api/generate/route.ts +106 -0
  43. package/src/app/api/ip/route.ts +6 -0
  44. package/src/app/api/knowledge/[id]/route.ts +61 -0
  45. package/src/app/api/knowledge/route.ts +48 -0
  46. package/src/app/api/knowledge/upload/route.ts +86 -0
  47. package/src/app/api/logs/route.ts +65 -0
  48. package/src/app/api/mcp-servers/[id]/route.ts +32 -0
  49. package/src/app/api/mcp-servers/[id]/test/route.ts +23 -0
  50. package/src/app/api/mcp-servers/[id]/tools/route.ts +32 -0
  51. package/src/app/api/mcp-servers/route.ts +27 -0
  52. package/src/app/api/memory/[id]/route.ts +126 -0
  53. package/src/app/api/memory/maintenance/route.ts +63 -0
  54. package/src/app/api/memory/route.ts +111 -0
  55. package/src/app/api/memory-images/[filename]/route.ts +36 -0
  56. package/src/app/api/orchestrator/run/route.ts +43 -0
  57. package/src/app/api/plugins/install/route.ts +58 -0
  58. package/src/app/api/plugins/marketplace/route.ts +33 -0
  59. package/src/app/api/plugins/route.ts +21 -0
  60. package/src/app/api/preview-server/route.ts +339 -0
  61. package/src/app/api/providers/[id]/models/route.ts +29 -0
  62. package/src/app/api/providers/[id]/route.ts +34 -0
  63. package/src/app/api/providers/configs/route.ts +7 -0
  64. package/src/app/api/providers/ollama/route.ts +30 -0
  65. package/src/app/api/providers/openclaw/health/route.ts +23 -0
  66. package/src/app/api/providers/route.ts +28 -0
  67. package/src/app/api/runs/[id]/route.ts +9 -0
  68. package/src/app/api/runs/route.ts +13 -0
  69. package/src/app/api/schedules/[id]/route.ts +28 -0
  70. package/src/app/api/schedules/[id]/run/route.ts +104 -0
  71. package/src/app/api/schedules/route.ts +78 -0
  72. package/src/app/api/secrets/[id]/route.ts +29 -0
  73. package/src/app/api/secrets/route.ts +42 -0
  74. package/src/app/api/sessions/[id]/browser/route.ts +13 -0
  75. package/src/app/api/sessions/[id]/chat/route.ts +96 -0
  76. package/src/app/api/sessions/[id]/clear/route.ts +19 -0
  77. package/src/app/api/sessions/[id]/deploy/route.ts +34 -0
  78. package/src/app/api/sessions/[id]/devserver/route.ts +69 -0
  79. package/src/app/api/sessions/[id]/mailbox/route.ts +70 -0
  80. package/src/app/api/sessions/[id]/main-loop/route.ts +94 -0
  81. package/src/app/api/sessions/[id]/messages/route.ts +9 -0
  82. package/src/app/api/sessions/[id]/retry/route.ts +28 -0
  83. package/src/app/api/sessions/[id]/route.ts +103 -0
  84. package/src/app/api/sessions/[id]/stop/route.ts +13 -0
  85. package/src/app/api/sessions/heartbeat/route.ts +26 -0
  86. package/src/app/api/sessions/route.ts +85 -0
  87. package/src/app/api/settings/route.ts +58 -0
  88. package/src/app/api/setup/check-provider/route.ts +326 -0
  89. package/src/app/api/setup/doctor/route.ts +250 -0
  90. package/src/app/api/skills/[id]/route.ts +40 -0
  91. package/src/app/api/skills/import/route.ts +69 -0
  92. package/src/app/api/skills/route.ts +28 -0
  93. package/src/app/api/tasks/[id]/route.ts +102 -0
  94. package/src/app/api/tasks/route.ts +115 -0
  95. package/src/app/api/tts/route.ts +40 -0
  96. package/src/app/api/upload/route.ts +18 -0
  97. package/src/app/api/uploads/[filename]/route.ts +59 -0
  98. package/src/app/api/usage/route.ts +35 -0
  99. package/src/app/api/version/route.ts +81 -0
  100. package/src/app/api/version/update/route.ts +95 -0
  101. package/src/app/api/webhooks/[id]/history/route.ts +13 -0
  102. package/src/app/api/webhooks/[id]/route.ts +204 -0
  103. package/src/app/api/webhooks/route.ts +37 -0
  104. package/src/app/favicon.ico +0 -0
  105. package/src/app/globals.css +370 -0
  106. package/src/app/layout.tsx +52 -0
  107. package/src/app/page.tsx +172 -0
  108. package/src/cli/index.js +1232 -0
  109. package/src/cli/index.test.js +281 -0
  110. package/src/cli/index.ts +1158 -0
  111. package/src/cli/spec.js +284 -0
  112. package/src/components/agents/agent-card.tsx +219 -0
  113. package/src/components/agents/agent-chat-list.tsx +165 -0
  114. package/src/components/agents/agent-list.tsx +110 -0
  115. package/src/components/agents/agent-sheet.tsx +1220 -0
  116. package/src/components/auth/access-key-gate.tsx +248 -0
  117. package/src/components/auth/setup-wizard.tsx +940 -0
  118. package/src/components/auth/user-picker.tsx +88 -0
  119. package/src/components/chat/chat-area.tsx +406 -0
  120. package/src/components/chat/chat-header.tsx +491 -0
  121. package/src/components/chat/chat-tool-toggles.tsx +161 -0
  122. package/src/components/chat/code-block.tsx +146 -0
  123. package/src/components/chat/dev-server-bar.tsx +39 -0
  124. package/src/components/chat/message-bubble.tsx +486 -0
  125. package/src/components/chat/message-list.tsx +299 -0
  126. package/src/components/chat/session-debug-panel.tsx +196 -0
  127. package/src/components/chat/streaming-bubble.tsx +85 -0
  128. package/src/components/chat/thinking-indicator.tsx +26 -0
  129. package/src/components/chat/tool-call-bubble.tsx +438 -0
  130. package/src/components/chat/tool-request-banner.tsx +103 -0
  131. package/src/components/connectors/connector-list.tsx +196 -0
  132. package/src/components/connectors/connector-sheet.tsx +804 -0
  133. package/src/components/input/chat-input.tsx +235 -0
  134. package/src/components/knowledge/knowledge-list.tsx +206 -0
  135. package/src/components/knowledge/knowledge-sheet.tsx +316 -0
  136. package/src/components/layout/app-layout.tsx +1016 -0
  137. package/src/components/layout/daemon-indicator.tsx +56 -0
  138. package/src/components/layout/mobile-header.tsx +31 -0
  139. package/src/components/layout/network-banner.tsx +17 -0
  140. package/src/components/layout/update-banner.tsx +130 -0
  141. package/src/components/logs/log-list.tsx +358 -0
  142. package/src/components/mcp-servers/mcp-server-list.tsx +122 -0
  143. package/src/components/mcp-servers/mcp-server-sheet.tsx +243 -0
  144. package/src/components/memory/memory-card.tsx +63 -0
  145. package/src/components/memory/memory-detail.tsx +339 -0
  146. package/src/components/memory/memory-list.tsx +198 -0
  147. package/src/components/memory/memory-sheet.tsx +70 -0
  148. package/src/components/plugins/plugin-list.tsx +60 -0
  149. package/src/components/plugins/plugin-sheet.tsx +311 -0
  150. package/src/components/providers/provider-list.tsx +96 -0
  151. package/src/components/providers/provider-sheet.tsx +542 -0
  152. package/src/components/runs/run-list.tsx +231 -0
  153. package/src/components/schedules/schedule-card.tsx +63 -0
  154. package/src/components/schedules/schedule-list.tsx +76 -0
  155. package/src/components/schedules/schedule-sheet.tsx +336 -0
  156. package/src/components/secrets/secret-sheet.tsx +180 -0
  157. package/src/components/secrets/secrets-list.tsx +91 -0
  158. package/src/components/sessions/new-session-sheet.tsx +478 -0
  159. package/src/components/sessions/session-card.tsx +144 -0
  160. package/src/components/sessions/session-list.tsx +202 -0
  161. package/src/components/shared/ai-gen-block.tsx +77 -0
  162. package/src/components/shared/avatar.tsx +48 -0
  163. package/src/components/shared/bottom-sheet.tsx +30 -0
  164. package/src/components/shared/confirm-dialog.tsx +47 -0
  165. package/src/components/shared/connector-platform-icon.tsx +113 -0
  166. package/src/components/shared/dir-browser.tsx +285 -0
  167. package/src/components/shared/dropdown.tsx +55 -0
  168. package/src/components/shared/icon-button.tsx +25 -0
  169. package/src/components/shared/settings/plugin-manager.tsx +207 -0
  170. package/src/components/shared/settings/section-capability-policy.tsx +93 -0
  171. package/src/components/shared/settings/section-embedding.tsx +99 -0
  172. package/src/components/shared/settings/section-heartbeat.tsx +168 -0
  173. package/src/components/shared/settings/section-memory.tsx +77 -0
  174. package/src/components/shared/settings/section-orchestrator.tsx +108 -0
  175. package/src/components/shared/settings/section-providers.tsx +181 -0
  176. package/src/components/shared/settings/section-runtime-loop.tsx +183 -0
  177. package/src/components/shared/settings/section-secrets.tsx +132 -0
  178. package/src/components/shared/settings/section-user-preferences.tsx +24 -0
  179. package/src/components/shared/settings/section-voice.tsx +53 -0
  180. package/src/components/shared/settings/settings-sheet.tsx +88 -0
  181. package/src/components/shared/settings/types.ts +7 -0
  182. package/src/components/shared/settings/utils.ts +13 -0
  183. package/src/components/shared/settings-sheet.tsx +1 -0
  184. package/src/components/shared/skeleton.tsx +19 -0
  185. package/src/components/shared/usage-badge.tsx +28 -0
  186. package/src/components/skills/clawhub-browser.tsx +225 -0
  187. package/src/components/skills/skill-list.tsx +70 -0
  188. package/src/components/skills/skill-sheet.tsx +254 -0
  189. package/src/components/tasks/task-board.tsx +96 -0
  190. package/src/components/tasks/task-card.tsx +179 -0
  191. package/src/components/tasks/task-column.tsx +73 -0
  192. package/src/components/tasks/task-list.tsx +118 -0
  193. package/src/components/tasks/task-sheet.tsx +415 -0
  194. package/src/components/ui/avatar.tsx +109 -0
  195. package/src/components/ui/badge.tsx +48 -0
  196. package/src/components/ui/button.tsx +64 -0
  197. package/src/components/ui/card.tsx +92 -0
  198. package/src/components/ui/dialog.tsx +158 -0
  199. package/src/components/ui/dropdown-menu.tsx +257 -0
  200. package/src/components/ui/input.tsx +21 -0
  201. package/src/components/ui/scroll-area.tsx +58 -0
  202. package/src/components/ui/select.tsx +190 -0
  203. package/src/components/ui/separator.tsx +28 -0
  204. package/src/components/ui/sheet.tsx +143 -0
  205. package/src/components/ui/sonner.tsx +22 -0
  206. package/src/components/ui/textarea.tsx +18 -0
  207. package/src/components/ui/tooltip.tsx +56 -0
  208. package/src/components/usage/usage-list.tsx +105 -0
  209. package/src/components/webhooks/webhook-list.tsx +166 -0
  210. package/src/components/webhooks/webhook-sheet.tsx +402 -0
  211. package/src/hooks/use-auto-resize.ts +20 -0
  212. package/src/hooks/use-media-query.ts +21 -0
  213. package/src/hooks/use-speech-recognition.ts +83 -0
  214. package/src/instrumentation.ts +8 -0
  215. package/src/lib/agents.ts +13 -0
  216. package/src/lib/api-client.ts +100 -0
  217. package/src/lib/chat.ts +60 -0
  218. package/src/lib/memory.ts +42 -0
  219. package/src/lib/openclaw-endpoint.test.ts +48 -0
  220. package/src/lib/openclaw-endpoint.ts +67 -0
  221. package/src/lib/provider-config.ts +13 -0
  222. package/src/lib/providers/anthropic.ts +135 -0
  223. package/src/lib/providers/claude-cli.ts +202 -0
  224. package/src/lib/providers/codex-cli.ts +260 -0
  225. package/src/lib/providers/index.ts +351 -0
  226. package/src/lib/providers/ollama.ts +131 -0
  227. package/src/lib/providers/openai.ts +164 -0
  228. package/src/lib/providers/openclaw.ts +330 -0
  229. package/src/lib/providers/opencode-cli.ts +164 -0
  230. package/src/lib/runtime-loop.ts +15 -0
  231. package/src/lib/schedule-dedupe.test.ts +84 -0
  232. package/src/lib/schedule-dedupe.ts +174 -0
  233. package/src/lib/schedule-name.ts +62 -0
  234. package/src/lib/schedules.ts +16 -0
  235. package/src/lib/server/agent-registry.ts +70 -0
  236. package/src/lib/server/api-routes.test.ts +362 -0
  237. package/src/lib/server/autonomy-contract.ts +200 -0
  238. package/src/lib/server/build-llm.ts +155 -0
  239. package/src/lib/server/capability-router.test.ts +21 -0
  240. package/src/lib/server/capability-router.ts +172 -0
  241. package/src/lib/server/chat-execution.ts +894 -0
  242. package/src/lib/server/clawhub-client.test.ts +161 -0
  243. package/src/lib/server/clawhub-client.ts +26 -0
  244. package/src/lib/server/connectors/connector-routing.test.ts +243 -0
  245. package/src/lib/server/connectors/discord.ts +116 -0
  246. package/src/lib/server/connectors/googlechat.ts +66 -0
  247. package/src/lib/server/connectors/manager.ts +559 -0
  248. package/src/lib/server/connectors/matrix.ts +78 -0
  249. package/src/lib/server/connectors/media.ts +149 -0
  250. package/src/lib/server/connectors/openclaw.test.ts +375 -0
  251. package/src/lib/server/connectors/openclaw.ts +1132 -0
  252. package/src/lib/server/connectors/signal.ts +183 -0
  253. package/src/lib/server/connectors/slack.ts +258 -0
  254. package/src/lib/server/connectors/teams.ts +94 -0
  255. package/src/lib/server/connectors/telegram.ts +221 -0
  256. package/src/lib/server/connectors/types.ts +62 -0
  257. package/src/lib/server/connectors/whatsapp.ts +349 -0
  258. package/src/lib/server/context-manager.ts +232 -0
  259. package/src/lib/server/cost.ts +31 -0
  260. package/src/lib/server/daemon-state.ts +354 -0
  261. package/src/lib/server/data-dir.ts +3 -0
  262. package/src/lib/server/embeddings.ts +111 -0
  263. package/src/lib/server/execution-log.ts +257 -0
  264. package/src/lib/server/gateway/protocol.test.ts +54 -0
  265. package/src/lib/server/gateway/protocol.ts +114 -0
  266. package/src/lib/server/heartbeat-service.ts +366 -0
  267. package/src/lib/server/knowledge-db.test.ts +441 -0
  268. package/src/lib/server/logger.ts +47 -0
  269. package/src/lib/server/main-agent-loop.ts +1017 -0
  270. package/src/lib/server/mcp-client.test.ts +342 -0
  271. package/src/lib/server/mcp-client.ts +130 -0
  272. package/src/lib/server/memory-db.ts +1078 -0
  273. package/src/lib/server/memory-graph.test.ts +153 -0
  274. package/src/lib/server/memory-graph.ts +138 -0
  275. package/src/lib/server/openclaw-health.ts +245 -0
  276. package/src/lib/server/orchestrator-lg.ts +431 -0
  277. package/src/lib/server/orchestrator.ts +364 -0
  278. package/src/lib/server/playwright-proxy.mjs +70 -0
  279. package/src/lib/server/plugins.ts +229 -0
  280. package/src/lib/server/process-manager.ts +327 -0
  281. package/src/lib/server/provider-health.ts +113 -0
  282. package/src/lib/server/queue.ts +859 -0
  283. package/src/lib/server/runtime-settings.ts +119 -0
  284. package/src/lib/server/scheduler.ts +196 -0
  285. package/src/lib/server/session-mailbox.ts +129 -0
  286. package/src/lib/server/session-run-manager.ts +512 -0
  287. package/src/lib/server/session-tools/connector.ts +124 -0
  288. package/src/lib/server/session-tools/context-mgmt.ts +103 -0
  289. package/src/lib/server/session-tools/context.ts +114 -0
  290. package/src/lib/server/session-tools/crud.ts +673 -0
  291. package/src/lib/server/session-tools/delegate.ts +708 -0
  292. package/src/lib/server/session-tools/file.ts +264 -0
  293. package/src/lib/server/session-tools/index.ts +164 -0
  294. package/src/lib/server/session-tools/memory.ts +230 -0
  295. package/src/lib/server/session-tools/session-info.ts +422 -0
  296. package/src/lib/server/session-tools/session-tools-wiring.test.ts +166 -0
  297. package/src/lib/server/session-tools/shell.ts +171 -0
  298. package/src/lib/server/session-tools/web.ts +408 -0
  299. package/src/lib/server/session-tools.ts +9 -0
  300. package/src/lib/server/skills-normalize.ts +130 -0
  301. package/src/lib/server/storage-mcp.test.ts +161 -0
  302. package/src/lib/server/storage.ts +670 -0
  303. package/src/lib/server/stream-agent-chat.ts +571 -0
  304. package/src/lib/server/task-reports.ts +122 -0
  305. package/src/lib/server/task-result.ts +161 -0
  306. package/src/lib/server/task-validation.test.ts +27 -0
  307. package/src/lib/server/task-validation.ts +90 -0
  308. package/src/lib/server/tool-capability-policy.test.ts +58 -0
  309. package/src/lib/server/tool-capability-policy.ts +262 -0
  310. package/src/lib/sessions.ts +68 -0
  311. package/src/lib/tasks.ts +20 -0
  312. package/src/lib/tts.ts +42 -0
  313. package/src/lib/upload.ts +10 -0
  314. package/src/lib/utils.ts +6 -0
  315. package/src/proxy.ts +43 -0
  316. package/src/stores/use-app-store.ts +468 -0
  317. package/src/stores/use-chat-store.ts +323 -0
  318. package/src/types/index.ts +621 -0
  319. package/tsconfig.json +34 -0
@@ -0,0 +1,1078 @@
1
+ import Database from 'better-sqlite3'
2
+ import path from 'path'
3
+ import crypto from 'crypto'
4
+ import fs from 'fs'
5
+ import type { MemoryEntry, FileReference, MemoryImage, MemoryReference } from '@/types'
6
+ import { getEmbedding, cosineSimilarity, serializeEmbedding, deserializeEmbedding } from './embeddings'
7
+ import { loadSettings } from './storage'
8
+ import {
9
+ normalizeLinkedMemoryIds,
10
+ normalizeMemoryLookupLimits,
11
+ resolveLookupRequest,
12
+ traverseLinkedMemoryGraph,
13
+ type MemoryLookupLimits,
14
+ } from './memory-graph'
15
+
16
+ import { DATA_DIR } from './data-dir'
17
+
18
+ const DB_PATH = path.join(DATA_DIR, 'memory.db')
19
+ const IMAGES_DIR = path.join(DATA_DIR, 'memory-images')
20
+
21
+ const MAX_IMAGE_INPUT_BYTES = 10 * 1024 * 1024 // 10MB
22
+ const IMAGE_EXT_WHITELIST = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff'])
23
+ const MAX_FTS_QUERY_TERMS = 6
24
+ const MAX_FTS_TERM_LENGTH = 48
25
+ const MAX_FTS_RESULT_ROWS = 30
26
+ const MAX_MERGED_RESULTS = 50
27
+
28
+ const MEMORY_FTS_STOP_WORDS = new Set([
29
+ 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'how',
30
+ 'i', 'if', 'in', 'is', 'it', 'of', 'on', 'or', 'that', 'the', 'this',
31
+ 'to', 'was', 'we', 'were', 'what', 'when', 'where', 'which', 'who', 'with',
32
+ 'you', 'your',
33
+ ])
34
+
35
+ function shouldSkipSearchQuery(input: string): boolean {
36
+ const text = String(input || '').toLowerCase().trim()
37
+ if (!text) return true
38
+ if (text.length > 1200) return true
39
+ if (text.includes('swarm_heartbeat_check')) return true
40
+ if (text.includes('opencode_test_ok')) return true
41
+ if (text.includes('reply exactly') && text.includes('heartbeat')) return true
42
+ return false
43
+ }
44
+
45
+ // Simple cache for query embeddings to avoid blocking
46
+ const embeddingCache = new Map<string, number[]>()
47
+
48
+ function getEmbeddingSync(query: string): number[] | null {
49
+ const cached = embeddingCache.get(query)
50
+ if (cached) return cached
51
+ // Kick off async computation for next time
52
+ getEmbedding(query).then((emb) => {
53
+ if (emb) embeddingCache.set(query, emb)
54
+ // Evict old entries
55
+ if (embeddingCache.size > 100) {
56
+ const firstKey = embeddingCache.keys().next().value
57
+ if (firstKey) embeddingCache.delete(firstKey)
58
+ }
59
+ }).catch(() => { /* ok */ })
60
+ return null
61
+ }
62
+
63
+ function parseImageDimensionsFromSharp(metadata: { width?: number; height?: number }): { width?: number; height?: number } {
64
+ const width = typeof metadata.width === 'number' ? metadata.width : undefined
65
+ const height = typeof metadata.height === 'number' ? metadata.height : undefined
66
+ return { width, height }
67
+ }
68
+
69
+ function normalizeImageExt(sourcePath: string): string {
70
+ const ext = path.extname(sourcePath).toLowerCase()
71
+ return IMAGE_EXT_WHITELIST.has(ext) ? ext : '.jpg'
72
+ }
73
+
74
+ /** Compress an image file and store it in the memory-images directory. Returns structured image metadata. */
75
+ export async function storeMemoryImageAsset(sourcePath: string, memoryId: string): Promise<MemoryImage> {
76
+ if (!fs.existsSync(sourcePath)) {
77
+ throw new Error(`Image file not found: ${sourcePath}`)
78
+ }
79
+ const sourceStat = fs.statSync(sourcePath)
80
+ if (sourceStat.size > MAX_IMAGE_INPUT_BYTES) {
81
+ throw new Error(`Image exceeds max size (${MAX_IMAGE_INPUT_BYTES} bytes): ${sourcePath}`)
82
+ }
83
+
84
+ // Ensure images directory exists
85
+ fs.mkdirSync(IMAGES_DIR, { recursive: true })
86
+
87
+ const ext = normalizeImageExt(sourcePath)
88
+ const destFilename = `${memoryId}${ext}`
89
+ const destPath = path.join(IMAGES_DIR, destFilename)
90
+ const jpgPath = destPath.replace(/\.[^.]+$/, '.jpg')
91
+
92
+ try {
93
+ // Try to use sharp for compression
94
+ const sharp = (await import('sharp')).default
95
+ const transformed = sharp(sourcePath)
96
+ .resize(1024, 1024, { fit: 'inside', withoutEnlargement: true })
97
+ .jpeg({ quality: 75 })
98
+ const info = await transformed.toFile(jpgPath)
99
+ const relPath = `data/memory-images/${path.basename(jpgPath)}`
100
+ return {
101
+ path: relPath,
102
+ mimeType: 'image/jpeg',
103
+ ...parseImageDimensionsFromSharp(info),
104
+ sizeBytes: info.size,
105
+ }
106
+ } catch {
107
+ // Fallback: copy file as-is if sharp is not available
108
+ fs.copyFileSync(sourcePath, destPath)
109
+ const stat = fs.statSync(destPath)
110
+ const mimeType = ext === '.png'
111
+ ? 'image/png'
112
+ : ext === '.gif'
113
+ ? 'image/gif'
114
+ : ext === '.webp'
115
+ ? 'image/webp'
116
+ : 'image/jpeg'
117
+ return {
118
+ path: `data/memory-images/${destFilename}`,
119
+ mimeType,
120
+ sizeBytes: stat.size,
121
+ }
122
+ }
123
+ }
124
+
125
+ export async function storeMemoryImageFromDataUrl(dataUrl: string, memoryId: string): Promise<MemoryImage> {
126
+ const match = dataUrl.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/)
127
+ if (!match) throw new Error('Invalid image data URL format')
128
+ const [, mimeType, base64] = match
129
+ const buf = Buffer.from(base64, 'base64')
130
+ if (buf.length > MAX_IMAGE_INPUT_BYTES) {
131
+ throw new Error(`Image exceeds max size (${MAX_IMAGE_INPUT_BYTES} bytes)`)
132
+ }
133
+
134
+ fs.mkdirSync(IMAGES_DIR, { recursive: true })
135
+ const ext = mimeType.includes('png')
136
+ ? '.png'
137
+ : mimeType.includes('gif')
138
+ ? '.gif'
139
+ : mimeType.includes('webp')
140
+ ? '.webp'
141
+ : '.jpg'
142
+ const tmpPath = path.join(IMAGES_DIR, `${memoryId}-upload${ext}`)
143
+ fs.writeFileSync(tmpPath, buf)
144
+ try {
145
+ return await storeMemoryImageAsset(tmpPath, memoryId)
146
+ } finally {
147
+ try { fs.unlinkSync(tmpPath) } catch { /* ignore */ }
148
+ }
149
+ }
150
+
151
+ /** Backward-compatible helper returning only the stored relative path. */
152
+ export async function storeMemoryImage(sourcePath: string, memoryId: string): Promise<string> {
153
+ const image = await storeMemoryImageAsset(sourcePath, memoryId)
154
+ return image.path
155
+ }
156
+
157
+ let _db: ReturnType<typeof initDb> | null = null
158
+
159
+ export function getMemoryLookupLimits(settingsOverride?: Record<string, unknown>): MemoryLookupLimits {
160
+ const settings = settingsOverride || loadSettings()
161
+ return normalizeMemoryLookupLimits(settings)
162
+ }
163
+
164
+ function parseJsonSafe<T>(value: unknown, fallback: T): T {
165
+ if (typeof value !== 'string' || !value.trim()) return fallback
166
+ try {
167
+ return JSON.parse(value) as T
168
+ } catch {
169
+ return fallback
170
+ }
171
+ }
172
+
173
+ function normalizeReferencePath(raw: unknown): string | undefined {
174
+ if (typeof raw !== 'string') return undefined
175
+ const value = raw.trim()
176
+ return value ? value : undefined
177
+ }
178
+
179
+ function canonicalText(value: unknown): string {
180
+ return String(value || '')
181
+ .toLowerCase()
182
+ .replace(/\s+/g, ' ')
183
+ .replace(/[^\w\s:/.-]/g, '')
184
+ .trim()
185
+ }
186
+
187
+ function buildFtsQuery(input: string): string {
188
+ const tokens = String(input || '')
189
+ .toLowerCase()
190
+ .match(/[a-z0-9][a-z0-9._:/-]*/g) || []
191
+ if (!tokens.length) return ''
192
+
193
+ const unique: string[] = []
194
+ const seen = new Set<string>()
195
+ for (const token of tokens) {
196
+ const term = token.slice(0, MAX_FTS_TERM_LENGTH)
197
+ if (term.length < 3) continue
198
+ if (MEMORY_FTS_STOP_WORDS.has(term)) continue
199
+ if (seen.has(term)) continue
200
+ seen.add(term)
201
+ unique.push(term)
202
+ if (unique.length >= MAX_FTS_QUERY_TERMS) break
203
+ }
204
+
205
+ if (unique.length === 1) {
206
+ return unique[0].length >= 5 ? `"${unique[0].replace(/"/g, '')}"` : ''
207
+ }
208
+
209
+ const selected = unique.slice(0, Math.min(4, MAX_FTS_QUERY_TERMS))
210
+ return selected.map((term) => `"${term.replace(/"/g, '')}"`).join(' AND ')
211
+ }
212
+
213
+ function resolveExists(pathValue: string | undefined): boolean | undefined {
214
+ if (!pathValue) return undefined
215
+ const absolute = path.isAbsolute(pathValue) ? pathValue : path.resolve(process.cwd(), pathValue)
216
+ try {
217
+ return fs.existsSync(absolute)
218
+ } catch {
219
+ return undefined
220
+ }
221
+ }
222
+
223
+ function normalizeReferences(
224
+ rawRefs: unknown,
225
+ legacyFilePaths: unknown,
226
+ ): MemoryReference[] | undefined {
227
+ const output: MemoryReference[] = []
228
+ const seen = new Set<string>()
229
+
230
+ const pushRef = (ref: MemoryReference) => {
231
+ const key = `${ref.type}|${ref.path || ''}|${ref.projectRoot || ''}|${ref.title || ''}`
232
+ if (seen.has(key)) return
233
+ seen.add(key)
234
+ output.push(ref)
235
+ }
236
+
237
+ if (Array.isArray(rawRefs)) {
238
+ for (const raw of rawRefs) {
239
+ if (!raw || typeof raw !== 'object') continue
240
+ const obj = raw as Record<string, unknown>
241
+ const type = typeof obj.type === 'string' ? obj.type : 'file'
242
+ if (!['project', 'folder', 'file', 'task', 'session', 'url'].includes(type)) continue
243
+ const pathValue = normalizeReferencePath(obj.path)
244
+ const projectRoot = normalizeReferencePath(obj.projectRoot)
245
+ const title = typeof obj.title === 'string' ? obj.title.trim() : undefined
246
+ const note = typeof obj.note === 'string' ? obj.note.trim() : undefined
247
+ const projectName = typeof obj.projectName === 'string' ? obj.projectName.trim() : undefined
248
+ const ts = typeof obj.timestamp === 'number' && Number.isFinite(obj.timestamp)
249
+ ? Math.trunc(obj.timestamp)
250
+ : Date.now()
251
+ const exists = resolveExists(pathValue) ?? (typeof obj.exists === 'boolean' ? obj.exists : undefined)
252
+ pushRef({
253
+ type: type as MemoryReference['type'],
254
+ path: pathValue,
255
+ projectRoot,
256
+ projectName,
257
+ title,
258
+ note,
259
+ exists,
260
+ timestamp: ts,
261
+ })
262
+ }
263
+ }
264
+
265
+ const legacy = Array.isArray(legacyFilePaths) ? legacyFilePaths as FileReference[] : []
266
+ for (const raw of legacy) {
267
+ if (!raw || typeof raw !== 'object') continue
268
+ const pathValue = normalizeReferencePath((raw as FileReference).path)
269
+ if (!pathValue) continue
270
+ const kind = (raw as FileReference).kind || 'file'
271
+ const type: MemoryReference['type'] = kind === 'project' ? 'project' : (kind === 'folder' ? 'folder' : 'file')
272
+ const timestamp = typeof raw.timestamp === 'number' && Number.isFinite(raw.timestamp)
273
+ ? Math.trunc(raw.timestamp)
274
+ : Date.now()
275
+ pushRef({
276
+ type,
277
+ path: pathValue,
278
+ projectRoot: raw.projectRoot,
279
+ projectName: raw.projectName,
280
+ note: raw.contextSnippet,
281
+ exists: typeof raw.exists === 'boolean' ? raw.exists : resolveExists(pathValue),
282
+ timestamp,
283
+ })
284
+ }
285
+
286
+ return output.length ? output : undefined
287
+ }
288
+
289
+ function referencesToLegacyFilePaths(references?: MemoryReference[]): FileReference[] | undefined {
290
+ if (!references?.length) return undefined
291
+ const fileRefs: FileReference[] = references
292
+ .filter((ref) => ref.type === 'file' || ref.type === 'folder' || ref.type === 'project')
293
+ .map((ref) => ({
294
+ path: ref.path || '',
295
+ contextSnippet: ref.note,
296
+ kind: ref.type === 'project'
297
+ ? 'project' as const
298
+ : ref.type === 'folder'
299
+ ? 'folder' as const
300
+ : 'file' as const,
301
+ projectRoot: ref.projectRoot,
302
+ projectName: ref.projectName,
303
+ exists: ref.exists,
304
+ timestamp: ref.timestamp || Date.now(),
305
+ }))
306
+ .filter((ref) => !!ref.path)
307
+ return fileRefs.length ? fileRefs : undefined
308
+ }
309
+
310
+ function normalizeImage(rawImage: unknown, legacyImagePath?: string | null): MemoryImage | null | undefined {
311
+ if (rawImage && typeof rawImage === 'object') {
312
+ const obj = rawImage as Record<string, unknown>
313
+ const pathValue = normalizeReferencePath(obj.path)
314
+ if (pathValue) {
315
+ return {
316
+ path: pathValue,
317
+ mimeType: typeof obj.mimeType === 'string' ? obj.mimeType : undefined,
318
+ width: typeof obj.width === 'number' ? obj.width : undefined,
319
+ height: typeof obj.height === 'number' ? obj.height : undefined,
320
+ sizeBytes: typeof obj.sizeBytes === 'number' ? obj.sizeBytes : undefined,
321
+ }
322
+ }
323
+ }
324
+ const legacy = normalizeReferencePath(legacyImagePath || undefined)
325
+ if (legacy) return { path: legacy }
326
+ return undefined
327
+ }
328
+
329
+ function initDb() {
330
+ const db = new Database(DB_PATH)
331
+ db.pragma('journal_mode = WAL')
332
+
333
+ db.exec(`
334
+ CREATE TABLE IF NOT EXISTS memories (
335
+ id TEXT PRIMARY KEY,
336
+ agentId TEXT,
337
+ sessionId TEXT,
338
+ category TEXT NOT NULL DEFAULT 'note',
339
+ title TEXT NOT NULL,
340
+ content TEXT NOT NULL DEFAULT '',
341
+ metadata TEXT,
342
+ createdAt INTEGER NOT NULL,
343
+ updatedAt INTEGER NOT NULL
344
+ )
345
+ `)
346
+
347
+ // Safe column migrations for older databases
348
+ for (const col of [
349
+ 'agentId TEXT',
350
+ 'sessionId TEXT',
351
+ 'embedding BLOB',
352
+ 'filePaths TEXT',
353
+ 'imagePath TEXT',
354
+ 'linkedMemoryIds TEXT',
355
+ '"references" TEXT',
356
+ 'image TEXT',
357
+ ]) {
358
+ try { db.exec(`ALTER TABLE memories ADD COLUMN ${col}`) } catch { /* already exists */ }
359
+ }
360
+
361
+ // FTS5 virtual table for full-text search
362
+ db.exec(`
363
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
364
+ title, content, category,
365
+ content='memories',
366
+ content_rowid='rowid'
367
+ )
368
+ `)
369
+
370
+ // Triggers to keep FTS in sync
371
+ db.exec(`
372
+ CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
373
+ INSERT INTO memories_fts(rowid, title, content, category)
374
+ VALUES (new.rowid, new.title, new.content, new.category);
375
+ END
376
+ `)
377
+
378
+ // Critical list-path indexes for large memory datasets.
379
+ // Without these, ORDER BY updatedAt DESC LIMIT N performs a full table scan + temp sort.
380
+ db.exec(`
381
+ CREATE INDEX IF NOT EXISTS idx_memories_updated_at ON memories(updatedAt DESC)
382
+ `)
383
+ db.exec(`
384
+ CREATE INDEX IF NOT EXISTS idx_memories_agent_updated_at ON memories(agentId, updatedAt DESC)
385
+ `)
386
+ db.exec(`
387
+ CREATE INDEX IF NOT EXISTS idx_memories_session_category_updated_at ON memories(sessionId, category, updatedAt DESC)
388
+ `)
389
+ db.exec(`
390
+ CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
391
+ INSERT INTO memories_fts(memories_fts, rowid, title, content, category)
392
+ VALUES ('delete', old.rowid, old.title, old.content, old.category);
393
+ END
394
+ `)
395
+ db.exec(`
396
+ CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
397
+ INSERT INTO memories_fts(memories_fts, rowid, title, content, category)
398
+ VALUES ('delete', old.rowid, old.title, old.content, old.category);
399
+ INSERT INTO memories_fts(rowid, title, content, category)
400
+ VALUES (new.rowid, new.title, new.content, new.category);
401
+ END
402
+ `)
403
+
404
+ const rowsForMigration = db.prepare(`
405
+ SELECT id, filePaths, imagePath, linkedMemoryIds, "references" as refs, image
406
+ FROM memories
407
+ `).all() as Array<{
408
+ id: string
409
+ filePaths: string | null
410
+ imagePath: string | null
411
+ linkedMemoryIds: string | null
412
+ refs: string | null
413
+ image: string | null
414
+ }>
415
+
416
+ const migrationStmt = db.prepare(`
417
+ UPDATE memories
418
+ SET "references" = ?, image = ?, linkedMemoryIds = ?
419
+ WHERE id = ?
420
+ `)
421
+
422
+ const migrateLegacyRows = db.transaction(() => {
423
+ let migrated = 0
424
+ for (const row of rowsForMigration) {
425
+ const legacyFilePaths = parseJsonSafe<FileReference[]>(row.filePaths, [])
426
+ const refs = normalizeReferences(parseJsonSafe<MemoryReference[]>(row.refs, []), legacyFilePaths)
427
+ const image = normalizeImage(parseJsonSafe<MemoryImage | null>(row.image, null), row.imagePath)
428
+ const linkedIds = normalizeLinkedMemoryIds(parseJsonSafe<string[]>(row.linkedMemoryIds, []), row.id)
429
+
430
+ const nextRefs = refs?.length ? JSON.stringify(refs) : null
431
+ const nextImage = image ? JSON.stringify(image) : null
432
+ const nextLinks = linkedIds.length ? JSON.stringify(linkedIds) : null
433
+
434
+ if (nextRefs === row.refs && nextImage === row.image && nextLinks === row.linkedMemoryIds) continue
435
+ migrationStmt.run(nextRefs, nextImage, nextLinks, row.id)
436
+ migrated++
437
+ }
438
+ if (migrated > 0) {
439
+ console.log(`[memory-db] Migrated ${migrated} legacy memory row(s) to graph schema`)
440
+ }
441
+ })
442
+ migrateLegacyRows()
443
+
444
+ // Fresh installs now start with an empty memory graph.
445
+ // Durable memories are created only from actual user/agent interactions.
446
+
447
+ const stmts = {
448
+ insert: db.prepare(`
449
+ INSERT INTO memories (
450
+ id, agentId, sessionId, category, title, content, metadata, embedding,
451
+ "references", filePaths, image, imagePath, linkedMemoryIds, createdAt, updatedAt
452
+ )
453
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
454
+ `),
455
+ update: db.prepare(`
456
+ UPDATE memories
457
+ SET agentId=?, sessionId=?, category=?, title=?, content=?, metadata=?, embedding=?,
458
+ "references"=?, filePaths=?, image=?, imagePath=?, linkedMemoryIds=?, updatedAt=?
459
+ WHERE id=?
460
+ `),
461
+ delete: db.prepare(`DELETE FROM memories WHERE id=?`),
462
+ getById: db.prepare(`SELECT * FROM memories WHERE id=?`),
463
+ getByIds: (ids: string[]) => {
464
+ if (!ids.length) return []
465
+ const placeholders = ids.map(() => '?').join(',')
466
+ return db.prepare(`SELECT * FROM memories WHERE id IN (${placeholders})`).all(...ids) as any[]
467
+ },
468
+ listAll: db.prepare(`SELECT * FROM memories ORDER BY updatedAt DESC LIMIT ?`),
469
+ listByAgent: db.prepare(`SELECT * FROM memories WHERE agentId=? ORDER BY updatedAt DESC LIMIT ?`),
470
+ search: db.prepare(`
471
+ SELECT m.* FROM memories m
472
+ INNER JOIN memories_fts f ON m.rowid = f.rowid
473
+ WHERE memories_fts MATCH ?
474
+ LIMIT ${MAX_FTS_RESULT_ROWS}
475
+ `),
476
+ searchByAgent: db.prepare(`
477
+ SELECT m.* FROM memories m
478
+ INNER JOIN memories_fts f ON m.rowid = f.rowid
479
+ WHERE memories_fts MATCH ? AND m.agentId = ?
480
+ LIMIT ${MAX_FTS_RESULT_ROWS}
481
+ `),
482
+ // Remove a linked ID from all memories that reference it (cleanup on delete)
483
+ findMemoriesLinkingTo: db.prepare(`SELECT * FROM memories WHERE linkedMemoryIds LIKE ?`),
484
+ updateLinks: db.prepare(`UPDATE memories SET linkedMemoryIds = ?, updatedAt = ? WHERE id = ?`),
485
+ latestBySessionCategory: db.prepare(`
486
+ SELECT * FROM memories
487
+ WHERE sessionId = ? AND category = ?
488
+ ORDER BY updatedAt DESC
489
+ LIMIT 1
490
+ `),
491
+ allRowsByUpdated: db.prepare(`SELECT * FROM memories ORDER BY updatedAt DESC`),
492
+ exactDuplicateBySessionCategory: db.prepare(`
493
+ SELECT * FROM memories
494
+ WHERE sessionId = ? AND category = ? AND title = ? AND content = ?
495
+ ORDER BY updatedAt DESC
496
+ LIMIT 1
497
+ `),
498
+ }
499
+
500
+ function rowToEntry(row: Record<string, unknown>): MemoryEntry {
501
+ const legacyFilePaths = parseJsonSafe<FileReference[]>(row.filePaths, [])
502
+ const references = normalizeReferences(parseJsonSafe<MemoryReference[]>(row.references, []), legacyFilePaths)
503
+ const image = normalizeImage(parseJsonSafe<MemoryImage | null>(row.image, null), typeof row.imagePath === 'string' ? row.imagePath : null)
504
+ const filePaths = referencesToLegacyFilePaths(references)
505
+ const linkedMemoryIds = normalizeLinkedMemoryIds(parseJsonSafe<string[]>(row.linkedMemoryIds, []), typeof row.id === 'string' ? row.id : undefined)
506
+
507
+ return {
508
+ id: String(row.id || ''),
509
+ agentId: typeof row.agentId === 'string' ? row.agentId : null,
510
+ sessionId: typeof row.sessionId === 'string' ? row.sessionId : null,
511
+ category: typeof row.category === 'string' ? row.category : 'note',
512
+ title: typeof row.title === 'string' ? row.title : 'Untitled',
513
+ content: typeof row.content === 'string' ? row.content : '',
514
+ metadata: parseJsonSafe<Record<string, unknown> | undefined>(row.metadata, undefined),
515
+ references,
516
+ filePaths,
517
+ image,
518
+ imagePath: image?.path || undefined,
519
+ linkedMemoryIds: linkedMemoryIds.length ? linkedMemoryIds : undefined,
520
+ createdAt: typeof row.createdAt === 'number' ? row.createdAt : Date.now(),
521
+ updatedAt: typeof row.updatedAt === 'number' ? row.updatedAt : Date.now(),
522
+ }
523
+ }
524
+
525
+ function traverseLinked(
526
+ seedEntries: MemoryEntry[],
527
+ limits: MemoryLookupLimits,
528
+ ): { entries: MemoryEntry[]; truncated: boolean; expandedLinkedCount: number } {
529
+ const traversal = traverseLinkedMemoryGraph(
530
+ seedEntries,
531
+ limits,
532
+ (ids) => {
533
+ const linkedRows = stmts.getByIds(ids)
534
+ return linkedRows.map((row) => rowToEntry(row as Record<string, unknown>))
535
+ },
536
+ )
537
+ return traversal
538
+ }
539
+
540
+ const getAllWithEmbeddings = db.prepare(
541
+ `SELECT * FROM memories WHERE embedding IS NOT NULL`
542
+ )
543
+ const getAllWithEmbeddingsByAgent = db.prepare(
544
+ `SELECT * FROM memories WHERE embedding IS NOT NULL AND agentId = ?`
545
+ )
546
+
547
+ return {
548
+ add(data: Omit<MemoryEntry, 'id' | 'createdAt' | 'updatedAt'>): MemoryEntry {
549
+ const id = crypto.randomBytes(6).toString('hex')
550
+ const now = Date.now()
551
+ const references = normalizeReferences(data.references, data.filePaths)
552
+ const legacyFilePaths = referencesToLegacyFilePaths(references)
553
+ const image = normalizeImage(data.image, data.imagePath)
554
+ const linkedMemoryIds = normalizeLinkedMemoryIds(data.linkedMemoryIds, id)
555
+ const sessionId = data.sessionId || null
556
+ const category = data.category || 'note'
557
+ const title = data.title || 'Untitled'
558
+ const content = data.content || ''
559
+
560
+ // Guard against exact duplicate memory spam for the same session/category.
561
+ if (sessionId) {
562
+ const duplicate = stmts.exactDuplicateBySessionCategory.get(sessionId, category, title, content) as Record<string, unknown> | undefined
563
+ if (duplicate) return rowToEntry(duplicate)
564
+ }
565
+ stmts.insert.run(
566
+ id, data.agentId || null, sessionId,
567
+ category, title, content,
568
+ data.metadata ? JSON.stringify(data.metadata) : null,
569
+ null, // embedding computed async
570
+ references?.length ? JSON.stringify(references) : null,
571
+ legacyFilePaths?.length ? JSON.stringify(legacyFilePaths) : null,
572
+ image ? JSON.stringify(image) : null,
573
+ image?.path || null,
574
+ linkedMemoryIds.length ? JSON.stringify(linkedMemoryIds) : null,
575
+ now, now,
576
+ )
577
+ // Compute embedding in background (fire-and-forget)
578
+ const text = `${title} ${content}`.slice(0, 4000)
579
+ getEmbedding(text).then((emb) => {
580
+ if (emb) {
581
+ db.prepare(`UPDATE memories SET embedding = ? WHERE id = ?`).run(
582
+ serializeEmbedding(emb), id,
583
+ )
584
+ }
585
+ }).catch(() => { /* embedding not available, ok */ })
586
+
587
+ // Keep memory links bidirectional by default.
588
+ if (linkedMemoryIds.length) this.link(id, linkedMemoryIds, true)
589
+
590
+ const created = this.get(id)
591
+ if (created) return created
592
+ return {
593
+ ...data,
594
+ id,
595
+ sessionId,
596
+ category,
597
+ title,
598
+ content,
599
+ references,
600
+ filePaths: legacyFilePaths,
601
+ image,
602
+ imagePath: image?.path || null,
603
+ linkedMemoryIds,
604
+ createdAt: now,
605
+ updatedAt: now,
606
+ }
607
+ },
608
+
609
+ update(id: string, updates: Partial<MemoryEntry>): MemoryEntry | null {
610
+ const existing = stmts.getById.get(id) as Record<string, unknown> | undefined
611
+ if (!existing) return null
612
+ const existingEntry = rowToEntry(existing)
613
+ const merged = { ...existingEntry, ...updates }
614
+ const references = normalizeReferences(merged.references, merged.filePaths)
615
+ const legacyFilePaths = referencesToLegacyFilePaths(references)
616
+ const image = normalizeImage(merged.image, merged.imagePath)
617
+ const nextLinked = normalizeLinkedMemoryIds(merged.linkedMemoryIds, id)
618
+ const prevLinked = normalizeLinkedMemoryIds(existingEntry.linkedMemoryIds, id)
619
+ const now = Date.now()
620
+ stmts.update.run(
621
+ merged.agentId || null, merged.sessionId || null,
622
+ merged.category, merged.title, merged.content,
623
+ merged.metadata ? JSON.stringify(merged.metadata) : null,
624
+ existing.embedding || null, // preserve existing embedding
625
+ references?.length ? JSON.stringify(references) : null,
626
+ legacyFilePaths?.length ? JSON.stringify(legacyFilePaths) : null,
627
+ image ? JSON.stringify(image) : null,
628
+ image?.path || null,
629
+ nextLinked.length ? JSON.stringify(nextLinked) : null,
630
+ now, id,
631
+ )
632
+
633
+ // Keep links reciprocal when link set changes.
634
+ if (updates.linkedMemoryIds) {
635
+ const added = nextLinked.filter((lid) => !prevLinked.includes(lid))
636
+ const removed = prevLinked.filter((lid) => !nextLinked.includes(lid))
637
+ if (added.length) this.link(id, added, true)
638
+ if (removed.length) this.unlink(id, removed, true)
639
+ }
640
+
641
+ // Re-compute embedding if content changed
642
+ if (updates.title || updates.content) {
643
+ const text = `${merged.title} ${merged.content}`.slice(0, 4000)
644
+ getEmbedding(text).then((emb) => {
645
+ if (emb) {
646
+ db.prepare(`UPDATE memories SET embedding = ? WHERE id = ?`).run(
647
+ serializeEmbedding(emb), id,
648
+ )
649
+ }
650
+ }).catch(() => { /* ok */ })
651
+ }
652
+ return this.get(id)
653
+ },
654
+
655
+ delete(id: string) {
656
+ // Clean up image file if present
657
+ const row = stmts.getById.get(id) as Record<string, unknown> | undefined
658
+ const entry = row ? rowToEntry(row) : null
659
+ if (entry?.image?.path || entry?.imagePath) {
660
+ const imgPath = path.join(process.cwd(), entry.image?.path || entry.imagePath || '')
661
+ try { fs.unlinkSync(imgPath) } catch { /* file may not exist */ }
662
+ }
663
+ stmts.delete.run(id)
664
+ // Remove this ID from any other memory's linkedMemoryIds
665
+ const linking = stmts.findMemoriesLinkingTo.all(`%"${id}"%`) as any[]
666
+ for (const row of linking) {
667
+ const ids = normalizeLinkedMemoryIds(parseJsonSafe<string[]>(row.linkedMemoryIds, []), row.id)
668
+ const filtered = ids.filter((lid: string) => lid !== id)
669
+ stmts.updateLinks.run(filtered.length ? JSON.stringify(filtered) : null, Date.now(), row.id)
670
+ }
671
+ },
672
+
673
+ get(id: string): MemoryEntry | null {
674
+ const row = stmts.getById.get(id) as Record<string, unknown> | undefined
675
+ if (!row) return null
676
+ return rowToEntry(row)
677
+ },
678
+
679
+ /** Get a memory and its linked memories via BFS traversal */
680
+ getWithLinked(
681
+ id: string,
682
+ maxDepth?: number,
683
+ maxResults?: number,
684
+ maxLinkedExpansion?: number,
685
+ ): { entries: MemoryEntry[]; truncated: boolean; expandedLinkedCount: number; limits: MemoryLookupLimits } | null {
686
+ const row = stmts.getById.get(id) as Record<string, unknown> | undefined
687
+ if (!row) return null
688
+ const entry = rowToEntry(row)
689
+ const defaults = getMemoryLookupLimits()
690
+ const limits = resolveLookupRequest(defaults, {
691
+ depth: maxDepth ?? defaults.maxDepth,
692
+ limit: maxResults ?? defaults.maxPerLookup,
693
+ linkedLimit: maxLinkedExpansion ?? defaults.maxLinkedExpansion,
694
+ })
695
+ const traversal = traverseLinked([entry], limits)
696
+ return { ...traversal, limits }
697
+ },
698
+
699
+ /** Add links from one memory to others */
700
+ link(id: string, targetIds: string[], bidirectional = true): MemoryEntry | null {
701
+ const existing = stmts.getById.get(id) as Record<string, unknown> | undefined
702
+ if (!existing) return null
703
+ const entry = rowToEntry(existing)
704
+ const validTargetIds = normalizeLinkedMemoryIds(targetIds, id)
705
+ const targetRows = stmts.getByIds(validTargetIds)
706
+ const existingTargetIds = new Set((targetRows as Array<Record<string, unknown>>).map((row) => String(row.id)))
707
+ const filteredTargets = validTargetIds.filter((tid) => existingTargetIds.has(tid))
708
+
709
+ const sourceLinks = new Set(normalizeLinkedMemoryIds(entry.linkedMemoryIds, id))
710
+ for (const tid of filteredTargets) sourceLinks.add(tid)
711
+
712
+ const now = Date.now()
713
+ const tx = db.transaction(() => {
714
+ const sourceValues = [...sourceLinks]
715
+ stmts.updateLinks.run(sourceValues.length ? JSON.stringify(sourceValues) : null, now, id)
716
+
717
+ if (!bidirectional) return
718
+ for (const targetRow of targetRows as Array<Record<string, unknown>>) {
719
+ const targetEntry = rowToEntry(targetRow)
720
+ const targetLinks = new Set(normalizeLinkedMemoryIds(targetEntry.linkedMemoryIds, targetEntry.id))
721
+ targetLinks.add(id)
722
+ const next = [...targetLinks]
723
+ stmts.updateLinks.run(next.length ? JSON.stringify(next) : null, now, targetEntry.id)
724
+ }
725
+ })
726
+ tx()
727
+
728
+ return this.get(id)
729
+ },
730
+
731
+ /** Remove links from one memory to others */
732
+ unlink(id: string, targetIds: string[], bidirectional = true): MemoryEntry | null {
733
+ const existing = stmts.getById.get(id) as Record<string, unknown> | undefined
734
+ if (!existing) return null
735
+ const entry = rowToEntry(existing)
736
+ const removeSet = new Set(normalizeLinkedMemoryIds(targetIds, id))
737
+ const now = Date.now()
738
+ const tx = db.transaction(() => {
739
+ const sourceLinks = normalizeLinkedMemoryIds(entry.linkedMemoryIds, id).filter((lid) => !removeSet.has(lid))
740
+ stmts.updateLinks.run(sourceLinks.length ? JSON.stringify(sourceLinks) : null, now, id)
741
+
742
+ if (!bidirectional || !removeSet.size) return
743
+ const targetRows = stmts.getByIds([...removeSet]) as Array<Record<string, unknown>>
744
+ for (const targetRow of targetRows) {
745
+ const targetEntry = rowToEntry(targetRow)
746
+ const next = normalizeLinkedMemoryIds(targetEntry.linkedMemoryIds, targetEntry.id).filter((lid) => lid !== id)
747
+ stmts.updateLinks.run(next.length ? JSON.stringify(next) : null, now, targetEntry.id)
748
+ }
749
+ })
750
+ tx()
751
+
752
+ return this.get(id)
753
+ },
754
+
755
+ search(query: string, agentId?: string): MemoryEntry[] {
756
+ if (shouldSkipSearchQuery(query)) return []
757
+ const startedAt = Date.now()
758
+ // FTS keyword search
759
+ const ftsQuery = buildFtsQuery(query)
760
+ const ftsResults: MemoryEntry[] = ftsQuery
761
+ ? (agentId
762
+ ? stmts.searchByAgent.all(ftsQuery, agentId) as any[]
763
+ : stmts.search.all(ftsQuery) as any[]
764
+ ).map(rowToEntry)
765
+ : []
766
+
767
+ // Attempt vector search (synchronous — uses cached embedding if available)
768
+ let vectorResults: MemoryEntry[] = []
769
+ try {
770
+ const queryEmbedding = getEmbeddingSync(query)
771
+ if (queryEmbedding) {
772
+ const rows = agentId
773
+ ? getAllWithEmbeddingsByAgent.all(agentId) as any[]
774
+ : getAllWithEmbeddings.all() as any[]
775
+
776
+ const scored = rows
777
+ .map((row) => {
778
+ const emb = deserializeEmbedding(row.embedding)
779
+ const score = cosineSimilarity(queryEmbedding, emb)
780
+ return { row, score }
781
+ })
782
+ .filter((s) => s.score > 0.3) // relevance threshold
783
+ .sort((a, b) => b.score - a.score)
784
+ .slice(0, 20)
785
+
786
+ vectorResults = scored.map((s) => rowToEntry(s.row))
787
+ }
788
+ } catch {
789
+ // Vector search unavailable, use FTS only
790
+ }
791
+
792
+ // Merge: deduplicate by id, FTS results first then vector-only
793
+ const seen = new Set<string>()
794
+ const merged: MemoryEntry[] = []
795
+ for (const entry of [...ftsResults, ...vectorResults]) {
796
+ if (!seen.has(entry.id)) {
797
+ seen.add(entry.id)
798
+ merged.push(entry)
799
+ }
800
+ }
801
+ const out = merged.slice(0, MAX_MERGED_RESULTS)
802
+ const elapsed = Date.now() - startedAt
803
+ if (elapsed > 1200) {
804
+ console.warn(
805
+ `[memory-db] Slow search ${elapsed}ms (agent=${agentId || 'all'}, rawLen=${String(query || '').length}, fts="${ftsQuery.slice(0, 180)}")`,
806
+ )
807
+ }
808
+ return out
809
+ },
810
+
811
+ /** Search with linked memory traversal */
812
+ searchWithLinked(
813
+ query: string,
814
+ agentId?: string,
815
+ maxDepth?: number,
816
+ maxResults?: number,
817
+ maxLinkedExpansion?: number,
818
+ ): { entries: MemoryEntry[]; truncated: boolean; expandedLinkedCount: number; limits: MemoryLookupLimits } {
819
+ const baseResults = this.search(query, agentId)
820
+ const defaults = getMemoryLookupLimits()
821
+ const limits = resolveLookupRequest(defaults, {
822
+ depth: maxDepth ?? defaults.maxDepth,
823
+ limit: maxResults ?? defaults.maxPerLookup,
824
+ linkedLimit: maxLinkedExpansion ?? defaults.maxLinkedExpansion,
825
+ })
826
+ if (limits.maxDepth <= 0) {
827
+ return {
828
+ entries: baseResults.slice(0, limits.maxPerLookup),
829
+ truncated: baseResults.length > limits.maxPerLookup,
830
+ expandedLinkedCount: 0,
831
+ limits,
832
+ }
833
+ }
834
+ const traversal = traverseLinked(baseResults, limits)
835
+ return { ...traversal, limits }
836
+ },
837
+
838
+ list(agentId?: string, limit = 200): MemoryEntry[] {
839
+ const safeLimit = Math.max(1, Math.min(500, Math.trunc(limit)))
840
+ const rows = agentId
841
+ ? stmts.listByAgent.all(agentId, safeLimit) as any[]
842
+ : stmts.listAll.all(safeLimit) as any[]
843
+ return rows.map(rowToEntry)
844
+ },
845
+
846
+ getByAgent(agentId: string, limit = 200): MemoryEntry[] {
847
+ const safeLimit = Math.max(1, Math.min(500, Math.trunc(limit)))
848
+ return (stmts.listByAgent.all(agentId, safeLimit) as any[]).map(rowToEntry)
849
+ },
850
+
851
+ analyzeMaintenance(ttlHours = 24): {
852
+ total: number
853
+ exactDuplicateCandidates: number
854
+ canonicalDuplicateCandidates: number
855
+ staleWorkingCandidates: number
856
+ } {
857
+ const rows = (stmts.allRowsByUpdated.all() as any[]).map(rowToEntry)
858
+ const seenExact = new Set<string>()
859
+ const seenCanonical = new Set<string>()
860
+ let exactDuplicateCandidates = 0
861
+ let canonicalDuplicateCandidates = 0
862
+ let staleWorkingCandidates = 0
863
+ const cutoff = Date.now() - Math.max(1, Math.min(24 * 365, Math.trunc(ttlHours))) * 3600_000
864
+
865
+ for (const row of rows) {
866
+ const keyExact = [
867
+ row.agentId || '',
868
+ row.sessionId || '',
869
+ row.category || '',
870
+ row.title || '',
871
+ row.content || '',
872
+ ].join('|')
873
+ if (seenExact.has(keyExact)) exactDuplicateCandidates++
874
+ else seenExact.add(keyExact)
875
+
876
+ const keyCanonical = [
877
+ row.agentId || '',
878
+ row.sessionId || '',
879
+ row.category || '',
880
+ canonicalText(row.title),
881
+ canonicalText(row.content),
882
+ ].join('|')
883
+ if (seenCanonical.has(keyCanonical)) canonicalDuplicateCandidates++
884
+ else seenCanonical.add(keyCanonical)
885
+
886
+ const category = String(row.category || '').toLowerCase()
887
+ const isWorkingLike = category === 'execution' || category === 'working' || category === 'scratch'
888
+ if (isWorkingLike && (row.updatedAt || row.createdAt || 0) < cutoff) staleWorkingCandidates++
889
+ }
890
+
891
+ return {
892
+ total: rows.length,
893
+ exactDuplicateCandidates,
894
+ canonicalDuplicateCandidates,
895
+ staleWorkingCandidates,
896
+ }
897
+ },
898
+
899
+ maintain(opts?: {
900
+ dedupe?: boolean
901
+ canonicalDedupe?: boolean
902
+ pruneWorking?: boolean
903
+ ttlHours?: number
904
+ maxDeletes?: number
905
+ }): {
906
+ deduped: number
907
+ pruned: number
908
+ deletedIds: string[]
909
+ analyzed: {
910
+ total: number
911
+ exactDuplicateCandidates: number
912
+ canonicalDuplicateCandidates: number
913
+ staleWorkingCandidates: number
914
+ }
915
+ } {
916
+ const options = opts || {}
917
+ const rows = (stmts.allRowsByUpdated.all() as any[]).map(rowToEntry)
918
+ const analyzed = this.analyzeMaintenance(options.ttlHours)
919
+ const deleteBudget = Math.max(1, Math.min(20_000, Math.trunc(options.maxDeletes || 500)))
920
+ const deleteIds: string[] = []
921
+ const toDelete = new Set<string>()
922
+ const dedupe = options.dedupe !== false
923
+ const canonicalDedupe = options.canonicalDedupe === true
924
+ const pruneWorking = options.pruneWorking !== false
925
+ const cutoff = Date.now() - Math.max(1, Math.min(24 * 365, Math.trunc(options.ttlHours || 24))) * 3600_000
926
+
927
+ if (dedupe) {
928
+ const seen = new Set<string>()
929
+ for (const row of rows) {
930
+ const key = [
931
+ row.agentId || '',
932
+ row.sessionId || '',
933
+ row.category || '',
934
+ row.title || '',
935
+ row.content || '',
936
+ ].join('|')
937
+ if (seen.has(key)) toDelete.add(row.id)
938
+ else seen.add(key)
939
+ if (toDelete.size >= deleteBudget) break
940
+ }
941
+ }
942
+
943
+ if (canonicalDedupe && toDelete.size < deleteBudget) {
944
+ const seen = new Set<string>()
945
+ for (const row of rows) {
946
+ if (toDelete.has(row.id)) continue
947
+ const key = [
948
+ row.agentId || '',
949
+ row.sessionId || '',
950
+ row.category || '',
951
+ canonicalText(row.title),
952
+ canonicalText(row.content),
953
+ ].join('|')
954
+ if (seen.has(key)) toDelete.add(row.id)
955
+ else seen.add(key)
956
+ if (toDelete.size >= deleteBudget) break
957
+ }
958
+ }
959
+
960
+ if (pruneWorking && toDelete.size < deleteBudget) {
961
+ for (const row of rows) {
962
+ if (toDelete.has(row.id)) continue
963
+ const category = String(row.category || '').toLowerCase()
964
+ const isWorkingLike = category === 'execution' || category === 'working' || category === 'scratch'
965
+ const updatedAt = row.updatedAt || row.createdAt || 0
966
+ if (isWorkingLike && updatedAt < cutoff) toDelete.add(row.id)
967
+ if (toDelete.size >= deleteBudget) break
968
+ }
969
+ }
970
+
971
+ for (const id of toDelete) {
972
+ this.delete(id)
973
+ deleteIds.push(id)
974
+ if (deleteIds.length >= deleteBudget) break
975
+ }
976
+
977
+ let pruned = 0
978
+ let deduped = 0
979
+ if (deleteIds.length) {
980
+ const deletedSet = new Set(deleteIds)
981
+ for (const row of rows) {
982
+ if (!deletedSet.has(row.id)) continue
983
+ const category = String(row.category || '').toLowerCase()
984
+ const isWorkingLike = category === 'execution' || category === 'working' || category === 'scratch'
985
+ if (isWorkingLike) pruned++
986
+ else deduped++
987
+ }
988
+ }
989
+
990
+ return {
991
+ deduped,
992
+ pruned,
993
+ deletedIds: deleteIds,
994
+ analyzed,
995
+ }
996
+ },
997
+
998
+ getLatestBySessionCategory(sessionId: string, category: string): MemoryEntry | null {
999
+ const sid = (sessionId || '').trim()
1000
+ const cat = (category || '').trim()
1001
+ if (!sid || !cat) return null
1002
+ const row = stmts.latestBySessionCategory.get(sid, cat) as Record<string, unknown> | undefined
1003
+ if (!row) return null
1004
+ return rowToEntry(row)
1005
+ },
1006
+ }
1007
+ }
1008
+
1009
+ export function getMemoryDb() {
1010
+ if (!_db) _db = initDb()
1011
+ return _db
1012
+ }
1013
+
1014
+ // ---------------------------------------------------------------------------
1015
+ // Cross-Agent Knowledge Base helpers
1016
+ // ---------------------------------------------------------------------------
1017
+
1018
+ export function addKnowledge(params: {
1019
+ title: string
1020
+ content: string
1021
+ tags?: string[]
1022
+ createdByAgentId?: string | null
1023
+ createdBySessionId?: string | null
1024
+ }): MemoryEntry {
1025
+ const db = getMemoryDb()
1026
+ return db.add({
1027
+ agentId: null,
1028
+ sessionId: null,
1029
+ category: 'knowledge',
1030
+ title: params.title,
1031
+ content: params.content,
1032
+ metadata: {
1033
+ tags: params.tags || [],
1034
+ createdByAgentId: params.createdByAgentId || null,
1035
+ createdBySessionId: params.createdBySessionId || null,
1036
+ },
1037
+ })
1038
+ }
1039
+
1040
+ export function searchKnowledge(query: string, tags?: string[], limit?: number): MemoryEntry[] {
1041
+ const db = getMemoryDb()
1042
+ const results = db.search(query)
1043
+ let filtered = results.filter((e) => e.category === 'knowledge')
1044
+
1045
+ if (tags && tags.length > 0) {
1046
+ const tagSet = new Set(tags.map((t) => t.toLowerCase()))
1047
+ filtered = filtered.filter((e) => {
1048
+ const entryTags: string[] = (e.metadata as Record<string, unknown>)?.tags as string[] || []
1049
+ return entryTags.some((t) => tagSet.has(t.toLowerCase()))
1050
+ })
1051
+ }
1052
+
1053
+ if (limit && limit > 0) {
1054
+ filtered = filtered.slice(0, limit)
1055
+ }
1056
+
1057
+ return filtered
1058
+ }
1059
+
1060
+ export function listKnowledge(tags?: string[], limit?: number): MemoryEntry[] {
1061
+ const db = getMemoryDb()
1062
+ const all = db.list(undefined, 500)
1063
+ let filtered = all.filter((e) => e.category === 'knowledge')
1064
+
1065
+ if (tags && tags.length > 0) {
1066
+ const tagSet = new Set(tags.map((t) => t.toLowerCase()))
1067
+ filtered = filtered.filter((e) => {
1068
+ const entryTags: string[] = (e.metadata as Record<string, unknown>)?.tags as string[] || []
1069
+ return entryTags.some((t) => tagSet.has(t.toLowerCase()))
1070
+ })
1071
+ }
1072
+
1073
+ if (limit && limit > 0) {
1074
+ filtered = filtered.slice(0, limit)
1075
+ }
1076
+
1077
+ return filtered
1078
+ }