agent-mockingbird 0.0.1

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 (227) hide show
  1. package/.agents/skills/btca-cli/SKILL.md +64 -0
  2. package/.agents/skills/btca-cli/agents/openai.yaml +3 -0
  3. package/.agents/skills/frontend-design/SKILL.md +42 -0
  4. package/.agents/skills/frontend-design/agents/openai.yaml +3 -0
  5. package/.env.example +36 -0
  6. package/.githooks/pre-commit +33 -0
  7. package/.github/workflows/ci.yml +309 -0
  8. package/.opencode/bun.lock +18 -0
  9. package/.opencode/package.json +5 -0
  10. package/.opencode/tools/agent_type_manager.ts +100 -0
  11. package/.opencode/tools/config_manager.ts +87 -0
  12. package/.opencode/tools/cron_manager.ts +145 -0
  13. package/.opencode/tools/memory_get.ts +43 -0
  14. package/.opencode/tools/memory_remember.ts +53 -0
  15. package/.opencode/tools/memory_search.ts +48 -0
  16. package/AGENTS.md +126 -0
  17. package/MEMORY.md +2 -0
  18. package/README.md +451 -0
  19. package/THIRD_PARTY_NOTICES.md +11 -0
  20. package/agent-mockingbird.config.example.json +135 -0
  21. package/apps/server/package.json +32 -0
  22. package/apps/server/src/backend/agents/bootstrapContext.ts +362 -0
  23. package/apps/server/src/backend/agents/openclawImport.test.ts +133 -0
  24. package/apps/server/src/backend/agents/openclawImport.ts +797 -0
  25. package/apps/server/src/backend/agents/opencodeConfig.ts +428 -0
  26. package/apps/server/src/backend/agents/service.ts +10 -0
  27. package/apps/server/src/backend/config/example-config.test.ts +20 -0
  28. package/apps/server/src/backend/config/orchestration.ts +243 -0
  29. package/apps/server/src/backend/config/policy.ts +158 -0
  30. package/apps/server/src/backend/config/schema.test.ts +15 -0
  31. package/apps/server/src/backend/config/schema.ts +391 -0
  32. package/apps/server/src/backend/config/semantic.test.ts +34 -0
  33. package/apps/server/src/backend/config/semantic.ts +149 -0
  34. package/apps/server/src/backend/config/service.test.ts +75 -0
  35. package/apps/server/src/backend/config/service.ts +207 -0
  36. package/apps/server/src/backend/config/smoke.ts +77 -0
  37. package/apps/server/src/backend/config/store.test.ts +123 -0
  38. package/apps/server/src/backend/config/store.ts +581 -0
  39. package/apps/server/src/backend/config/testFixtures.ts +5 -0
  40. package/apps/server/src/backend/config/types.ts +56 -0
  41. package/apps/server/src/backend/contracts/events.ts +320 -0
  42. package/apps/server/src/backend/contracts/runtime.ts +111 -0
  43. package/apps/server/src/backend/cron/executor.ts +435 -0
  44. package/apps/server/src/backend/cron/repository.ts +170 -0
  45. package/apps/server/src/backend/cron/service.ts +660 -0
  46. package/apps/server/src/backend/cron/storage.ts +92 -0
  47. package/apps/server/src/backend/cron/types.ts +138 -0
  48. package/apps/server/src/backend/cron/utils.ts +351 -0
  49. package/apps/server/src/backend/db/client.ts +20 -0
  50. package/apps/server/src/backend/db/migrate.ts +40 -0
  51. package/apps/server/src/backend/db/repository.ts +1762 -0
  52. package/apps/server/src/backend/db/schema.ts +113 -0
  53. package/apps/server/src/backend/db/usageDashboard.test.ts +102 -0
  54. package/apps/server/src/backend/db/wipe.ts +13 -0
  55. package/apps/server/src/backend/defaults.ts +32 -0
  56. package/apps/server/src/backend/env.ts +48 -0
  57. package/apps/server/src/backend/heartbeat/activeHours.ts +45 -0
  58. package/apps/server/src/backend/heartbeat/defaultJob.ts +88 -0
  59. package/apps/server/src/backend/heartbeat/heartbeat.test.ts +110 -0
  60. package/apps/server/src/backend/heartbeat/runtimeService.ts +190 -0
  61. package/apps/server/src/backend/heartbeat/service.ts +176 -0
  62. package/apps/server/src/backend/heartbeat/state.test.ts +63 -0
  63. package/apps/server/src/backend/heartbeat/state.ts +167 -0
  64. package/apps/server/src/backend/heartbeat/types.ts +54 -0
  65. package/apps/server/src/backend/http/boundedQueue.test.ts +49 -0
  66. package/apps/server/src/backend/http/boundedQueue.ts +92 -0
  67. package/apps/server/src/backend/http/parsers.ts +40 -0
  68. package/apps/server/src/backend/http/router.ts +61 -0
  69. package/apps/server/src/backend/http/routes/agentRoutes.ts +67 -0
  70. package/apps/server/src/backend/http/routes/backgroundRoutes.ts +203 -0
  71. package/apps/server/src/backend/http/routes/chatRoutes.ts +107 -0
  72. package/apps/server/src/backend/http/routes/configRoutes.ts +602 -0
  73. package/apps/server/src/backend/http/routes/cronRoutes.ts +221 -0
  74. package/apps/server/src/backend/http/routes/dashboardRoutes.ts +308 -0
  75. package/apps/server/src/backend/http/routes/eventRoutes.ts +7 -0
  76. package/apps/server/src/backend/http/routes/heartbeatRoutes.test.ts +41 -0
  77. package/apps/server/src/backend/http/routes/heartbeatRoutes.ts +28 -0
  78. package/apps/server/src/backend/http/routes/index.ts +101 -0
  79. package/apps/server/src/backend/http/routes/mcpRoutes.ts +213 -0
  80. package/apps/server/src/backend/http/routes/memoryRoutes.ts +154 -0
  81. package/apps/server/src/backend/http/routes/runRoutes.ts +310 -0
  82. package/apps/server/src/backend/http/routes/runtimeRoutes.ts +197 -0
  83. package/apps/server/src/backend/http/routes/skillRoutes.ts +112 -0
  84. package/apps/server/src/backend/http/routes/uiRoutes.test.ts +161 -0
  85. package/apps/server/src/backend/http/routes/uiRoutes.ts +177 -0
  86. package/apps/server/src/backend/http/routes/usageRoutes.test.ts +104 -0
  87. package/apps/server/src/backend/http/routes/usageRoutes.ts +767 -0
  88. package/apps/server/src/backend/http/schemas.ts +64 -0
  89. package/apps/server/src/backend/http/sse.ts +144 -0
  90. package/apps/server/src/backend/integration/backend-core.test.ts +2316 -0
  91. package/apps/server/src/backend/logging/logger.ts +64 -0
  92. package/apps/server/src/backend/mcp/service.ts +326 -0
  93. package/apps/server/src/backend/memory/cli.ts +170 -0
  94. package/apps/server/src/backend/memory/conceptExpansion.test.ts +28 -0
  95. package/apps/server/src/backend/memory/conceptExpansion.ts +80 -0
  96. package/apps/server/src/backend/memory/qmdPort.test.ts +54 -0
  97. package/apps/server/src/backend/memory/qmdPort.ts +61 -0
  98. package/apps/server/src/backend/memory/records.test.ts +66 -0
  99. package/apps/server/src/backend/memory/records.ts +229 -0
  100. package/apps/server/src/backend/memory/service.ts +2012 -0
  101. package/apps/server/src/backend/memory/sqliteVec.ts +58 -0
  102. package/apps/server/src/backend/memory/types.ts +104 -0
  103. package/apps/server/src/backend/opencode/agentMockingbirdPlugin.test.ts +396 -0
  104. package/apps/server/src/backend/opencode/client.ts +98 -0
  105. package/apps/server/src/backend/opencode/models.ts +41 -0
  106. package/apps/server/src/backend/opencode/systemPrompt.test.ts +146 -0
  107. package/apps/server/src/backend/opencode/systemPrompt.ts +284 -0
  108. package/apps/server/src/backend/paths.ts +57 -0
  109. package/apps/server/src/backend/prompts/service.ts +100 -0
  110. package/apps/server/src/backend/queue/queue.test.ts +189 -0
  111. package/apps/server/src/backend/queue/service.ts +177 -0
  112. package/apps/server/src/backend/queue/types.ts +39 -0
  113. package/apps/server/src/backend/run/service.ts +576 -0
  114. package/apps/server/src/backend/run/storage.ts +47 -0
  115. package/apps/server/src/backend/run/types.ts +44 -0
  116. package/apps/server/src/backend/runtime/errors.ts +61 -0
  117. package/apps/server/src/backend/runtime/index.ts +72 -0
  118. package/apps/server/src/backend/runtime/memoryPromptDedup.test.ts +153 -0
  119. package/apps/server/src/backend/runtime/memoryPromptDedup.ts +76 -0
  120. package/apps/server/src/backend/runtime/opencodeRuntime/backgroundMethods.ts +765 -0
  121. package/apps/server/src/backend/runtime/opencodeRuntime/coreMethods.ts +705 -0
  122. package/apps/server/src/backend/runtime/opencodeRuntime/eventMethods.ts +503 -0
  123. package/apps/server/src/backend/runtime/opencodeRuntime/memoryMethods.ts +462 -0
  124. package/apps/server/src/backend/runtime/opencodeRuntime/promptMethods.ts +1167 -0
  125. package/apps/server/src/backend/runtime/opencodeRuntime/shared.ts +254 -0
  126. package/apps/server/src/backend/runtime/opencodeRuntime.test.ts +2899 -0
  127. package/apps/server/src/backend/runtime/opencodeRuntime.ts +135 -0
  128. package/apps/server/src/backend/runtime/sessionScope.ts +45 -0
  129. package/apps/server/src/backend/skills/service.ts +442 -0
  130. package/apps/server/src/backend/workspace/resolve.ts +27 -0
  131. package/apps/server/src/cli/agent-mockingbird.mjs +2522 -0
  132. package/apps/server/src/cli/agent-mockingbird.test.ts +68 -0
  133. package/apps/server/src/cli/runtime-assets.mjs +269 -0
  134. package/apps/server/src/cli/runtime-assets.test.ts +52 -0
  135. package/apps/server/src/cli/runtime-layout.mjs +75 -0
  136. package/apps/server/src/cli/standaloneBuild.test.ts +19 -0
  137. package/apps/server/src/cli/standaloneBuild.ts +19 -0
  138. package/apps/server/src/cli/standaloneCronBinary.test.ts +187 -0
  139. package/apps/server/src/index.ts +178 -0
  140. package/apps/server/tsconfig.json +12 -0
  141. package/backlog.md +5 -0
  142. package/bin/agent-mockingbird +2522 -0
  143. package/bin/runtime-layout.mjs +75 -0
  144. package/build-bin.ts +34 -0
  145. package/build-cli.mjs +37 -0
  146. package/build.ts +40 -0
  147. package/bun-env.d.ts +11 -0
  148. package/bun.lock +888 -0
  149. package/bunfig.toml +2 -0
  150. package/components.json +21 -0
  151. package/config.json +130 -0
  152. package/deploy/RELEASE_INSTALL.md +112 -0
  153. package/deploy/docker-compose.yml +42 -0
  154. package/deploy/systemd/README.md +46 -0
  155. package/deploy/systemd/agent-mockingbird.service +28 -0
  156. package/deploy/systemd/opencode.service +25 -0
  157. package/docs/legacy-config-ui-reference.md +51 -0
  158. package/docs/memory-e2e-trace-2026-03-04.md +63 -0
  159. package/docs/memory-ops.md +96 -0
  160. package/docs/memory-runtime-contract.md +42 -0
  161. package/docs/memory-tuning-remote-2026-03-04.md +59 -0
  162. package/docs/opencode-rebase-workflow-plan.md +614 -0
  163. package/docs/opencode-startup-sync-plan.md +94 -0
  164. package/docs/vendor-opencode.md +41 -0
  165. package/drizzle/0000_famous_turbo.sql +49 -0
  166. package/drizzle/0001_cron_memory_aux.sql +160 -0
  167. package/drizzle/0002_runtime_session_bindings.sql +28 -0
  168. package/drizzle/0003_background_runs.sql +27 -0
  169. package/drizzle/0004_memory_open_write.sql +63 -0
  170. package/drizzle/0005_signal_channel.sql +47 -0
  171. package/drizzle/0006_usage_event_dimensions.sql +7 -0
  172. package/drizzle/meta/0000_snapshot.json +341 -0
  173. package/drizzle/meta/_journal.json +55 -0
  174. package/drizzle.config.ts +14 -0
  175. package/eslint.config.mjs +77 -0
  176. package/knip.json +18 -0
  177. package/memory/2026-03-04.md +4 -0
  178. package/opencode.lock.json +16 -0
  179. package/package.json +67 -0
  180. package/packages/agent-mockingbird-installer/README.md +31 -0
  181. package/packages/agent-mockingbird-installer/bin/agent-mockingbird-installer.mjs +44 -0
  182. package/packages/agent-mockingbird-installer/opencode.lock.json +16 -0
  183. package/packages/agent-mockingbird-installer/package.json +23 -0
  184. package/packages/contracts/package.json +19 -0
  185. package/packages/contracts/src/agentTypes.ts +122 -0
  186. package/packages/contracts/src/cron.ts +146 -0
  187. package/packages/contracts/src/dashboard.ts +378 -0
  188. package/packages/contracts/src/index.ts +3 -0
  189. package/packages/contracts/tsconfig.json +4 -0
  190. package/patches/opencode/0001-Wafflebot-OpenCode-baseline.patch +2341 -0
  191. package/patches/opencode/0002-Fix-OpenCode-web-entry-and-settings-icons.patch +104 -0
  192. package/patches/opencode/0003-fix-app-remove-duplicate-sidebar-mount.patch +32 -0
  193. package/patches/opencode/0004-Add-heartbeat-settings-and-usage-nav.patch +506 -0
  194. package/patches/opencode/0005-Use-chart-icon-for-usage-nav.patch +38 -0
  195. package/patches/opencode/0006-Modernize-cron-settings.patch +399 -0
  196. package/patches/opencode/0007-Rename-waffle-namespaces-to-mockingbird.patch +1110 -0
  197. package/patches/opencode/0008-Remove-cron-contract-section.patch +178 -0
  198. package/patches/opencode/0009-Rework-cron-tab-as-operations-console.patch +414 -0
  199. package/patches/opencode/0010-Refine-heartbeat-settings-controls.patch +208 -0
  200. package/runtime-assets/opencode-config/opencode.jsonc +25 -0
  201. package/runtime-assets/opencode-config/package.json +5 -0
  202. package/runtime-assets/opencode-config/plugins/agent-mockingbird.ts +715 -0
  203. package/runtime-assets/workspace/.agents/skills/config-auditor/SKILL.md +25 -0
  204. package/runtime-assets/workspace/.agents/skills/config-editor/SKILL.md +24 -0
  205. package/runtime-assets/workspace/.agents/skills/cron-manager/SKILL.md +57 -0
  206. package/runtime-assets/workspace/.agents/skills/memory-ops/SKILL.md +120 -0
  207. package/runtime-assets/workspace/.agents/skills/runtime-diagnose/SKILL.md +25 -0
  208. package/runtime-assets/workspace/AGENTS.md +56 -0
  209. package/runtime-assets/workspace/MEMORY.md +4 -0
  210. package/scripts/build-release-bundle.sh +66 -0
  211. package/scripts/check-ship.ts +383 -0
  212. package/scripts/dev-opencode.sh +17 -0
  213. package/scripts/dev-stack-opencode.sh +15 -0
  214. package/scripts/dev-stack.sh +61 -0
  215. package/scripts/install-systemd.sh +87 -0
  216. package/scripts/memory-e2e.sh +76 -0
  217. package/scripts/memory-trace-e2e.sh +141 -0
  218. package/scripts/migrate-opencode-env.ts +108 -0
  219. package/scripts/onboard/bootstrap.sh +32 -0
  220. package/scripts/opencode-swap.ts +78 -0
  221. package/scripts/opencode-sync.ts +715 -0
  222. package/scripts/runtime-assets-sync.mjs +83 -0
  223. package/scripts/setup-git-hooks.ts +39 -0
  224. package/tsconfig.json +45 -0
  225. package/tui.json +98 -0
  226. package/turbo.json +36 -0
  227. package/vendor/OPENCODE_VENDOR.md +13 -0
@@ -0,0 +1,2012 @@
1
+ import crypto from "node:crypto";
2
+ import { lstat, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { buildConceptExpandedQueries } from "./conceptExpansion";
6
+ import { blendRrfAndRerank, hasStrongBm25Signal, reciprocalRankFusion } from "./qmdPort";
7
+ import {
8
+ createMemoryRecord,
9
+ extractRecordIdFromChunk,
10
+ formatMemoryRecord,
11
+ parseMemoryRecordBlocks,
12
+ parseMemoryRecords,
13
+ } from "./records";
14
+ import { ensureSqliteVecLoaded, getSqliteVecState } from "./sqliteVec";
15
+ import type {
16
+ MemoryChunk,
17
+ MemoryLintReport,
18
+ MemoryRecord,
19
+ MemoryRecordInput,
20
+ MemoryRememberInput,
21
+ MemoryRememberResult,
22
+ MemorySearchResult,
23
+ MemoryStatus,
24
+ MemoryWriteEvent,
25
+ MemoryWriteValidation,
26
+ } from "./types";
27
+ import { getConfigSnapshot } from "../config/service";
28
+ import { sqlite } from "../db/client";
29
+ import { getBinaryDir } from "../paths";
30
+
31
+ interface MemoryFileRow {
32
+ path: string;
33
+ hash: string;
34
+ }
35
+
36
+ interface MemoryChunkRow {
37
+ id: string;
38
+ path: string;
39
+ start_line: number;
40
+ end_line: number;
41
+ text: string;
42
+ embedding_json: string | null;
43
+ updated_at: number;
44
+ }
45
+
46
+ interface MemoryFtsRow {
47
+ chunk_id: string;
48
+ path: string;
49
+ start_line: number;
50
+ end_line: number;
51
+ text: string;
52
+ rank: number;
53
+ }
54
+
55
+ interface MemoryRecordRow {
56
+ id: string;
57
+ confidence: number;
58
+ superseded_by: string | null;
59
+ }
60
+
61
+ interface MemoryWriteEventRow {
62
+ id: string;
63
+ status: "accepted" | "rejected";
64
+ reason: string;
65
+ source: string;
66
+ content: string;
67
+ confidence: number;
68
+ session_id: string | null;
69
+ topic: string | null;
70
+ record_id: string | null;
71
+ path: string | null;
72
+ created_at: number;
73
+ }
74
+
75
+ interface SearchCandidate {
76
+ id: string;
77
+ path: string;
78
+ startLine: number;
79
+ endLine: number;
80
+ text: string;
81
+ embedding: number[] | null;
82
+ vectorScore: number;
83
+ textScore: number;
84
+ score: number;
85
+ updatedAt: number;
86
+ recordId: string | null;
87
+ }
88
+
89
+ const MEMORY_META_KEY = "memory_index_meta_v1";
90
+ const MEMORY_VEC_META_KEY = "memory_vec_meta_v1";
91
+ const SOURCE = "memory";
92
+ const SNIPPET_MAX_CHARS = 700;
93
+ const DEFAULT_CANDIDATE_LIMIT = 48;
94
+ const MMR_LAMBDA = 0.75;
95
+ const VECTOR_PREFILTER_MIN = 200;
96
+ const VECTOR_PREFILTER_MAX = 800;
97
+ const SQLITE_IN_BIND_LIMIT = 900;
98
+ const MEMORY_VEC_TABLE = "memory_chunks_vec";
99
+ const SEARCH_TOKEN_RE = /[a-z0-9]{3,}/g;
100
+ const RECALL_INTENT_RE = /\b(?:what\s+do\s+you\s+remember|remind\s+me|recall|from\s+memory|what\s+do\s+you\s+know\s+about\s+me)\b/i;
101
+ const MEMORY_MANAGEMENT_RE = /\b(?:memory|remember|recall|note|save)\b/i;
102
+ const STOPWORDS = new Set([
103
+ "about",
104
+ "after",
105
+ "again",
106
+ "against",
107
+ "also",
108
+ "and",
109
+ "any",
110
+ "are",
111
+ "because",
112
+ "been",
113
+ "being",
114
+ "between",
115
+ "both",
116
+ "but",
117
+ "can",
118
+ "could",
119
+ "did",
120
+ "does",
121
+ "doing",
122
+ "dont",
123
+ "each",
124
+ "few",
125
+ "for",
126
+ "from",
127
+ "had",
128
+ "has",
129
+ "have",
130
+ "here",
131
+ "how",
132
+ "into",
133
+ "its",
134
+ "just",
135
+ "more",
136
+ "most",
137
+ "not",
138
+ "now",
139
+ "off",
140
+ "our",
141
+ "out",
142
+ "over",
143
+ "same",
144
+ "should",
145
+ "some",
146
+ "such",
147
+ "than",
148
+ "that",
149
+ "the",
150
+ "their",
151
+ "them",
152
+ "then",
153
+ "there",
154
+ "these",
155
+ "they",
156
+ "this",
157
+ "those",
158
+ "through",
159
+ "under",
160
+ "until",
161
+ "very",
162
+ "want",
163
+ "what",
164
+ "when",
165
+ "where",
166
+ "which",
167
+ "while",
168
+ "with",
169
+ "would",
170
+ "your",
171
+ ]);
172
+
173
+ let schemaReady = false;
174
+ let lastSyncMs = 0;
175
+ let syncPromise: Promise<void> | null = null;
176
+
177
+ const nowMs = () => Date.now();
178
+ const hashText = (value: string) => crypto.createHash("sha256").update(value).digest("hex");
179
+
180
+ function currentMemoryConfig() {
181
+ return getConfigSnapshot().config.runtime.memory;
182
+ }
183
+
184
+ function resolveWorkspaceDir() {
185
+ const workspaceDir = currentMemoryConfig().workspaceDir;
186
+ if (path.isAbsolute(workspaceDir)) {
187
+ return workspaceDir;
188
+ }
189
+ return path.resolve(getBinaryDir(), workspaceDir);
190
+ }
191
+
192
+ function normalizeRelPath(absPath: string) {
193
+ return path.relative(resolveWorkspaceDir(), absPath).replaceAll(path.sep, "/");
194
+ }
195
+
196
+ function sqliteTableExists(name: string) {
197
+ const row = sqlite
198
+ .query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?1")
199
+ .get(name) as { name: string } | null;
200
+ return Boolean(row?.name);
201
+ }
202
+
203
+ function isMemoryPath(relPath: string) {
204
+ const normalized = relPath.trim().replaceAll("\\", "/");
205
+ if (!normalized || normalized.startsWith("../")) {
206
+ return false;
207
+ }
208
+ if (normalized === "MEMORY.md" || normalized === "memory.md") {
209
+ return true;
210
+ }
211
+ return normalized.startsWith("memory/") && normalized.endsWith(".md");
212
+ }
213
+
214
+ function buildChunkId(relPath: string, chunk: MemoryChunk) {
215
+ return hashText(`${SOURCE}:${relPath}:${chunk.startLine}:${chunk.endLine}:${chunk.hash}`);
216
+ }
217
+
218
+ function parseEmbedding(raw: string | null): number[] {
219
+ if (!raw) return [];
220
+ try {
221
+ const parsed = JSON.parse(raw) as number[];
222
+ return Array.isArray(parsed) ? parsed : [];
223
+ } catch {
224
+ return [];
225
+ }
226
+ }
227
+
228
+ function cosineSimilarity(a: number[], b: number[]) {
229
+ if (!a.length || !b.length) return 0;
230
+ const length = Math.min(a.length, b.length);
231
+ let dot = 0;
232
+ let magA = 0;
233
+ let magB = 0;
234
+ for (let i = 0; i < length; i += 1) {
235
+ const av = a[i] ?? 0;
236
+ const bv = b[i] ?? 0;
237
+ dot += av * bv;
238
+ magA += av * av;
239
+ magB += bv * bv;
240
+ }
241
+ if (magA <= 0 || magB <= 0) return 0;
242
+ return dot / (Math.sqrt(magA) * Math.sqrt(magB));
243
+ }
244
+
245
+ function clipSnippet(text: string, maxChars = SNIPPET_MAX_CHARS) {
246
+ if (text.length <= maxChars) return text;
247
+ return `${text.slice(0, maxChars).trimEnd()}…`;
248
+ }
249
+
250
+ function collectQueryTokens(text: string) {
251
+ const matched = text.toLowerCase().match(SEARCH_TOKEN_RE) ?? [];
252
+ return new Set(matched.filter(token => !STOPWORDS.has(token)));
253
+ }
254
+
255
+ function normalizeSnippetFromChunk(text: string) {
256
+ const structured = text.match(/^(###\s+\[memory:[^\n]+\])\n(?:meta:[^\n]*\n)?\n?([\s\S]*)$/i);
257
+ if (!structured) {
258
+ return clipSnippet(text);
259
+ }
260
+ const heading = structured[1]?.trim();
261
+ const body = structured[2]?.trim();
262
+ if (!heading || !body) {
263
+ return clipSnippet(text);
264
+ }
265
+ return clipSnippet(`${heading}\n${body}`);
266
+ }
267
+
268
+ function isMemoryRecallIntentQuery(query: string) {
269
+ return RECALL_INTENT_RE.test(query);
270
+ }
271
+
272
+ function isMemoryManagementQuery(query: string) {
273
+ return MEMORY_MANAGEMENT_RE.test(query);
274
+ }
275
+
276
+ function hasLexicalSignal(candidate: SearchCandidate, queryTokens: Set<string>) {
277
+ if (!queryTokens.size) return true;
278
+ const candidateTokens = collectQueryTokens(`${candidate.path}\n${candidate.text}`);
279
+ let overlap = 0;
280
+ for (const token of queryTokens) {
281
+ if (!candidateTokens.has(token)) continue;
282
+ overlap += 1;
283
+ if (overlap >= 2) return true;
284
+ }
285
+ return overlap >= 1 && candidate.score >= 0.55;
286
+ }
287
+
288
+ function isLikelyBoilerplateIndexCandidate(candidate: SearchCandidate) {
289
+ if (candidate.path.toLowerCase() !== "memory.md") return false;
290
+ const text = candidate.text.toLowerCase();
291
+ return (
292
+ text.includes("memory index") ||
293
+ text.includes("store durable notes") ||
294
+ text.includes("memory/*.md")
295
+ );
296
+ }
297
+
298
+ function normalizeRecordInput(input: MemoryRecordInput): MemoryRecordInput {
299
+ return {
300
+ source: input.source,
301
+ content: input.content.trim(),
302
+ entities: [...new Set((input.entities ?? []).map(value => value.trim()).filter(Boolean))],
303
+ confidence:
304
+ typeof input.confidence === "number" ? Math.max(0, Math.min(1, input.confidence)) : undefined,
305
+ supersedes: [...new Set((input.supersedes ?? []).map(value => value.trim()).filter(Boolean))],
306
+ };
307
+ }
308
+
309
+ function chunkMarkdown(content: string, tokens: number, overlap: number): MemoryChunk[] {
310
+ const lines = content.split("\n");
311
+ if (!lines.length) return [];
312
+
313
+ const maxChars = Math.max(64, tokens * 4);
314
+ const overlapChars = Math.max(0, overlap * 4);
315
+
316
+ const chunks: MemoryChunk[] = [];
317
+ let current: Array<{ line: string; lineNo: number }> = [];
318
+ let charCount = 0;
319
+
320
+ const flush = () => {
321
+ if (!current.length) return;
322
+ const first = current[0];
323
+ const last = current[current.length - 1];
324
+ if (!first || !last) return;
325
+ const text = current.map(entry => entry.line).join("\n");
326
+ chunks.push({
327
+ id: "",
328
+ path: "",
329
+ startLine: first.lineNo,
330
+ endLine: last.lineNo,
331
+ text,
332
+ hash: hashText(text),
333
+ });
334
+ };
335
+
336
+ const carryOverlap = () => {
337
+ if (overlapChars <= 0 || !current.length) {
338
+ current = [];
339
+ charCount = 0;
340
+ return;
341
+ }
342
+
343
+ let tracked = 0;
344
+ const kept: Array<{ line: string; lineNo: number }> = [];
345
+ for (let i = current.length - 1; i >= 0; i -= 1) {
346
+ const entry = current[i];
347
+ if (!entry) continue;
348
+ tracked += entry.line.length + 1;
349
+ kept.unshift(entry);
350
+ if (tracked >= overlapChars) break;
351
+ }
352
+
353
+ current = kept;
354
+ charCount = kept.reduce((sum, entry) => sum + entry.line.length + 1, 0);
355
+ };
356
+
357
+ for (let index = 0; index < lines.length; index += 1) {
358
+ const line = lines[index] ?? "";
359
+ const lineNo = index + 1;
360
+ const segments: string[] = line.length
361
+ ? Array.from({ length: Math.ceil(line.length / maxChars) }, (_, segmentIndex) =>
362
+ line.slice(segmentIndex * maxChars, segmentIndex * maxChars + maxChars),
363
+ )
364
+ : [""];
365
+
366
+ for (const segment of segments) {
367
+ const segmentSize = segment.length + 1;
368
+ if (charCount + segmentSize > maxChars && current.length) {
369
+ flush();
370
+ carryOverlap();
371
+ }
372
+ current.push({ line: segment, lineNo });
373
+ charCount += segmentSize;
374
+ }
375
+ }
376
+
377
+ flush();
378
+ return chunks.filter(chunk => chunk.text.trim().length > 0);
379
+ }
380
+
381
+ function buildFtsQuery(query: string): string | null {
382
+ const tokens = query
383
+ .toLowerCase()
384
+ .split(/[^a-z0-9_]+/i)
385
+ .map(token => token.trim())
386
+ .filter(token => token.length >= 2);
387
+ if (!tokens.length) return null;
388
+ return tokens.map(token => `${token}*`).join(" AND ");
389
+ }
390
+
391
+ async function walkDirectory(dir: string, output: string[]) {
392
+ const entries = await readdir(dir, { withFileTypes: true });
393
+ for (const entry of entries) {
394
+ const full = path.join(dir, entry.name);
395
+ if (entry.isSymbolicLink()) continue;
396
+ if (entry.isDirectory()) {
397
+ await walkDirectory(full, output);
398
+ continue;
399
+ }
400
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
401
+ output.push(full);
402
+ }
403
+ }
404
+
405
+ async function listMemoryFiles(): Promise<string[]> {
406
+ const workspaceDir = resolveWorkspaceDir();
407
+ const files: string[] = [];
408
+ const baseCandidates = ["MEMORY.md", "memory.md"].map(name => path.join(workspaceDir, name));
409
+ for (const candidate of baseCandidates) {
410
+ try {
411
+ const candidateStat = await lstat(candidate);
412
+ if (candidateStat.isFile() && !candidateStat.isSymbolicLink()) {
413
+ files.push(candidate);
414
+ }
415
+ } catch {
416
+ // noop
417
+ }
418
+ }
419
+
420
+ const memoryDir = path.join(workspaceDir, "memory");
421
+ try {
422
+ const memoryStat = await lstat(memoryDir);
423
+ if (memoryStat.isDirectory() && !memoryStat.isSymbolicLink()) {
424
+ await walkDirectory(memoryDir, files);
425
+ }
426
+ } catch {
427
+ // noop
428
+ }
429
+
430
+ return [...new Set(files)];
431
+ }
432
+
433
+ async function ensureWorkspaceScaffold() {
434
+ const workspaceDir = resolveWorkspaceDir();
435
+ await mkdir(path.join(workspaceDir, "memory"), { recursive: true });
436
+ const memoryFile = path.join(workspaceDir, "MEMORY.md");
437
+ try {
438
+ await stat(memoryFile);
439
+ } catch {
440
+ await writeFile(memoryFile, "# Memory\n\n", "utf8");
441
+ }
442
+ }
443
+
444
+ function memoryIndexMeta() {
445
+ if (!sqliteTableExists("memory_meta")) return null;
446
+ const row = sqlite
447
+ .query("SELECT value_json FROM memory_meta WHERE key = ?1")
448
+ .get(MEMORY_META_KEY) as { value_json: string } | null;
449
+ if (!row?.value_json) return null;
450
+ try {
451
+ return JSON.parse(row.value_json) as {
452
+ provider?: string;
453
+ model?: string;
454
+ chunkTokens?: number;
455
+ chunkOverlap?: number;
456
+ indexedAt?: number;
457
+ };
458
+ } catch {
459
+ return null;
460
+ }
461
+ }
462
+
463
+ function memoryVecMeta() {
464
+ if (!sqliteTableExists("memory_meta")) return null;
465
+ const row = sqlite
466
+ .query("SELECT value_json FROM memory_meta WHERE key = ?1")
467
+ .get(MEMORY_VEC_META_KEY) as { value_json: string } | null;
468
+ if (!row?.value_json) return null;
469
+ try {
470
+ return JSON.parse(row.value_json) as {
471
+ enabled?: boolean;
472
+ dims?: number;
473
+ model?: string;
474
+ provider?: string;
475
+ updatedAt?: number;
476
+ error?: string | null;
477
+ };
478
+ } catch {
479
+ return null;
480
+ }
481
+ }
482
+
483
+ function vecTableInfo() {
484
+ return sqlite
485
+ .query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?1")
486
+ .get(MEMORY_VEC_TABLE) as { sql: string } | null;
487
+ }
488
+
489
+ function vecTableExists() {
490
+ return Boolean(vecTableInfo());
491
+ }
492
+
493
+ function vecTableDimensions() {
494
+ const info = vecTableInfo();
495
+ if (!info?.sql) return null;
496
+ const match = info.sql.match(/float\[(\d+)\]/);
497
+ if (!match?.[1]) return null;
498
+ const parsed = Number(match[1]);
499
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
500
+ }
501
+
502
+ function recreateVecTableForDimensions(dims: number) {
503
+ sqlite.exec(`DROP TABLE IF EXISTS ${MEMORY_VEC_TABLE}`);
504
+ sqlite.exec(
505
+ `CREATE VIRTUAL TABLE ${MEMORY_VEC_TABLE} USING vec0(chunk_id TEXT PRIMARY KEY, embedding float[${dims}] distance_metric=cosine)`,
506
+ );
507
+ }
508
+
509
+ function ensureVecTableForDimensions(dims: number) {
510
+ if (!Number.isFinite(dims) || dims <= 0) return false;
511
+ const existingDims = vecTableDimensions();
512
+ if (existingDims === dims && vecTableExists()) {
513
+ return false;
514
+ }
515
+ recreateVecTableForDimensions(dims);
516
+ return true;
517
+ }
518
+
519
+ function deleteVecRowsByChunkIds(chunkIds: string[]) {
520
+ if (!chunkIds.length || !vecTableExists()) return;
521
+ const batches: string[][] = [];
522
+ for (let index = 0; index < chunkIds.length; index += SQLITE_IN_BIND_LIMIT) {
523
+ batches.push(chunkIds.slice(index, index + SQLITE_IN_BIND_LIMIT));
524
+ }
525
+ for (const batch of batches) {
526
+ if (!batch.length) continue;
527
+ sqlite
528
+ .query(`DELETE FROM ${MEMORY_VEC_TABLE} WHERE chunk_id IN (${batch.map(() => "?").join(", ")})`)
529
+ .run(...batch);
530
+ }
531
+ }
532
+
533
+ function upsertVecRows(rows: Array<{ chunkId: string; embedding: number[] }>) {
534
+ if (!rows.length || !vecTableExists()) return;
535
+ const insert = sqlite.query(`INSERT OR REPLACE INTO ${MEMORY_VEC_TABLE} (chunk_id, embedding) VALUES (?1, ?2)`);
536
+ for (const row of rows) {
537
+ if (!row.embedding.length) continue;
538
+ insert.run(row.chunkId, new Float32Array(row.embedding));
539
+ }
540
+ }
541
+
542
+ async function ensureSchema() {
543
+ if (schemaReady) return;
544
+ await ensureSqliteVecLoaded(sqlite);
545
+ sqlite.exec(`
546
+ CREATE TABLE IF NOT EXISTS memory_meta (
547
+ key TEXT PRIMARY KEY,
548
+ value_json TEXT NOT NULL
549
+ );
550
+
551
+ CREATE TABLE IF NOT EXISTS memory_files (
552
+ path TEXT PRIMARY KEY,
553
+ source TEXT NOT NULL DEFAULT 'memory',
554
+ hash TEXT NOT NULL,
555
+ mtime INTEGER NOT NULL,
556
+ size INTEGER NOT NULL,
557
+ indexed_at INTEGER NOT NULL
558
+ );
559
+
560
+ CREATE TABLE IF NOT EXISTS memory_chunks (
561
+ id TEXT PRIMARY KEY,
562
+ path TEXT NOT NULL,
563
+ source TEXT NOT NULL DEFAULT 'memory',
564
+ start_line INTEGER NOT NULL,
565
+ end_line INTEGER NOT NULL,
566
+ hash TEXT NOT NULL,
567
+ text TEXT NOT NULL,
568
+ embedding_json TEXT,
569
+ updated_at INTEGER NOT NULL
570
+ );
571
+
572
+ CREATE INDEX IF NOT EXISTS memory_chunks_path_idx ON memory_chunks(path);
573
+ CREATE INDEX IF NOT EXISTS memory_chunks_updated_idx ON memory_chunks(updated_at);
574
+
575
+ CREATE TABLE IF NOT EXISTS memory_embedding_cache (
576
+ provider TEXT NOT NULL,
577
+ model TEXT NOT NULL,
578
+ hash TEXT NOT NULL,
579
+ embedding_json TEXT NOT NULL,
580
+ dims INTEGER NOT NULL,
581
+ updated_at INTEGER NOT NULL,
582
+ PRIMARY KEY(provider, model, hash)
583
+ );
584
+
585
+ CREATE INDEX IF NOT EXISTS memory_embedding_cache_updated_idx ON memory_embedding_cache(updated_at);
586
+
587
+ CREATE TABLE IF NOT EXISTS memory_records (
588
+ id TEXT PRIMARY KEY,
589
+ path TEXT NOT NULL,
590
+ source TEXT NOT NULL,
591
+ content TEXT NOT NULL,
592
+ entities_json TEXT NOT NULL,
593
+ confidence REAL NOT NULL,
594
+ supersedes_json TEXT NOT NULL,
595
+ superseded_by TEXT,
596
+ recorded_at INTEGER NOT NULL,
597
+ updated_at INTEGER NOT NULL
598
+ );
599
+
600
+ CREATE INDEX IF NOT EXISTS memory_records_path_idx ON memory_records(path);
601
+ CREATE INDEX IF NOT EXISTS memory_records_superseded_idx ON memory_records(superseded_by);
602
+
603
+ CREATE TABLE IF NOT EXISTS memory_write_events (
604
+ id TEXT PRIMARY KEY,
605
+ status TEXT NOT NULL,
606
+ reason TEXT NOT NULL,
607
+ source TEXT NOT NULL,
608
+ content TEXT NOT NULL,
609
+ confidence REAL NOT NULL,
610
+ session_id TEXT,
611
+ topic TEXT,
612
+ record_id TEXT,
613
+ path TEXT,
614
+ created_at INTEGER NOT NULL
615
+ );
616
+
617
+ CREATE INDEX IF NOT EXISTS memory_write_events_created_idx ON memory_write_events(created_at DESC);
618
+ `);
619
+
620
+ sqlite.exec(`
621
+ CREATE VIRTUAL TABLE IF NOT EXISTS memory_chunks_fts USING fts5(
622
+ text,
623
+ chunk_id UNINDEXED,
624
+ path UNINDEXED,
625
+ start_line UNINDEXED,
626
+ end_line UNINDEXED,
627
+ updated_at UNINDEXED
628
+ );
629
+ `);
630
+
631
+ schemaReady = true;
632
+ }
633
+
634
+ function getEmbeddingProviderConfig() {
635
+ const memoryConfig = currentMemoryConfig();
636
+ return {
637
+ provider: memoryConfig.embedProvider,
638
+ model: memoryConfig.embedModel,
639
+ ollamaBaseUrl: memoryConfig.ollamaBaseUrl,
640
+ };
641
+ }
642
+
643
+ async function embedWithOllama(texts: string[]): Promise<number[][]> {
644
+ if (!texts.length) return [];
645
+
646
+ const { ollamaBaseUrl, model } = getEmbeddingProviderConfig();
647
+ const baseUrl = ollamaBaseUrl.replace(/\/+$/, "");
648
+ const embedResponse = await fetch(`${baseUrl}/api/embed`, {
649
+ method: "POST",
650
+ headers: { "Content-Type": "application/json" },
651
+ body: JSON.stringify({ model, input: texts }),
652
+ });
653
+
654
+ if (embedResponse.ok) {
655
+ const payload = (await embedResponse.json()) as { embeddings?: number[][] };
656
+ const embeddings = payload.embeddings ?? [];
657
+ if (embeddings.length === texts.length) {
658
+ return embeddings.map(vector => vector.map(value => (Number.isFinite(value) ? value : 0)));
659
+ }
660
+ }
661
+
662
+ const vectors: number[][] = [];
663
+ for (const text of texts) {
664
+ const fallbackResponse = await fetch(`${baseUrl}/api/embeddings`, {
665
+ method: "POST",
666
+ headers: { "Content-Type": "application/json" },
667
+ body: JSON.stringify({ model, prompt: text }),
668
+ });
669
+ if (!fallbackResponse.ok) {
670
+ const payload = await fallbackResponse.text();
671
+ throw new Error(`Ollama embedding failed: ${fallbackResponse.status} ${payload}`);
672
+ }
673
+ const payload = (await fallbackResponse.json()) as { embedding?: number[] };
674
+ vectors.push((payload.embedding ?? []).map(value => (Number.isFinite(value) ? value : 0)));
675
+ }
676
+
677
+ return vectors;
678
+ }
679
+
680
+ async function embedTexts(texts: string[]): Promise<number[][]> {
681
+ const provider = currentMemoryConfig().embedProvider;
682
+ if (provider === "none") {
683
+ return texts.map(() => []);
684
+ }
685
+ return embedWithOllama(texts);
686
+ }
687
+
688
+ function loadEmbeddingCache(hashes: string[]) {
689
+ if (!hashes.length) return new Map<string, number[]>();
690
+ const { provider, model } = getEmbeddingProviderConfig();
691
+ const unique = [...new Set(hashes)];
692
+ const placeholders = unique.map(() => "?").join(", ");
693
+ const rows = sqlite
694
+ .query(
695
+ `
696
+ SELECT hash, embedding_json
697
+ FROM memory_embedding_cache
698
+ WHERE provider = ?1 AND model = ?2 AND hash IN (${placeholders})
699
+ `,
700
+ )
701
+ .all(provider, model, ...unique) as Array<{ hash: string; embedding_json: string }>;
702
+
703
+ return new Map(rows.map(row => [row.hash, parseEmbedding(row.embedding_json)]));
704
+ }
705
+
706
+ function upsertEmbeddingCache(entries: Array<{ hash: string; embedding: number[] }>) {
707
+ if (!entries.length) return;
708
+ const { provider, model } = getEmbeddingProviderConfig();
709
+ const upsert = sqlite.query(`
710
+ INSERT INTO memory_embedding_cache (provider, model, hash, embedding_json, dims, updated_at)
711
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)
712
+ ON CONFLICT(provider, model, hash) DO UPDATE SET
713
+ embedding_json = excluded.embedding_json,
714
+ dims = excluded.dims,
715
+ updated_at = excluded.updated_at
716
+ `);
717
+ const updatedAt = nowMs();
718
+ for (const entry of entries) {
719
+ upsert.run(provider, model, entry.hash, JSON.stringify(entry.embedding), entry.embedding.length, updatedAt);
720
+ }
721
+ }
722
+
723
+ async function embedChunks(chunks: MemoryChunk[]) {
724
+ if (!chunks.length) return [];
725
+ const cache = loadEmbeddingCache(chunks.map(chunk => chunk.hash));
726
+ const result: number[][] = Array.from({ length: chunks.length }, () => []);
727
+ const missing: Array<{ index: number; chunk: MemoryChunk }> = [];
728
+
729
+ for (let i = 0; i < chunks.length; i += 1) {
730
+ const chunk = chunks[i];
731
+ if (!chunk) continue;
732
+ const cached = cache.get(chunk.hash);
733
+ if (cached?.length) {
734
+ result[i] = cached;
735
+ } else {
736
+ missing.push({ index: i, chunk });
737
+ }
738
+ }
739
+
740
+ if (!missing.length) return result;
741
+
742
+ const embeddings = await embedTexts(missing.map(item => item.chunk.text));
743
+ const cacheEntries: Array<{ hash: string; embedding: number[] }> = [];
744
+ for (let i = 0; i < missing.length; i += 1) {
745
+ const missingItem = missing[i];
746
+ const embedding = embeddings[i] ?? [];
747
+ if (!missingItem) continue;
748
+ result[missingItem.index] = embedding;
749
+ cacheEntries.push({ hash: missingItem.chunk.hash, embedding });
750
+ }
751
+ upsertEmbeddingCache(cacheEntries);
752
+ return result;
753
+ }
754
+
755
+ async function indexMemoryFile(filePath: string, options?: { force?: boolean }) {
756
+ const fileStat = await stat(filePath);
757
+ const content = await readFile(filePath, "utf8");
758
+ const fileHash = hashText(content);
759
+ const relPath = normalizeRelPath(filePath);
760
+
761
+ const row = sqlite
762
+ .query("SELECT path, hash FROM memory_files WHERE path = ?1")
763
+ .get(relPath) as MemoryFileRow | null;
764
+ if (!options?.force && row?.hash === fileHash) {
765
+ return false;
766
+ }
767
+
768
+ const memoryConfig = currentMemoryConfig();
769
+ const recordBlocks = parseMemoryRecordBlocks(content);
770
+ const chunks =
771
+ recordBlocks.length > 0
772
+ ? recordBlocks.map(block => {
773
+ const text = block.text;
774
+ const hash = hashText(text);
775
+ const chunk: MemoryChunk = {
776
+ id: "",
777
+ path: relPath,
778
+ startLine: block.startLine,
779
+ endLine: block.endLine,
780
+ text,
781
+ hash,
782
+ };
783
+ return {
784
+ ...chunk,
785
+ id: buildChunkId(relPath, chunk),
786
+ };
787
+ })
788
+ : chunkMarkdown(content, memoryConfig.chunkTokens, memoryConfig.chunkOverlap).map(
789
+ chunk => ({
790
+ ...chunk,
791
+ id: buildChunkId(relPath, chunk),
792
+ path: relPath,
793
+ }),
794
+ );
795
+ const embeddings = await embedChunks(chunks);
796
+ const vecState = getSqliteVecState();
797
+ const retrievalConfig = memoryConfig.retrieval;
798
+ const sqliteVecWriteEnabled = retrievalConfig.vectorBackend === "sqlite_vec" && vecState.available;
799
+ const vecRows = chunks
800
+ .map((chunk, index) => ({ chunkId: chunk.id, embedding: embeddings[index] ?? [] }))
801
+ .filter(row => row.embedding.length > 0);
802
+ const vecDims = vecRows[0]?.embedding.length ?? 0;
803
+ if (sqliteVecWriteEnabled && vecDims > 0) {
804
+ ensureVecTableForDimensions(vecDims);
805
+ }
806
+ const updatedAt = nowMs();
807
+
808
+ const tx = sqlite.transaction(() => {
809
+ const existingChunkRows = sqlite
810
+ .query(
811
+ `
812
+ SELECT id
813
+ FROM memory_chunks
814
+ WHERE path = ?1
815
+ `,
816
+ )
817
+ .all(relPath) as Array<{ id: string }>;
818
+ if (sqliteVecWriteEnabled && existingChunkRows.length) {
819
+ deleteVecRowsByChunkIds(existingChunkRows.map(row => row.id));
820
+ }
821
+
822
+ sqlite.query("DELETE FROM memory_chunks_fts WHERE path = ?1").run(relPath);
823
+ sqlite.query("DELETE FROM memory_chunks WHERE path = ?1").run(relPath);
824
+ sqlite.query("DELETE FROM memory_records WHERE path = ?1").run(relPath);
825
+
826
+ const insertChunk = sqlite.query(`
827
+ INSERT INTO memory_chunks (
828
+ id, path, source, start_line, end_line, hash, text, embedding_json, updated_at
829
+ )
830
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
831
+ `);
832
+ const insertFts = sqlite.query(`
833
+ INSERT INTO memory_chunks_fts (text, chunk_id, path, start_line, end_line, updated_at)
834
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)
835
+ `);
836
+
837
+ for (let index = 0; index < chunks.length; index += 1) {
838
+ const chunk = chunks[index];
839
+ if (!chunk) continue;
840
+ const embedding = embeddings[index] ?? [];
841
+ insertChunk.run(
842
+ chunk.id,
843
+ relPath,
844
+ SOURCE,
845
+ chunk.startLine,
846
+ chunk.endLine,
847
+ chunk.hash,
848
+ chunk.text,
849
+ embedding.length ? JSON.stringify(embedding) : null,
850
+ updatedAt,
851
+ );
852
+ insertFts.run(chunk.text, chunk.id, relPath, chunk.startLine, chunk.endLine, updatedAt);
853
+ }
854
+ if (sqliteVecWriteEnabled) {
855
+ upsertVecRows(vecRows);
856
+ }
857
+
858
+ const records = parseMemoryRecords(content);
859
+ const insertRecord = sqlite.query(`
860
+ INSERT INTO memory_records (
861
+ id, path, source, content, entities_json, confidence, supersedes_json, superseded_by, recorded_at, updated_at
862
+ )
863
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8, ?9)
864
+ ON CONFLICT(id) DO UPDATE SET
865
+ path = excluded.path,
866
+ source = excluded.source,
867
+ content = excluded.content,
868
+ entities_json = excluded.entities_json,
869
+ confidence = excluded.confidence,
870
+ supersedes_json = excluded.supersedes_json,
871
+ recorded_at = excluded.recorded_at,
872
+ updated_at = excluded.updated_at
873
+ `);
874
+ const markSuperseded = sqlite.query(`
875
+ UPDATE memory_records
876
+ SET superseded_by = ?2, updated_at = ?3
877
+ WHERE id = ?1
878
+ `);
879
+
880
+ for (const record of records) {
881
+ const recordedMs = Number.isFinite(Date.parse(record.recordedAt))
882
+ ? Date.parse(record.recordedAt)
883
+ : updatedAt;
884
+ insertRecord.run(
885
+ record.id,
886
+ relPath,
887
+ record.source,
888
+ record.content,
889
+ JSON.stringify(record.entities ?? []),
890
+ typeof record.confidence === "number" ? record.confidence : 0.75,
891
+ JSON.stringify(record.supersedes ?? []),
892
+ recordedMs,
893
+ updatedAt,
894
+ );
895
+ }
896
+
897
+ for (const record of records) {
898
+ for (const supersededId of record.supersedes ?? []) {
899
+ markSuperseded.run(supersededId, record.id, updatedAt);
900
+ }
901
+ }
902
+
903
+ sqlite
904
+ .query(
905
+ `
906
+ INSERT INTO memory_files (path, source, hash, mtime, size, indexed_at)
907
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)
908
+ ON CONFLICT(path) DO UPDATE SET
909
+ source = excluded.source,
910
+ hash = excluded.hash,
911
+ mtime = excluded.mtime,
912
+ size = excluded.size,
913
+ indexed_at = excluded.indexed_at
914
+ `,
915
+ )
916
+ .run(relPath, SOURCE, fileHash, Math.floor(fileStat.mtimeMs), fileStat.size, updatedAt);
917
+ });
918
+
919
+ tx();
920
+ return true;
921
+ }
922
+
923
+ async function pruneStaleFiles(activePaths: Set<string>) {
924
+ const staleRows = sqlite.query("SELECT path FROM memory_files").all() as Array<{ path: string }>;
925
+ const tx = sqlite.transaction(() => {
926
+ for (const stale of staleRows) {
927
+ if (activePaths.has(stale.path)) continue;
928
+ const chunkRows = sqlite
929
+ .query(
930
+ `
931
+ SELECT id
932
+ FROM memory_chunks
933
+ WHERE path = ?1
934
+ `,
935
+ )
936
+ .all(stale.path) as Array<{ id: string }>;
937
+ deleteVecRowsByChunkIds(chunkRows.map(row => row.id));
938
+ sqlite.query("DELETE FROM memory_files WHERE path = ?1").run(stale.path);
939
+ sqlite.query("DELETE FROM memory_chunks WHERE path = ?1").run(stale.path);
940
+ sqlite.query("DELETE FROM memory_chunks_fts WHERE path = ?1").run(stale.path);
941
+ sqlite.query("DELETE FROM memory_records WHERE path = ?1").run(stale.path);
942
+ }
943
+ });
944
+ tx();
945
+ }
946
+
947
+ async function runSync(force = false) {
948
+ const memoryConfig = currentMemoryConfig();
949
+ await ensureWorkspaceScaffold();
950
+ await ensureSchema();
951
+ const priorMeta = memoryIndexMeta();
952
+ const embeddingModelChanged =
953
+ priorMeta?.provider !== memoryConfig.embedProvider || priorMeta?.model !== memoryConfig.embedModel;
954
+ const effectiveForce = force || embeddingModelChanged;
955
+
956
+ if (effectiveForce && vecTableExists()) {
957
+ sqlite.exec(`DELETE FROM ${MEMORY_VEC_TABLE}`);
958
+ }
959
+
960
+ const files = await listMemoryFiles();
961
+ const activePaths = new Set<string>();
962
+ for (const filePath of files) {
963
+ const relPath = normalizeRelPath(filePath);
964
+ if (!isMemoryPath(relPath)) continue;
965
+ activePaths.add(relPath);
966
+ await indexMemoryFile(filePath, { force: effectiveForce });
967
+ }
968
+ await pruneStaleFiles(activePaths);
969
+ const indexedAt = nowMs();
970
+ const vecState = getSqliteVecState();
971
+ const vecDims = vecTableDimensions();
972
+ sqlite
973
+ .query(
974
+ `
975
+ INSERT INTO memory_meta (key, value_json)
976
+ VALUES (?1, ?2)
977
+ ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json
978
+ `,
979
+ )
980
+ .run(
981
+ MEMORY_META_KEY,
982
+ JSON.stringify({
983
+ provider: memoryConfig.embedProvider,
984
+ model: memoryConfig.embedModel,
985
+ chunkTokens: memoryConfig.chunkTokens,
986
+ chunkOverlap: memoryConfig.chunkOverlap,
987
+ indexedAt,
988
+ }),
989
+ );
990
+ const vecRowCount = vecTableExists()
991
+ ? ((sqlite.query(`SELECT COUNT(*) as count FROM ${MEMORY_VEC_TABLE}`).get() as { count: number }).count ?? 0)
992
+ : 0;
993
+ sqlite
994
+ .query(
995
+ `
996
+ INSERT INTO memory_meta (key, value_json)
997
+ VALUES (?1, ?2)
998
+ ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json
999
+ `,
1000
+ )
1001
+ .run(
1002
+ MEMORY_VEC_META_KEY,
1003
+ JSON.stringify({
1004
+ enabled: memoryConfig.retrieval.vectorBackend === "sqlite_vec",
1005
+ provider: memoryConfig.embedProvider,
1006
+ model: memoryConfig.embedModel,
1007
+ dims: vecDims,
1008
+ count: vecRowCount,
1009
+ error: vecState.error,
1010
+ updatedAt: indexedAt,
1011
+ }),
1012
+ );
1013
+ lastSyncMs = indexedAt;
1014
+ }
1015
+
1016
+ export async function syncMemoryIndex(options?: { force?: boolean }) {
1017
+ if (!currentMemoryConfig().enabled) {
1018
+ return;
1019
+ }
1020
+ if (syncPromise) {
1021
+ await syncPromise;
1022
+ return;
1023
+ }
1024
+ syncPromise = runSync(Boolean(options?.force)).finally(() => {
1025
+ syncPromise = null;
1026
+ });
1027
+ await syncPromise;
1028
+ }
1029
+
1030
+ async function ensureFreshIndex() {
1031
+ const needsSync = lastSyncMs === 0 || nowMs() - lastSyncMs > currentMemoryConfig().syncCooldownMs;
1032
+ if (needsSync) {
1033
+ await syncMemoryIndex();
1034
+ }
1035
+ }
1036
+
1037
+ async function findDuplicateRecordId(input: MemoryRecordInput) {
1038
+ await ensureSchema();
1039
+ const row = sqlite
1040
+ .query(
1041
+ `
1042
+ SELECT id
1043
+ FROM memory_records
1044
+ WHERE content = ?1
1045
+ AND superseded_by IS NULL
1046
+ ORDER BY recorded_at DESC
1047
+ LIMIT 1
1048
+ `,
1049
+ )
1050
+ .get(input.content) as { id: string } | null;
1051
+ return row?.id;
1052
+ }
1053
+
1054
+ export async function validateMemoryRememberInput(input: MemoryRememberInput): Promise<MemoryWriteValidation> {
1055
+ const normalized = normalizeRecordInput(input);
1056
+ const content = normalized.content;
1057
+ const confidence = typeof normalized.confidence === "number" ? normalized.confidence : 0.75;
1058
+
1059
+ if (!content) {
1060
+ return {
1061
+ accepted: false,
1062
+ reason: "content is required",
1063
+ normalizedContent: content,
1064
+ normalizedConfidence: confidence,
1065
+ };
1066
+ }
1067
+
1068
+ const duplicateRecordId = await findDuplicateRecordId(normalized);
1069
+ if (duplicateRecordId) {
1070
+ return {
1071
+ accepted: false,
1072
+ reason: "duplicate memory already exists",
1073
+ normalizedContent: content,
1074
+ normalizedConfidence: confidence,
1075
+ duplicateRecordId,
1076
+ };
1077
+ }
1078
+
1079
+ return {
1080
+ accepted: true,
1081
+ reason: "accepted",
1082
+ normalizedContent: content,
1083
+ normalizedConfidence: confidence,
1084
+ };
1085
+ }
1086
+
1087
+ async function logMemoryWriteEvent(input: {
1088
+ status: "accepted" | "rejected";
1089
+ reason: string;
1090
+ source: MemoryRecordInput["source"];
1091
+ content: string;
1092
+ confidence: number;
1093
+ sessionId?: string;
1094
+ topic?: string;
1095
+ recordId?: string;
1096
+ path?: string;
1097
+ }) {
1098
+ await ensureSchema();
1099
+ const createdAt = nowMs();
1100
+ sqlite
1101
+ .query(
1102
+ `
1103
+ INSERT INTO memory_write_events (
1104
+ id, status, reason, source, content, confidence, session_id, topic, record_id, path, created_at
1105
+ )
1106
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
1107
+ `,
1108
+ )
1109
+ .run(
1110
+ crypto.randomUUID(),
1111
+ input.status,
1112
+ input.reason,
1113
+ input.source,
1114
+ input.content,
1115
+ input.confidence,
1116
+ input.sessionId ?? null,
1117
+ input.topic ?? null,
1118
+ input.recordId ?? null,
1119
+ input.path ?? null,
1120
+ createdAt,
1121
+ );
1122
+ }
1123
+
1124
+ export async function listMemoryWriteEvents(limit = 20): Promise<MemoryWriteEvent[]> {
1125
+ if (!currentMemoryConfig().enabled) {
1126
+ return [];
1127
+ }
1128
+ await ensureSchema();
1129
+ const normalizedLimit = Number.isFinite(limit) ? Math.floor(limit) : 20;
1130
+ const safeLimit = Math.max(1, Math.min(100, normalizedLimit));
1131
+ const rows = sqlite
1132
+ .query(
1133
+ `
1134
+ SELECT id, status, reason, source, content, confidence, session_id, topic, record_id, path, created_at
1135
+ FROM memory_write_events
1136
+ ORDER BY created_at DESC
1137
+ LIMIT ?1
1138
+ `,
1139
+ )
1140
+ .all(safeLimit) as MemoryWriteEventRow[];
1141
+ return rows.map(row => ({
1142
+ id: row.id,
1143
+ status: row.status,
1144
+ reason: row.reason,
1145
+ source: row.source as MemoryRecordInput["source"],
1146
+ content: row.content,
1147
+ confidence: row.confidence,
1148
+ sessionId: row.session_id,
1149
+ topic: row.topic,
1150
+ recordId: row.record_id,
1151
+ path: row.path,
1152
+ createdAt: new Date(row.created_at).toISOString(),
1153
+ }));
1154
+ }
1155
+
1156
+ export async function lintMemory(): Promise<MemoryLintReport> {
1157
+ if (!currentMemoryConfig().enabled) {
1158
+ return {
1159
+ ok: true,
1160
+ totalRecords: 0,
1161
+ duplicateActiveRecords: [],
1162
+ danglingSupersedes: [],
1163
+ };
1164
+ }
1165
+
1166
+ await ensureSchema();
1167
+ const allRows = sqlite
1168
+ .query(
1169
+ `
1170
+ SELECT id, content, supersedes_json, superseded_by
1171
+ FROM memory_records
1172
+ `,
1173
+ )
1174
+ .all() as Array<{
1175
+ id: string;
1176
+ content: string;
1177
+ supersedes_json: string;
1178
+ superseded_by: string | null;
1179
+ }>;
1180
+ const idSet = new Set(allRows.map(row => row.id));
1181
+
1182
+ const activeRows = allRows.filter(row => !row.superseded_by);
1183
+ const duplicateKeyMap = new Map<string, { content: string; ids: string[] }>();
1184
+ for (const row of activeRows) {
1185
+ const key = row.content;
1186
+ const existing = duplicateKeyMap.get(key);
1187
+ if (existing) {
1188
+ existing.ids.push(row.id);
1189
+ } else {
1190
+ duplicateKeyMap.set(key, {
1191
+ content: row.content,
1192
+ ids: [row.id],
1193
+ });
1194
+ }
1195
+ }
1196
+
1197
+ const duplicateActiveRecords = [...duplicateKeyMap.values()]
1198
+ .filter(entry => entry.ids.length > 1)
1199
+ .map(entry => ({
1200
+ content: clipSnippet(entry.content, 240),
1201
+ count: entry.ids.length,
1202
+ recordIds: entry.ids,
1203
+ }));
1204
+
1205
+ const danglingSupersedes = allRows
1206
+ .map(row => {
1207
+ let supersedes: string[] = [];
1208
+ try {
1209
+ const parsed = JSON.parse(row.supersedes_json) as unknown;
1210
+ if (Array.isArray(parsed)) {
1211
+ supersedes = parsed.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
1212
+ }
1213
+ } catch {
1214
+ supersedes = [];
1215
+ }
1216
+ const missing = supersedes.filter(id => !idSet.has(id));
1217
+ if (!missing.length) return null;
1218
+ return {
1219
+ recordId: row.id,
1220
+ missingSupersedes: missing,
1221
+ };
1222
+ })
1223
+ .filter((item): item is { recordId: string; missingSupersedes: string[] } => Boolean(item));
1224
+
1225
+ return {
1226
+ ok: duplicateActiveRecords.length === 0 && danglingSupersedes.length === 0,
1227
+ totalRecords: allRows.length,
1228
+ duplicateActiveRecords,
1229
+ danglingSupersedes,
1230
+ };
1231
+ }
1232
+
1233
+ export async function rememberMemory(
1234
+ input: MemoryRememberInput,
1235
+ options?: { validateOnly?: boolean },
1236
+ ): Promise<MemoryRememberResult> {
1237
+ if (!currentMemoryConfig().enabled) {
1238
+ throw new Error("Memory is disabled.");
1239
+ }
1240
+
1241
+ const normalized = normalizeRecordInput(input);
1242
+ const validation = await validateMemoryRememberInput(input);
1243
+
1244
+ if (!validation.accepted) {
1245
+ await logMemoryWriteEvent({
1246
+ status: "rejected",
1247
+ reason: validation.reason,
1248
+ source: normalized.source,
1249
+ content: validation.normalizedContent,
1250
+ confidence: validation.normalizedConfidence,
1251
+ sessionId: input.sessionId,
1252
+ topic: input.topic,
1253
+ });
1254
+ return {
1255
+ accepted: false,
1256
+ reason: validation.reason,
1257
+ validation,
1258
+ };
1259
+ }
1260
+
1261
+ if (options?.validateOnly) {
1262
+ return {
1263
+ accepted: true,
1264
+ reason: validation.reason,
1265
+ validation,
1266
+ };
1267
+ }
1268
+
1269
+ const persisted = await appendStructuredMemory({
1270
+ source: normalized.source,
1271
+ content: validation.normalizedContent,
1272
+ entities: normalized.entities,
1273
+ confidence: validation.normalizedConfidence,
1274
+ supersedes: normalized.supersedes,
1275
+ });
1276
+
1277
+ await logMemoryWriteEvent({
1278
+ status: "accepted",
1279
+ reason: validation.reason,
1280
+ source: normalized.source,
1281
+ content: validation.normalizedContent,
1282
+ confidence: validation.normalizedConfidence,
1283
+ sessionId: input.sessionId,
1284
+ topic: input.topic,
1285
+ recordId: persisted.record.id,
1286
+ path: persisted.path,
1287
+ });
1288
+
1289
+ return {
1290
+ accepted: true,
1291
+ reason: validation.reason,
1292
+ validation,
1293
+ record: persisted.record,
1294
+ path: persisted.path,
1295
+ };
1296
+ }
1297
+
1298
+ function parseUpdatedAtFromMeta(): number | null {
1299
+ if (!sqliteTableExists("memory_meta")) return null;
1300
+ const row = sqlite
1301
+ .query("SELECT value_json FROM memory_meta WHERE key = ?1")
1302
+ .get(MEMORY_META_KEY) as { value_json: string } | null;
1303
+ if (!row?.value_json) return null;
1304
+ try {
1305
+ const parsed = JSON.parse(row.value_json) as { indexedAt?: number };
1306
+ return typeof parsed.indexedAt === "number" ? parsed.indexedAt : null;
1307
+ } catch {
1308
+ return null;
1309
+ }
1310
+ }
1311
+
1312
+ function selectRecentChunkIds(limit: number): string[] {
1313
+ if (limit <= 0) return [];
1314
+ const rows = sqlite
1315
+ .query(
1316
+ `
1317
+ SELECT id
1318
+ FROM memory_chunks
1319
+ ORDER BY updated_at DESC
1320
+ LIMIT ?1
1321
+ `,
1322
+ )
1323
+ .all(limit) as Array<{ id: string }>;
1324
+ return rows.map(row => row.id);
1325
+ }
1326
+
1327
+ function resolveVectorBackend() {
1328
+ const memoryConfig = currentMemoryConfig();
1329
+ const configured = memoryConfig.retrieval.vectorBackend;
1330
+ const fallback = memoryConfig.retrieval.vectorUnavailableFallback;
1331
+ const vecState = getSqliteVecState();
1332
+ if (configured === "disabled") {
1333
+ return {
1334
+ configured,
1335
+ active: "disabled" as const,
1336
+ available: false,
1337
+ error: null as string | null,
1338
+ };
1339
+ }
1340
+ if (configured === "legacy_json") {
1341
+ return {
1342
+ configured,
1343
+ active: "legacy_json" as const,
1344
+ available: true,
1345
+ error: null as string | null,
1346
+ };
1347
+ }
1348
+ if (vecState.available) {
1349
+ return {
1350
+ configured,
1351
+ active: "sqlite_vec" as const,
1352
+ available: true,
1353
+ error: null as string | null,
1354
+ };
1355
+ }
1356
+ if (fallback === "legacy_json") {
1357
+ return {
1358
+ configured,
1359
+ active: "legacy_json" as const,
1360
+ available: false,
1361
+ error: vecState.error,
1362
+ };
1363
+ }
1364
+ return {
1365
+ configured,
1366
+ active: "disabled" as const,
1367
+ available: false,
1368
+ error: vecState.error,
1369
+ };
1370
+ }
1371
+
1372
+ function searchVectorCandidates(
1373
+ queryVector: number[],
1374
+ candidateLimit: number,
1375
+ seedChunkIds: string[] = [],
1376
+ options?: { exhaustive?: boolean },
1377
+ ) {
1378
+ if (!queryVector.length) return new Map<string, number>();
1379
+ const backend = resolveVectorBackend();
1380
+ const memoryConfig = currentMemoryConfig();
1381
+ if (backend.active === "disabled") return new Map<string, number>();
1382
+ const hasVecTable = vecTableExists();
1383
+ const useLegacyFallback =
1384
+ backend.active === "sqlite_vec" && !hasVecTable && memoryConfig.retrieval.vectorUnavailableFallback === "legacy_json";
1385
+ if (backend.active === "sqlite_vec" && hasVecTable) {
1386
+ const probeLimit = Math.max(1, memoryConfig.retrieval.vectorProbeLimit);
1387
+ const configuredK = Math.max(1, memoryConfig.retrieval.vectorK);
1388
+ const queryK = Math.max(1, Math.max(candidateLimit, probeLimit, configuredK));
1389
+ const rows = sqlite
1390
+ .query(
1391
+ `
1392
+ SELECT chunk_id, distance
1393
+ FROM ${MEMORY_VEC_TABLE}
1394
+ WHERE embedding MATCH ?1
1395
+ AND k = ?2
1396
+ `,
1397
+ )
1398
+ .all(new Float32Array(queryVector), queryK) as Array<{ chunk_id: string; distance: number }>;
1399
+ if (!rows.length) return new Map<string, number>();
1400
+ const scored = rows
1401
+ .map(row => ({
1402
+ id: row.chunk_id,
1403
+ score: Math.max(0, 1 / (1 + Math.max(0, Number(row.distance ?? 0)))),
1404
+ }))
1405
+ .filter(row => Number.isFinite(row.score) && row.score > 0)
1406
+ .sort((a, b) => b.score - a.score)
1407
+ .slice(0, candidateLimit);
1408
+ return new Map(scored.map(row => [row.id, row.score]));
1409
+ }
1410
+ if (backend.active === "sqlite_vec" && !useLegacyFallback) {
1411
+ return new Map<string, number>();
1412
+ }
1413
+
1414
+ const exhaustive = options?.exhaustive === true;
1415
+ const prefilterLimit = Math.max(VECTOR_PREFILTER_MIN, Math.min(VECTOR_PREFILTER_MAX, candidateLimit * 12));
1416
+ const prefilterIds = exhaustive
1417
+ ? []
1418
+ : [...new Set([...seedChunkIds, ...selectRecentChunkIds(prefilterLimit)])].slice(0, SQLITE_IN_BIND_LIMIT);
1419
+
1420
+ const rows = exhaustive
1421
+ ? (sqlite
1422
+ .query(
1423
+ `
1424
+ SELECT id, embedding_json
1425
+ FROM memory_chunks
1426
+ WHERE embedding_json IS NOT NULL
1427
+ `,
1428
+ )
1429
+ .all() as Array<{ id: string; embedding_json: string }>)
1430
+ : prefilterIds.length
1431
+ ? (sqlite
1432
+ .query(
1433
+ `
1434
+ SELECT id, embedding_json
1435
+ FROM memory_chunks
1436
+ WHERE embedding_json IS NOT NULL
1437
+ AND id IN (${prefilterIds.map(() => "?").join(", ")})
1438
+ `,
1439
+ )
1440
+ .all(...prefilterIds) as Array<{ id: string; embedding_json: string }>)
1441
+ : [];
1442
+ if (!rows.length) return new Map<string, number>();
1443
+
1444
+ const scored = rows
1445
+ .map(row => ({
1446
+ id: row.id,
1447
+ score: cosineSimilarity(queryVector, parseEmbedding(row.embedding_json)),
1448
+ }))
1449
+ .filter(row => Number.isFinite(row.score) && row.score > 0)
1450
+ .sort((a, b) => b.score - a.score)
1451
+ .slice(0, candidateLimit);
1452
+ return new Map(scored.map(row => [row.id, row.score]));
1453
+ }
1454
+
1455
+ function toRankedResults(scores: Map<string, number>, limit: number) {
1456
+ return [...scores.entries()]
1457
+ .map(([id, score]) => ({ id, score }))
1458
+ .sort((a, b) => b.score - a.score)
1459
+ .slice(0, limit);
1460
+ }
1461
+
1462
+ function searchTextCandidates(query: string, candidateLimit: number) {
1463
+ const ftsQuery = buildFtsQuery(query);
1464
+ if (!ftsQuery) return new Map<string, number>();
1465
+ const rows = sqlite
1466
+ .query(
1467
+ `
1468
+ SELECT chunk_id, path, start_line, end_line, text, bm25(memory_chunks_fts) AS rank
1469
+ FROM memory_chunks_fts
1470
+ WHERE memory_chunks_fts MATCH ?1
1471
+ ORDER BY rank ASC
1472
+ LIMIT ?2
1473
+ `,
1474
+ )
1475
+ .all(ftsQuery, candidateLimit) as MemoryFtsRow[];
1476
+ const scores = new Map<string, number>();
1477
+ for (const row of rows) {
1478
+ const rank = Number.isFinite(row.rank) ? Math.abs(row.rank) : 1;
1479
+ scores.set(row.chunk_id, 1 / (1 + rank));
1480
+ }
1481
+ return scores;
1482
+ }
1483
+
1484
+ function applyRecordStateAdjustments(candidates: SearchCandidate[]) {
1485
+ const recordIds = [...new Set(candidates.map(candidate => candidate.recordId).filter((id): id is string => Boolean(id)))];
1486
+ if (!recordIds.length) return;
1487
+ const placeholders = recordIds.map(() => "?").join(", ");
1488
+ const rows = sqlite
1489
+ .query(
1490
+ `
1491
+ SELECT id, confidence, superseded_by
1492
+ FROM memory_records
1493
+ WHERE id IN (${placeholders})
1494
+ `,
1495
+ )
1496
+ .all(...recordIds) as MemoryRecordRow[];
1497
+ const rowById = new Map(rows.map(row => [row.id, row]));
1498
+ for (const candidate of candidates) {
1499
+ if (!candidate.recordId) continue;
1500
+ const row = rowById.get(candidate.recordId);
1501
+ if (!row) continue;
1502
+ const confidence = Math.max(0, Math.min(1, Number.isFinite(row.confidence) ? row.confidence : 0.75));
1503
+ candidate.score *= 0.85 + 0.3 * confidence;
1504
+ if (row.superseded_by) {
1505
+ candidate.score = Math.max(0, candidate.score - 0.25);
1506
+ }
1507
+ }
1508
+ }
1509
+
1510
+ function applyRecencyBoost(candidates: SearchCandidate[]) {
1511
+ const current = nowMs();
1512
+ for (const candidate of candidates) {
1513
+ const ageDays = Math.max(0, (current - candidate.updatedAt) / (24 * 60 * 60 * 1000));
1514
+ const recency = 1 / (1 + ageDays / 14);
1515
+ candidate.score += recency * 0.08;
1516
+ }
1517
+ }
1518
+
1519
+ function mmrRerank(candidates: SearchCandidate[], maxResults: number) {
1520
+ if (candidates.length <= maxResults) {
1521
+ return [...candidates].sort((a, b) => b.score - a.score);
1522
+ }
1523
+
1524
+ const selected: SearchCandidate[] = [];
1525
+ const pool = [...candidates];
1526
+
1527
+ while (selected.length < maxResults && pool.length) {
1528
+ let bestIndex = 0;
1529
+ let bestScore = Number.NEGATIVE_INFINITY;
1530
+
1531
+ for (let index = 0; index < pool.length; index += 1) {
1532
+ const candidate = pool[index];
1533
+ if (!candidate) continue;
1534
+ const relevance = candidate.score;
1535
+ let maxSimilarity = 0;
1536
+ if (candidate.embedding?.length) {
1537
+ for (const picked of selected) {
1538
+ if (!picked.embedding?.length) continue;
1539
+ maxSimilarity = Math.max(maxSimilarity, cosineSimilarity(candidate.embedding, picked.embedding));
1540
+ }
1541
+ }
1542
+ const mmrScore = MMR_LAMBDA * relevance - (1 - MMR_LAMBDA) * maxSimilarity;
1543
+ if (mmrScore > bestScore) {
1544
+ bestScore = mmrScore;
1545
+ bestIndex = index;
1546
+ }
1547
+ }
1548
+
1549
+ const [picked] = pool.splice(bestIndex, 1);
1550
+ if (!picked) break;
1551
+ selected.push(picked);
1552
+ }
1553
+
1554
+ return selected;
1555
+ }
1556
+
1557
+ function mapCandidatesToResults(candidates: SearchCandidate[]): MemorySearchResult[] {
1558
+ return candidates.map(candidate => ({
1559
+ id: candidate.id,
1560
+ path: candidate.path,
1561
+ startLine: candidate.startLine,
1562
+ endLine: candidate.endLine,
1563
+ source: "memory",
1564
+ score: Number(candidate.score.toFixed(4)),
1565
+ snippet: normalizeSnippetFromChunk(candidate.text),
1566
+ citation: `${candidate.path}#L${candidate.startLine}`,
1567
+ }));
1568
+ }
1569
+
1570
+ interface MemorySearchDebug {
1571
+ engine: "legacy" | "qmd_hybrid";
1572
+ strongSignalSkippedExpansion: boolean;
1573
+ matchedConceptPacks: string[];
1574
+ semanticRescues: number;
1575
+ expansionQueries: Array<{ type: string; text: string }>;
1576
+ rankedLists: Array<{ name: string; count: number }>;
1577
+ }
1578
+
1579
+ function applyFinalFiltering(
1580
+ candidates: SearchCandidate[],
1581
+ normalizedQuery: string,
1582
+ minScore: number,
1583
+ maxResults: number,
1584
+ options?: {
1585
+ expandedTokens?: string[];
1586
+ semanticRescueEnabled?: boolean;
1587
+ semanticRescueMinVectorScore?: number;
1588
+ semanticRescueMaxResults?: number;
1589
+ },
1590
+ ) {
1591
+ const queryTokens = collectQueryTokens(normalizedQuery);
1592
+ const expandedTokens = new Set((options?.expandedTokens ?? []).map(token => token.toLowerCase()));
1593
+ const allowedTokens = new Set<string>([...queryTokens, ...expandedTokens]);
1594
+ const recallIntent = isMemoryRecallIntentQuery(normalizedQuery);
1595
+ const memoryManagementQuery = isMemoryManagementQuery(normalizedQuery);
1596
+ const semanticRescueEnabled = options?.semanticRescueEnabled === true;
1597
+ const semanticRescueMinVectorScore = Math.max(0, Math.min(1, options?.semanticRescueMinVectorScore ?? 0));
1598
+ const semanticRescueMaxResults = Math.max(0, options?.semanticRescueMaxResults ?? 0);
1599
+
1600
+ const baseFiltered = candidates
1601
+ .filter(candidate => {
1602
+ if (!recallIntent && !memoryManagementQuery && isLikelyBoilerplateIndexCandidate(candidate)) {
1603
+ return false;
1604
+ }
1605
+ return candidate.score >= minScore;
1606
+ })
1607
+ .sort((a, b) => b.score - a.score);
1608
+
1609
+ if (recallIntent) {
1610
+ return { candidates: mmrRerank(baseFiltered, maxResults), semanticRescues: 0 };
1611
+ }
1612
+
1613
+ const lexical = baseFiltered.filter(candidate => hasLexicalSignal(candidate, allowedTokens));
1614
+ if (!semanticRescueEnabled || semanticRescueMaxResults <= 0) {
1615
+ return { candidates: mmrRerank(lexical, maxResults), semanticRescues: 0 };
1616
+ }
1617
+
1618
+ const lexicalIds = new Set(lexical.map(candidate => candidate.id));
1619
+ const rescued = baseFiltered
1620
+ .filter(candidate => !lexicalIds.has(candidate.id))
1621
+ .filter(candidate => candidate.vectorScore >= semanticRescueMinVectorScore)
1622
+ .sort((a, b) => b.vectorScore - a.vectorScore)
1623
+ .slice(0, semanticRescueMaxResults);
1624
+ const merged = [...lexical, ...rescued];
1625
+ return {
1626
+ candidates: mmrRerank(merged, maxResults),
1627
+ semanticRescues: rescued.length,
1628
+ };
1629
+ }
1630
+
1631
+ function loadSearchCandidates(
1632
+ candidateIds: string[],
1633
+ textScores: Map<string, number>,
1634
+ vectorScores: Map<string, number>,
1635
+ baseScoreById?: Map<string, number>,
1636
+ ) {
1637
+ if (!candidateIds.length) return [] as SearchCandidate[];
1638
+ const placeholders = candidateIds.map(() => "?").join(", ");
1639
+ const chunkRows = sqlite
1640
+ .query(
1641
+ `
1642
+ SELECT id, path, start_line, end_line, text, embedding_json, updated_at
1643
+ FROM memory_chunks
1644
+ WHERE id IN (${placeholders})
1645
+ `,
1646
+ )
1647
+ .all(...candidateIds) as MemoryChunkRow[];
1648
+
1649
+ return chunkRows.map(row => {
1650
+ const vectorScore = vectorScores.get(row.id) ?? 0;
1651
+ const textScore = textScores.get(row.id) ?? 0;
1652
+ const fallbackHybrid = 0.72 * vectorScore + 0.28 * textScore;
1653
+ const embedding = parseEmbedding(row.embedding_json);
1654
+ return {
1655
+ id: row.id,
1656
+ path: row.path,
1657
+ startLine: row.start_line,
1658
+ endLine: row.end_line,
1659
+ text: row.text,
1660
+ embedding: embedding.length ? embedding : null,
1661
+ vectorScore,
1662
+ textScore,
1663
+ score: baseScoreById?.get(row.id) ?? fallbackHybrid,
1664
+ updatedAt: row.updated_at,
1665
+ recordId: extractRecordIdFromChunk(row.text),
1666
+ };
1667
+ });
1668
+ }
1669
+
1670
+ function runLegacySearch(
1671
+ normalizedQuery: string,
1672
+ maxResults: number,
1673
+ minScore: number,
1674
+ ): Promise<{ results: MemorySearchResult[]; debug: MemorySearchDebug }> {
1675
+ const candidateLimit = Math.max(maxResults * 6, DEFAULT_CANDIDATE_LIMIT);
1676
+ return (async () => {
1677
+ let queryVector: number[] = [];
1678
+ try {
1679
+ const vectors = await embedTexts([normalizedQuery]);
1680
+ queryVector = vectors[0] ?? [];
1681
+ } catch {
1682
+ queryVector = [];
1683
+ }
1684
+ const textScores = searchTextCandidates(normalizedQuery, candidateLimit);
1685
+ const vectorScores = searchVectorCandidates(queryVector, candidateLimit, [...textScores.keys()]);
1686
+ const candidateIds = [...new Set([...vectorScores.keys(), ...textScores.keys()])];
1687
+ const candidates = loadSearchCandidates(candidateIds, textScores, vectorScores);
1688
+ applyRecencyBoost(candidates);
1689
+ applyRecordStateAdjustments(candidates);
1690
+ const filtered = applyFinalFiltering(candidates, normalizedQuery, minScore, maxResults, {
1691
+ semanticRescueEnabled: false,
1692
+ });
1693
+ return {
1694
+ results: mapCandidatesToResults(filtered.candidates),
1695
+ debug: {
1696
+ engine: "legacy",
1697
+ strongSignalSkippedExpansion: false,
1698
+ matchedConceptPacks: [],
1699
+ semanticRescues: 0,
1700
+ expansionQueries: [] as Array<{ type: string; text: string }>,
1701
+ rankedLists: [] as Array<{ name: string; count: number }>,
1702
+ },
1703
+ };
1704
+ })();
1705
+ }
1706
+
1707
+ function rerankScoreFromOverlap(candidate: SearchCandidate, queryTokens: Set<string>) {
1708
+ if (!queryTokens.size) return candidate.score;
1709
+ const candidateTokens = collectQueryTokens(candidate.text);
1710
+ let overlap = 0;
1711
+ for (const token of queryTokens) {
1712
+ if (candidateTokens.has(token)) overlap += 1;
1713
+ }
1714
+ const overlapScore = overlap / Math.max(1, queryTokens.size);
1715
+ return Math.max(overlapScore, candidate.textScore, candidate.vectorScore);
1716
+ }
1717
+
1718
+ async function runQmdHybridSearch(
1719
+ normalizedQuery: string,
1720
+ maxResults: number,
1721
+ minScore: number,
1722
+ ): Promise<{ results: MemorySearchResult[]; debug: MemorySearchDebug }> {
1723
+ const memoryConfig = currentMemoryConfig();
1724
+ const retrieval = memoryConfig.retrieval;
1725
+ const candidateLimit = Math.max(maxResults * 6, retrieval.candidateLimit);
1726
+
1727
+ const initialFtsScores = searchTextCandidates(normalizedQuery, 20);
1728
+ const initialFts = toRankedResults(initialFtsScores, 20);
1729
+ const hasStrongSignal = hasStrongBm25Signal(initialFts, {
1730
+ minScore: retrieval.strongSignalMinScore,
1731
+ minGap: retrieval.strongSignalMinGap,
1732
+ });
1733
+
1734
+ const conceptExpansion = hasStrongSignal
1735
+ ? { expandedQueries: [], expandedTokens: [], matchedConceptPacks: [] }
1736
+ : buildConceptExpandedQueries(normalizedQuery, {
1737
+ enabled: retrieval.expansionEnabled && retrieval.conceptExpansionEnabled,
1738
+ maxPacks: retrieval.conceptExpansionMaxPacks,
1739
+ maxTerms: retrieval.conceptExpansionMaxTerms,
1740
+ });
1741
+ const expandedQueries = conceptExpansion.expandedQueries;
1742
+ const rankedLists: Array<{ name: string; list: Array<{ id: string; score: number }> }> = [];
1743
+ const textScoreById = new Map(initialFtsScores);
1744
+ const vectorScoreById = new Map<string, number>();
1745
+
1746
+ if (initialFts.length) {
1747
+ rankedLists.push({ name: "fts:original", list: initialFts });
1748
+ }
1749
+
1750
+ const vectorQueries = [{ type: "vec", text: normalizedQuery }, ...expandedQueries.filter(q => q.type !== "lex")];
1751
+ const vectorTexts = vectorQueries.map(entry => entry.text);
1752
+ let queryVectors: number[][] = [];
1753
+ try {
1754
+ queryVectors = vectorTexts.length ? await embedTexts(vectorTexts) : [];
1755
+ } catch {
1756
+ queryVectors = [];
1757
+ }
1758
+
1759
+ const exhaustiveVector = initialFts.length === 0;
1760
+ for (let index = 0; index < vectorQueries.length; index += 1) {
1761
+ const queryEntry = vectorQueries[index];
1762
+ const vector = queryVectors[index] ?? [];
1763
+ const scores = searchVectorCandidates(vector, 20, [...initialFtsScores.keys()], { exhaustive: exhaustiveVector });
1764
+ if (!scores.size) continue;
1765
+ for (const [id, score] of scores.entries()) {
1766
+ const existing = vectorScoreById.get(id) ?? 0;
1767
+ if (score > existing) vectorScoreById.set(id, score);
1768
+ }
1769
+ rankedLists.push({
1770
+ name: `${queryEntry?.type ?? "vec"}:${index === 0 ? "original" : "expanded"}`,
1771
+ list: toRankedResults(scores, 20),
1772
+ });
1773
+ }
1774
+
1775
+ for (const expansion of expandedQueries) {
1776
+ if (expansion.type !== "lex") continue;
1777
+ const lexScores = searchTextCandidates(expansion.text, 20);
1778
+ if (!lexScores.size) continue;
1779
+ for (const [id, score] of lexScores.entries()) {
1780
+ const existing = textScoreById.get(id) ?? 0;
1781
+ if (score > existing) textScoreById.set(id, score);
1782
+ }
1783
+ rankedLists.push({
1784
+ name: "fts:lex-expanded",
1785
+ list: toRankedResults(lexScores, 20),
1786
+ });
1787
+ }
1788
+
1789
+ const fused = reciprocalRankFusion(
1790
+ rankedLists.map(item => item.list),
1791
+ rankedLists.map((_, index) => (index < 2 ? 2 : 1)),
1792
+ retrieval.rrfK,
1793
+ );
1794
+
1795
+ const candidateIds = fused.slice(0, candidateLimit).map(entry => entry.id);
1796
+ const baseScoreById = new Map(fused.map(entry => [entry.id, entry.score]));
1797
+ const candidates = loadSearchCandidates(candidateIds, textScoreById, vectorScoreById, baseScoreById);
1798
+ applyRecencyBoost(candidates);
1799
+ applyRecordStateAdjustments(candidates);
1800
+
1801
+ if (retrieval.rerankEnabled && candidates.length) {
1802
+ const queryTokens = collectQueryTokens(normalizedQuery);
1803
+ const rankById = new Map(candidateIds.map((id, index) => [id, index + 1]));
1804
+ const rerankLimit = Math.max(1, Math.min(candidates.length, retrieval.rerankTopN));
1805
+ const sorted = [...candidates].sort((a, b) => b.score - a.score);
1806
+ for (let index = 0; index < rerankLimit; index += 1) {
1807
+ const candidate = sorted[index];
1808
+ if (!candidate) continue;
1809
+ const rerankScore = rerankScoreFromOverlap(candidate, queryTokens);
1810
+ const rank = rankById.get(candidate.id) ?? index + 1;
1811
+ candidate.score = blendRrfAndRerank(rank, rerankScore);
1812
+ }
1813
+ }
1814
+
1815
+ const filtered = applyFinalFiltering(candidates, normalizedQuery, minScore, maxResults, {
1816
+ expandedTokens: conceptExpansion.expandedTokens,
1817
+ semanticRescueEnabled: retrieval.semanticRescueEnabled,
1818
+ semanticRescueMinVectorScore: retrieval.semanticRescueMinVectorScore,
1819
+ semanticRescueMaxResults: retrieval.semanticRescueMaxResults,
1820
+ });
1821
+ return {
1822
+ results: mapCandidatesToResults(filtered.candidates),
1823
+ debug: {
1824
+ engine: "qmd_hybrid",
1825
+ strongSignalSkippedExpansion: hasStrongSignal,
1826
+ matchedConceptPacks: conceptExpansion.matchedConceptPacks,
1827
+ semanticRescues: filtered.semanticRescues,
1828
+ expansionQueries: expandedQueries,
1829
+ rankedLists: rankedLists.map(item => ({ name: item.name, count: item.list.length })),
1830
+ },
1831
+ };
1832
+ }
1833
+
1834
+ export async function searchMemoryDetailed(
1835
+ query: string,
1836
+ options?: { maxResults?: number; minScore?: number },
1837
+ ): Promise<{ results: MemorySearchResult[]; debug: MemorySearchDebug }> {
1838
+ const memoryConfig = currentMemoryConfig();
1839
+ if (!memoryConfig.enabled) {
1840
+ return {
1841
+ results: [] as MemorySearchResult[],
1842
+ debug: {
1843
+ engine: memoryConfig.retrieval.engine,
1844
+ strongSignalSkippedExpansion: false,
1845
+ matchedConceptPacks: [],
1846
+ semanticRescues: 0,
1847
+ expansionQueries: [],
1848
+ rankedLists: [],
1849
+ },
1850
+ };
1851
+ }
1852
+ const normalizedQuery = query.trim();
1853
+ if (!normalizedQuery) {
1854
+ return {
1855
+ results: [] as MemorySearchResult[],
1856
+ debug: {
1857
+ engine: memoryConfig.retrieval.engine,
1858
+ strongSignalSkippedExpansion: false,
1859
+ matchedConceptPacks: [],
1860
+ semanticRescues: 0,
1861
+ expansionQueries: [],
1862
+ rankedLists: [],
1863
+ },
1864
+ };
1865
+ }
1866
+
1867
+ await ensureFreshIndex();
1868
+
1869
+ const maxResults = Math.max(1, options?.maxResults ?? memoryConfig.maxResults);
1870
+ const minScore = Math.max(0, Math.min(1, options?.minScore ?? memoryConfig.minScore));
1871
+ if (memoryConfig.retrieval.engine === "legacy") {
1872
+ return runLegacySearch(normalizedQuery, maxResults, minScore);
1873
+ }
1874
+ return runQmdHybridSearch(normalizedQuery, maxResults, minScore);
1875
+ }
1876
+
1877
+ export async function searchMemory(query: string, options?: { maxResults?: number; minScore?: number }) {
1878
+ const detailed = await searchMemoryDetailed(query, options);
1879
+ return detailed.results;
1880
+ }
1881
+
1882
+ export async function readMemoryFileSlice(input: { relPath: string; from?: number; lines?: number }) {
1883
+ if (!currentMemoryConfig().enabled) {
1884
+ throw new Error("Memory is disabled.");
1885
+ }
1886
+
1887
+ const relPath = input.relPath.trim();
1888
+ if (!isMemoryPath(relPath)) {
1889
+ throw new Error("Invalid memory path.");
1890
+ }
1891
+
1892
+ const workspaceDir = resolveWorkspaceDir();
1893
+ const absPath = path.resolve(workspaceDir, relPath);
1894
+ if (normalizeRelPath(absPath) !== relPath.replaceAll("\\", "/")) {
1895
+ throw new Error("Invalid memory path.");
1896
+ }
1897
+
1898
+ const content = await readFile(absPath, "utf8");
1899
+ if (!input.from && !input.lines) {
1900
+ return { path: relPath, text: content };
1901
+ }
1902
+
1903
+ const from = Math.max(1, input.from ?? 1);
1904
+ const lines = Math.max(1, input.lines ?? 120);
1905
+ const slice = content.split("\n").slice(from - 1, from - 1 + lines).join("\n");
1906
+ return { path: relPath, text: slice };
1907
+ }
1908
+
1909
+ async function appendStructuredMemory(input: MemoryRecordInput): Promise<{
1910
+ record: MemoryRecord;
1911
+ path: string;
1912
+ }> {
1913
+ if (!currentMemoryConfig().enabled) {
1914
+ throw new Error("Memory is disabled.");
1915
+ }
1916
+ const normalized = normalizeRecordInput(input);
1917
+ if (!normalized.content) {
1918
+ throw new Error("Memory content is required.");
1919
+ }
1920
+
1921
+ await ensureWorkspaceScaffold();
1922
+ await ensureSchema();
1923
+
1924
+ const record = createMemoryRecord(normalized);
1925
+ const dayStamp = record.recordedAt.slice(0, 10);
1926
+ const relPath = `memory/${dayStamp}.md`;
1927
+ const absPath = path.join(resolveWorkspaceDir(), relPath);
1928
+ await mkdir(path.dirname(absPath), { recursive: true });
1929
+
1930
+ let previous = "";
1931
+ try {
1932
+ previous = await readFile(absPath, "utf8");
1933
+ } catch {
1934
+ previous = "";
1935
+ }
1936
+
1937
+ const block = formatMemoryRecord(record);
1938
+ const separator = previous.trim().length ? "\n" : "";
1939
+ await writeFile(absPath, `${previous}${separator}${block}`, "utf8");
1940
+
1941
+ await indexMemoryFile(absPath, { force: true });
1942
+ lastSyncMs = nowMs();
1943
+
1944
+ return { record, path: relPath };
1945
+ }
1946
+
1947
+ export async function getMemoryStatus(): Promise<MemoryStatus> {
1948
+ const memoryConfig = currentMemoryConfig();
1949
+ if (!memoryConfig.enabled) {
1950
+ const vectorBackend = resolveVectorBackend();
1951
+ return {
1952
+ enabled: false,
1953
+ workspaceDir: resolveWorkspaceDir(),
1954
+ provider: memoryConfig.embedProvider,
1955
+ model: memoryConfig.embedModel,
1956
+ toolMode: memoryConfig.toolMode,
1957
+ vectorBackendConfigured: memoryConfig.retrieval.vectorBackend,
1958
+ vectorBackendActive: vectorBackend.active,
1959
+ vectorAvailable: vectorBackend.available,
1960
+ vectorDims: null,
1961
+ vectorIndexedChunks: 0,
1962
+ vectorLastError: vectorBackend.error,
1963
+ files: 0,
1964
+ chunks: 0,
1965
+ records: 0,
1966
+ cacheEntries: 0,
1967
+ indexedAt: null,
1968
+ };
1969
+ }
1970
+
1971
+ await ensureSchema();
1972
+ const vectorBackend = resolveVectorBackend();
1973
+ const vecMeta = memoryVecMeta();
1974
+ const vecDims = vecTableDimensions();
1975
+ const vecCount = vecTableExists()
1976
+ ? ((sqlite.query(`SELECT COUNT(*) as count FROM ${MEMORY_VEC_TABLE}`).get() as { count: number }).count ?? 0)
1977
+ : 0;
1978
+ const files = sqlite.query("SELECT COUNT(*) as count FROM memory_files").get() as { count: number };
1979
+ const chunks = sqlite.query("SELECT COUNT(*) as count FROM memory_chunks").get() as { count: number };
1980
+ const records = sqlite.query("SELECT COUNT(*) as count FROM memory_records").get() as { count: number };
1981
+ const cache = sqlite.query("SELECT COUNT(*) as count FROM memory_embedding_cache").get() as {
1982
+ count: number;
1983
+ };
1984
+ const indexedAt = parseUpdatedAtFromMeta();
1985
+
1986
+ return {
1987
+ enabled: true,
1988
+ workspaceDir: resolveWorkspaceDir(),
1989
+ provider: memoryConfig.embedProvider,
1990
+ model: memoryConfig.embedModel,
1991
+ toolMode: memoryConfig.toolMode,
1992
+ vectorBackendConfigured: memoryConfig.retrieval.vectorBackend,
1993
+ vectorBackendActive: vectorBackend.active,
1994
+ vectorAvailable: vectorBackend.available,
1995
+ vectorDims: vecDims ?? vecMeta?.dims ?? null,
1996
+ vectorIndexedChunks: vecCount,
1997
+ vectorLastError: vectorBackend.error ?? vecMeta?.error ?? null,
1998
+ files: files.count,
1999
+ chunks: chunks.count,
2000
+ records: records.count,
2001
+ cacheEntries: cache.count,
2002
+ indexedAt: indexedAt ? new Date(indexedAt).toISOString() : null,
2003
+ };
2004
+ }
2005
+
2006
+ export async function initializeMemory() {
2007
+ if (!currentMemoryConfig().enabled) {
2008
+ return;
2009
+ }
2010
+ await ensureWorkspaceScaffold();
2011
+ await ensureSchema();
2012
+ }