recallnest 1.4.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 (261) hide show
  1. package/.claude-plugin/CLAUDE.md +29 -0
  2. package/.claude-plugin/marketplace.json +18 -0
  3. package/.env.example +3 -0
  4. package/.github/workflows/ci.yml +28 -0
  5. package/.mcp.json +9 -0
  6. package/CHANGELOG.md +51 -0
  7. package/CLAUDE.md +42 -0
  8. package/DEMO.md +94 -0
  9. package/EVAL.md +89 -0
  10. package/FAILURES.md +75 -0
  11. package/LICENSE +21 -0
  12. package/OPERATIONS.md +59 -0
  13. package/README.md +427 -0
  14. package/README_CN.md +425 -0
  15. package/ROADMAP.md +198 -0
  16. package/assets/recallnest-workbench.png +0 -0
  17. package/assets/ui/app.js +807 -0
  18. package/assets/ui/index.html +154 -0
  19. package/assets/ui/styles.css +605 -0
  20. package/assets/wechat-qr.jpg +0 -0
  21. package/config.json.example +38 -0
  22. package/daily-recall.sh +87 -0
  23. package/daily-reflections/2026-03-05.md +15 -0
  24. package/daily-reflections/2026-03-06.md +15 -0
  25. package/daily-reflections/2026-03-07.md +7 -0
  26. package/daily-reflections/2026-03-08.md +34 -0
  27. package/daily-reflections/2026-03-09.md +35 -0
  28. package/daily-reflections/2026-03-10.md +36 -0
  29. package/daily-reflections/2026-03-11.md +35 -0
  30. package/daily-reflections/2026-03-12.md +36 -0
  31. package/daily-reflections/2026-03-13.md +35 -0
  32. package/daily-reflections/2026-03-14.md +35 -0
  33. package/daily-reflections/2026-03-15.md +35 -0
  34. package/daily-reflections/2026-03-16.md +41 -0
  35. package/daily-reflections/2026-03-17.md +10 -0
  36. package/daily-reflections/2026-03-18.md +10 -0
  37. package/daily-reflections/2026-03-19.md +10 -0
  38. package/daily-reflections/2026-03-20.md +10 -0
  39. package/daily-reflections/2026-03-21.md +10 -0
  40. package/daily-reflections/2026-03-22.md +10 -0
  41. package/daily-reflections/2026-03-23.md +10 -0
  42. package/daily-reflections/2026-03-24.md +10 -0
  43. package/daily-reflections/2026-03-25.md +32 -0
  44. package/daily-reflections/2026-03-26.md +37 -0
  45. package/daily-reflections/2026-03-27.md +35 -0
  46. package/daily-reflections/2026-03-28.md +35 -0
  47. package/daily-reflections/2026-03-29.md +35 -0
  48. package/daily-reflections/2026-03-30.md +36 -0
  49. package/daily-reflections/2026-03-31.md +35 -0
  50. package/daily-reflections/2026-04-01.md +35 -0
  51. package/docs/api-reference.md +1291 -0
  52. package/docs/architecture.md +168 -0
  53. package/docs/memory-boundary-contract.md +226 -0
  54. package/docs/memory-categories.md +119 -0
  55. package/docs/pipeline-audit-ultramemory.md +177 -0
  56. package/docs/plans/2026-03-08-query-expansion.md +270 -0
  57. package/docs/plans/2026-03-16-continuity-layer.md +196 -0
  58. package/docs/plans/mcp-tiering-plan.md +126 -0
  59. package/docs/research/recall-quality-audit-2026-03-22.md +153 -0
  60. package/docs/self-evolution.md +198 -0
  61. package/docs/superpowers/plans/2026-03-19-ingest-pipeline-upgrade.md +782 -0
  62. package/docs/superpowers/specs/2026-03-19-ingest-pipeline-upgrade-design.md +77 -0
  63. package/docs/superpowers/specs/2026-03-23-pull-claude-desktop-design.md +95 -0
  64. package/eval/cases.json +85 -0
  65. package/eval/continuity/case-seeds.json +90 -0
  66. package/eval/continuity/cases.json +302 -0
  67. package/eval/continuity/memory-seeds.json +26 -0
  68. package/eval/continuity/pattern-seeds.json +100 -0
  69. package/eval/continuity/results-2026-03-16.md +117 -0
  70. package/eval/continuity/results-2026-03-19.md +453 -0
  71. package/eval/continuity/runbook.md +110 -0
  72. package/eval/continuity/seed-cases.md +54 -0
  73. package/eval/reports/2026-03-06-abstract-query-baseline.md +96 -0
  74. package/eval/reports/2026-03-06-aws-ssh-baseline.md +82 -0
  75. package/eval/reports/2026-03-06-baseline.md +82 -0
  76. package/eval/reports/query-expansion.md +138 -0
  77. package/integrations/README.md +57 -0
  78. package/integrations/claude-code/README.md +133 -0
  79. package/integrations/claude-code/claude-md-snippet.md +31 -0
  80. package/integrations/claude-code/setup.sh +66 -0
  81. package/integrations/claude-code/smoke-continuity.sh +452 -0
  82. package/integrations/codex/README.md +49 -0
  83. package/integrations/codex/agents-md-snippet.md +31 -0
  84. package/integrations/codex/setup.sh +58 -0
  85. package/integrations/examples/claude-agent-sdk/memory-agent.ts +182 -0
  86. package/integrations/examples/langchain/memory-chain.py +111 -0
  87. package/integrations/examples/openai-agents-sdk/memory-agent.py +109 -0
  88. package/integrations/gemini-cli/README.md +57 -0
  89. package/integrations/gemini-cli/gemini-md-snippet.md +31 -0
  90. package/integrations/gemini-cli/setup.sh +64 -0
  91. package/integrations/shared/install-managed-block.sh +51 -0
  92. package/lm +19 -0
  93. package/package.json +50 -0
  94. package/plan-v1.2-distributable.md +105 -0
  95. package/scripts/archive-distilled-junk.ts +116 -0
  96. package/scripts/backfill-l0l1l2.ts +98 -0
  97. package/scripts/blind-scan.ts +588 -0
  98. package/scripts/cleanup-step1-exact-dups.ts +117 -0
  99. package/scripts/cleanup-step2-template-dups.ts +113 -0
  100. package/scripts/cleanup-step3-stale-content.ts +149 -0
  101. package/scripts/cleanup-step4-orphan-scopes.ts +131 -0
  102. package/scripts/delete-distilled-junk.ts +88 -0
  103. package/scripts/distill-facts.ts +305 -0
  104. package/scripts/health-check.ts +343 -0
  105. package/scripts/incremental-ingest.sh +71 -0
  106. package/scripts/mlp-health-check.ts +382 -0
  107. package/scripts/pull-claude-desktop.ts +515 -0
  108. package/scripts/re-ingest-cc.sh +66 -0
  109. package/scripts/rebalance-memory-health.ts +181 -0
  110. package/scripts/run-desktop-ingest.sh +11 -0
  111. package/scripts/start-server.sh +12 -0
  112. package/scripts/viz-dashboard.html +423 -0
  113. package/scripts/viz-export.ts +703 -0
  114. package/src/__tests__/access-tracker-novelty.test.ts +228 -0
  115. package/src/__tests__/auto-consolidation.test.ts +168 -0
  116. package/src/__tests__/auto-recall.test.ts +131 -0
  117. package/src/__tests__/batch-dedup.test.ts +65 -0
  118. package/src/__tests__/capture-engine.test.ts +1142 -0
  119. package/src/__tests__/capture-heuristic.test.ts +171 -0
  120. package/src/__tests__/cascade-forget.test.ts +210 -0
  121. package/src/__tests__/category-min-scores.test.ts +33 -0
  122. package/src/__tests__/confidence-tracker.test.ts +196 -0
  123. package/src/__tests__/conflict-advisor.test.ts +206 -0
  124. package/src/__tests__/conflict-escalation.test.ts +149 -0
  125. package/src/__tests__/conflict-lifecycle.test.ts +83 -0
  126. package/src/__tests__/conflict-output.test.ts +85 -0
  127. package/src/__tests__/conflict-store.test.ts +374 -0
  128. package/src/__tests__/consolidation-engine.test.ts +181 -0
  129. package/src/__tests__/context-composer-pins.test.ts +64 -0
  130. package/src/__tests__/context-composer-task-selection.test.ts +24 -0
  131. package/src/__tests__/context-renderer.test.ts +91 -0
  132. package/src/__tests__/continuity-eval.test.ts +275 -0
  133. package/src/__tests__/continuity-schema.test.ts +97 -0
  134. package/src/__tests__/core-recall-eval.test.ts +235 -0
  135. package/src/__tests__/core-summary.test.ts +109 -0
  136. package/src/__tests__/dedup-actions.test.ts +58 -0
  137. package/src/__tests__/deterministic-id.test.ts +39 -0
  138. package/src/__tests__/doctor.test.ts +303 -0
  139. package/src/__tests__/drill-down.test.ts +108 -0
  140. package/src/__tests__/embedder.test.ts +122 -0
  141. package/src/__tests__/entity-resolver.test.ts +58 -0
  142. package/src/__tests__/event-segmenter.test.ts +153 -0
  143. package/src/__tests__/hotness-blend.test.ts +76 -0
  144. package/src/__tests__/ingest.test.ts +360 -0
  145. package/src/__tests__/kg-extractor.test.ts +184 -0
  146. package/src/__tests__/kg-store.test.ts +198 -0
  147. package/src/__tests__/llm-consolidation.test.ts +226 -0
  148. package/src/__tests__/memory-boundaries.test.ts +237 -0
  149. package/src/__tests__/memory-health-rebalance.test.ts +144 -0
  150. package/src/__tests__/memory-output.test.ts +215 -0
  151. package/src/__tests__/multi-vector.test.ts +242 -0
  152. package/src/__tests__/ppr-traversal.test.ts +256 -0
  153. package/src/__tests__/preference-guard-smoke.test.ts +211 -0
  154. package/src/__tests__/preference-matcher.test.ts +230 -0
  155. package/src/__tests__/preference-slots.test.ts +106 -0
  156. package/src/__tests__/prospective-memory.test.ts +283 -0
  157. package/src/__tests__/query-entity-detector.test.ts +171 -0
  158. package/src/__tests__/query-expander.test.ts +34 -0
  159. package/src/__tests__/recall-mode.test.ts +64 -0
  160. package/src/__tests__/result-synthesizer.test.ts +198 -0
  161. package/src/__tests__/resume-context.test.ts +4743 -0
  162. package/src/__tests__/retrieval-trace.test.ts +213 -0
  163. package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
  164. package/src/__tests__/retroactive-boost.test.ts +223 -0
  165. package/src/__tests__/scope-inventory.test.ts +200 -0
  166. package/src/__tests__/scope-policy.test.ts +69 -0
  167. package/src/__tests__/session-checkpoint.test.ts +259 -0
  168. package/src/__tests__/session-gc.test.ts +215 -0
  169. package/src/__tests__/store-scope-audit.test.ts +81 -0
  170. package/src/__tests__/temporal-parser.test.ts +241 -0
  171. package/src/__tests__/term-registry.test.ts +121 -0
  172. package/src/__tests__/tool-output-compressor.test.ts +133 -0
  173. package/src/__tests__/version-manager.test.ts +213 -0
  174. package/src/__tests__/workflow-observation.test.ts +400 -0
  175. package/src/access-tracker.ts +316 -0
  176. package/src/adaptive-retrieval.ts +64 -0
  177. package/src/api-server.ts +667 -0
  178. package/src/asset-sync.ts +79 -0
  179. package/src/auto-consolidation.ts +133 -0
  180. package/src/auto-gc.ts +124 -0
  181. package/src/auto-recall.ts +95 -0
  182. package/src/capture-engine.ts +931 -0
  183. package/src/capture-heuristic.ts +177 -0
  184. package/src/cascade-forget.ts +135 -0
  185. package/src/chunker.ts +253 -0
  186. package/src/cli.ts +1917 -0
  187. package/src/confidence-tracker.ts +166 -0
  188. package/src/conflict-advisor.ts +433 -0
  189. package/src/conflict-engine.ts +181 -0
  190. package/src/conflict-escalation.ts +138 -0
  191. package/src/conflict-lifecycle.ts +67 -0
  192. package/src/conflict-output.ts +218 -0
  193. package/src/conflict-schema.ts +130 -0
  194. package/src/conflict-store.ts +127 -0
  195. package/src/consolidation-engine.ts +256 -0
  196. package/src/context-composer-pins.ts +183 -0
  197. package/src/context-composer-queries.ts +121 -0
  198. package/src/context-composer-scope.ts +56 -0
  199. package/src/context-composer-stable-selection.ts +200 -0
  200. package/src/context-composer-stable.ts +217 -0
  201. package/src/context-composer-task-fallbacks.ts +36 -0
  202. package/src/context-composer-task-ranking.ts +197 -0
  203. package/src/context-composer-task-results.ts +137 -0
  204. package/src/context-composer-task-selection.ts +342 -0
  205. package/src/context-composer-text.ts +57 -0
  206. package/src/context-composer.ts +300 -0
  207. package/src/context-renderer.ts +179 -0
  208. package/src/decay-engine.ts +189 -0
  209. package/src/doctor.ts +543 -0
  210. package/src/embedder.ts +557 -0
  211. package/src/entity-resolver.ts +131 -0
  212. package/src/eval.ts +632 -0
  213. package/src/event-segmenter.ts +216 -0
  214. package/src/ingest.ts +1822 -0
  215. package/src/kg-extractor.ts +217 -0
  216. package/src/kg-store.ts +353 -0
  217. package/src/legacy-scope-review.ts +53 -0
  218. package/src/llm-client.ts +629 -0
  219. package/src/llm-consolidation.ts +168 -0
  220. package/src/mcp-server.ts +1337 -0
  221. package/src/memory-assets.ts +475 -0
  222. package/src/memory-boundaries.ts +354 -0
  223. package/src/memory-health-rebalance.ts +220 -0
  224. package/src/memory-output.ts +474 -0
  225. package/src/memory-schema.ts +184 -0
  226. package/src/multi-vector.ts +234 -0
  227. package/src/noise-filter.ts +155 -0
  228. package/src/ppr-traversal.ts +279 -0
  229. package/src/preference-matcher.ts +174 -0
  230. package/src/preference-slots.ts +259 -0
  231. package/src/prospective-memory.ts +198 -0
  232. package/src/query-entity-detector.ts +207 -0
  233. package/src/query-expander.ts +98 -0
  234. package/src/result-synthesizer.ts +114 -0
  235. package/src/retrieval-profiles.ts +101 -0
  236. package/src/retrieval-trace.ts +94 -0
  237. package/src/retriever.ts +1338 -0
  238. package/src/retroactive-boost.ts +154 -0
  239. package/src/rif.ts +73 -0
  240. package/src/runtime-config.ts +172 -0
  241. package/src/schema-utils.ts +56 -0
  242. package/src/scope-inventory.ts +310 -0
  243. package/src/scope-policy.ts +142 -0
  244. package/src/session-engine.ts +168 -0
  245. package/src/session-output.ts +96 -0
  246. package/src/session-schema.ts +60 -0
  247. package/src/session-store.ts +293 -0
  248. package/src/stderr-log.ts +16 -0
  249. package/src/store.ts +903 -0
  250. package/src/temporal-parser.ts +318 -0
  251. package/src/term-registry.ts +791 -0
  252. package/src/tool-output-compressor.ts +115 -0
  253. package/src/tracker.ts +57 -0
  254. package/src/ui-server.ts +430 -0
  255. package/src/version-manager.ts +172 -0
  256. package/src/workflow-observation-engine.ts +316 -0
  257. package/src/workflow-observation-managed.ts +83 -0
  258. package/src/workflow-observation-output.ts +86 -0
  259. package/src/workflow-observation-schema.ts +87 -0
  260. package/src/workflow-observation-scope-review.ts +409 -0
  261. package/src/workflow-observation-store.ts +69 -0
package/src/store.ts ADDED
@@ -0,0 +1,903 @@
1
+ /**
2
+ * LanceDB Storage Layer with Multi-Scope Support
3
+ */
4
+
5
+ import type * as LanceDB from "@lancedb/lancedb";
6
+ import { createHash, randomUUID } from "node:crypto";
7
+ import { existsSync, accessSync, constants, mkdirSync, realpathSync, lstatSync } from "node:fs";
8
+ import { dirname } from "node:path";
9
+ import { logWarn } from "./stderr-log.js";
10
+ import type { DurableMemoryCategory } from "./memory-schema.js";
11
+ import { matchesScopeFilter } from "./scope-policy.js";
12
+
13
+ // ============================================================================
14
+ // Types
15
+ // ============================================================================
16
+
17
+ /**
18
+ * Memory categories (v1.1 six-category system, inspired by OpenViking).
19
+ *
20
+ * User Memory (4):
21
+ * profile — 用户身份/背景(静态,合并优先)
22
+ * preferences — 偏好/倾向(合并优先)
23
+ * entities — 持续存在的名词:项目/工具/人物(合并优先)
24
+ * events — 发生过的事(追加,不合并)
25
+ *
26
+ * Agent Memory (2):
27
+ * cases — 问题→解决方案对(追加,不合并)
28
+ * patterns — 可复用的流程/模式(合并优先)
29
+ *
30
+ * Legacy categories kept for backward compatibility.
31
+ */
32
+ export type MemoryCategory =
33
+ | DurableMemoryCategory
34
+ | "preference" | "fact" | "decision" | "entity" | "other";
35
+
36
+ export interface MemoryEntry {
37
+ id: string;
38
+ text: string;
39
+ vector: number[];
40
+ category: MemoryCategory;
41
+ scope: string;
42
+ importance: number;
43
+ timestamp: number;
44
+ metadata?: string; // JSON string for extensible metadata — includes l0_abstract/l1_overview/l2_content/tier
45
+ }
46
+
47
+ export interface MemorySearchResult {
48
+ entry: MemoryEntry;
49
+ score: number;
50
+ }
51
+
52
+ export interface StoreConfig {
53
+ dbPath: string;
54
+ vectorDim: number;
55
+ }
56
+
57
+ export type LegacyScopeIssueKind = "missing" | "empty" | "global";
58
+
59
+ export interface LegacyScopeAuditSample {
60
+ id: string;
61
+ kind: LegacyScopeIssueKind;
62
+ scope: string | null;
63
+ category: MemoryEntry["category"];
64
+ timestamp: number;
65
+ text: string;
66
+ }
67
+
68
+ export interface LegacyScopeAudit {
69
+ totalCount: number;
70
+ counts: Record<LegacyScopeIssueKind, number>;
71
+ samples: LegacyScopeAuditSample[];
72
+ }
73
+
74
+ // ============================================================================
75
+ // Deterministic ID
76
+ // ============================================================================
77
+
78
+ const RECALLNEST_NS = "recallnest:v1";
79
+
80
+ /**
81
+ * Generate a deterministic UUID-formatted ID from scope + text.
82
+ * Same inputs always produce the same ID — prevents duplicate entries
83
+ * with different random UUIDs.
84
+ *
85
+ * Format: 8-4-4-4-12 hex (same shape as crypto.randomUUID).
86
+ */
87
+ export function deterministicId(scope: string, text: string): string {
88
+ const hash = createHash("sha256")
89
+ .update(`${RECALLNEST_NS}\0${scope}\0${text}`)
90
+ .digest("hex");
91
+ // Format as UUID: 8-4-4-4-12
92
+ return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-${hash.slice(12, 16)}-${hash.slice(16, 20)}-${hash.slice(20, 32)}`;
93
+ }
94
+
95
+ // ============================================================================
96
+ // LanceDB Dynamic Import
97
+ // ============================================================================
98
+
99
+ let lancedbImportPromise: Promise<typeof import("@lancedb/lancedb")> | null = null;
100
+
101
+ export const loadLanceDB = async (): Promise<typeof import("@lancedb/lancedb")> => {
102
+ if (!lancedbImportPromise) {
103
+ lancedbImportPromise = import("@lancedb/lancedb");
104
+ }
105
+ try {
106
+ return await lancedbImportPromise;
107
+ } catch (err) {
108
+ throw new Error(`memory-lancedb-pro: failed to load LanceDB. ${String(err)}`, { cause: err });
109
+ }
110
+ };
111
+
112
+ // ============================================================================
113
+ // Utility Functions
114
+ // ============================================================================
115
+
116
+ function clampInt(value: number, min: number, max: number): number {
117
+ if (!Number.isFinite(value)) return min;
118
+ return Math.min(max, Math.max(min, Math.floor(value)));
119
+ }
120
+
121
+ function escapeSqlLiteral(value: string): string {
122
+ return value.replace(/'/g, "''");
123
+ }
124
+
125
+ export function classifyLegacyScope(scope: unknown): LegacyScopeIssueKind | undefined {
126
+ if (scope == null) {
127
+ return "missing";
128
+ }
129
+ if (typeof scope !== "string") {
130
+ return "missing";
131
+ }
132
+ const normalized = scope.trim();
133
+ if (normalized.length === 0) {
134
+ return "empty";
135
+ }
136
+ if (normalized === "global") {
137
+ return "global";
138
+ }
139
+ return undefined;
140
+ }
141
+
142
+ // ============================================================================
143
+ // Storage Path Validation
144
+ // ============================================================================
145
+
146
+ /**
147
+ * Validate and prepare the storage directory before LanceDB connection.
148
+ * Resolves symlinks, creates missing directories, and checks write permissions.
149
+ * Returns the resolved absolute path on success, or throws a descriptive error.
150
+ */
151
+ export function validateStoragePath(dbPath: string): string {
152
+ let resolvedPath = dbPath;
153
+
154
+ // Resolve symlinks if the path already exists
155
+ try {
156
+ if (existsSync(dbPath)) {
157
+ const stats = lstatSync(dbPath);
158
+ if (stats.isSymbolicLink()) {
159
+ try {
160
+ resolvedPath = realpathSync(dbPath);
161
+ } catch (err: any) {
162
+ throw new Error(
163
+ `dbPath "${dbPath}" is a symlink whose target does not exist.\n` +
164
+ ` Fix: Create the target directory, or update the symlink to point to a valid path.\n` +
165
+ ` Details: ${err.code || ""} ${err.message}`
166
+ );
167
+ }
168
+ }
169
+ }
170
+ } catch (err: any) {
171
+ // Re-throw our own descriptive errors
172
+ if (err.message.includes("symlink")) throw err;
173
+ // Other lstat failures — continue with original path
174
+ }
175
+
176
+ // Create directory if it doesn't exist
177
+ if (!existsSync(resolvedPath)) {
178
+ try {
179
+ mkdirSync(resolvedPath, { recursive: true });
180
+ } catch (err: any) {
181
+ throw new Error(
182
+ `Failed to create dbPath directory "${resolvedPath}".\n` +
183
+ ` Fix: Ensure the parent directory "${dirname(resolvedPath)}" exists and is writable,\n` +
184
+ ` or create it manually: mkdir -p "${resolvedPath}"\n` +
185
+ ` Details: ${err.code || ""} ${err.message}`
186
+ );
187
+ }
188
+ }
189
+
190
+ // Check write permissions
191
+ try {
192
+ accessSync(resolvedPath, constants.W_OK);
193
+ } catch (err: any) {
194
+ throw new Error(
195
+ `dbPath directory "${resolvedPath}" is not writable.\n` +
196
+ ` Fix: Check permissions with: ls -la "${dirname(resolvedPath)}"\n` +
197
+ ` Or grant write access: chmod u+w "${resolvedPath}"\n` +
198
+ ` Details: ${err.code || ""} ${err.message}`
199
+ );
200
+ }
201
+
202
+ return resolvedPath;
203
+ }
204
+
205
+ // ============================================================================
206
+ // Memory Store
207
+ // ============================================================================
208
+
209
+ const TABLE_NAME = "memories";
210
+
211
+ export class MemoryStore {
212
+ private db: LanceDB.Connection | null = null;
213
+ private table: LanceDB.Table | null = null;
214
+ private initPromise: Promise<void> | null = null;
215
+ private ftsIndexCreated = false;
216
+
217
+ constructor(private readonly config: StoreConfig) {}
218
+
219
+ get dbPath(): string {
220
+ return this.config.dbPath;
221
+ }
222
+
223
+ private async ensureInitialized(): Promise<void> {
224
+ if (this.table) {
225
+ return;
226
+ }
227
+ if (this.initPromise) {
228
+ return this.initPromise;
229
+ }
230
+
231
+ this.initPromise = this.doInitialize().catch((err) => {
232
+ this.initPromise = null;
233
+ throw err;
234
+ });
235
+ return this.initPromise;
236
+ }
237
+
238
+ private async doInitialize(): Promise<void> {
239
+ const lancedb = await loadLanceDB();
240
+
241
+ let db: LanceDB.Connection;
242
+ try {
243
+ db = await lancedb.connect(this.config.dbPath);
244
+ } catch (err: any) {
245
+ const code = err.code || "";
246
+ const message = err.message || String(err);
247
+ throw new Error(
248
+ `Failed to open LanceDB at "${this.config.dbPath}": ${code} ${message}\n` +
249
+ ` Fix: Verify the path exists and is writable. Check parent directory permissions.`
250
+ );
251
+ }
252
+
253
+ let table: LanceDB.Table;
254
+
255
+ // Idempotent table init: try openTable first, create only if missing,
256
+ // and handle the race where tableNames() misses an existing table but
257
+ // createTable then sees it (LanceDB eventual consistency).
258
+ try {
259
+ table = await db.openTable(TABLE_NAME);
260
+
261
+ // Check if we need to add scope column for backward compatibility
262
+ try {
263
+ const sample = await table.query().limit(1).toArray();
264
+ if (sample.length > 0 && !("scope" in sample[0])) {
265
+ logWarn("Adding scope column for backward compatibility with existing data");
266
+ }
267
+ } catch (err) {
268
+ logWarn("Could not check table schema:", err);
269
+ }
270
+ } catch (_openErr) {
271
+ // Table doesn't exist yet — create it
272
+ const schemaEntry: MemoryEntry = {
273
+ id: "__schema__",
274
+ text: "",
275
+ vector: Array.from({ length: this.config.vectorDim }).fill(0) as number[],
276
+ category: "other",
277
+ scope: "__schema__",
278
+ importance: 0,
279
+ timestamp: 0,
280
+ metadata: "{}",
281
+ };
282
+
283
+ try {
284
+ table = await db.createTable(TABLE_NAME, [schemaEntry]);
285
+ await table.delete('id = "__schema__"');
286
+ } catch (createErr) {
287
+ // Race: another caller (or eventual consistency) created the table
288
+ // between our failed openTable and this createTable — just open it.
289
+ if (String(createErr).includes("already exists")) {
290
+ table = await db.openTable(TABLE_NAME);
291
+ } else {
292
+ throw createErr;
293
+ }
294
+ }
295
+ }
296
+
297
+ // Validate vector dimensions
298
+ // Note: LanceDB returns Arrow Vector objects, not plain JS arrays.
299
+ // Array.isArray() returns false for Arrow Vectors, so use .length instead.
300
+ const sample = await table.query().limit(1).toArray();
301
+ if (sample.length > 0 && sample[0]?.vector?.length) {
302
+ const existingDim = sample[0].vector.length;
303
+ if (existingDim !== this.config.vectorDim) {
304
+ throw new Error(
305
+ `Vector dimension mismatch: table=${existingDim}, config=${this.config.vectorDim}. Create a new table/dbPath or set matching embedding.dimensions.`
306
+ );
307
+ }
308
+ }
309
+
310
+ // Create FTS index for BM25 search (graceful fallback if unavailable)
311
+ try {
312
+ await this.createFtsIndex(table);
313
+ this.ftsIndexCreated = true;
314
+ } catch (err) {
315
+ logWarn("Failed to create FTS index, falling back to vector-only search:", err);
316
+ this.ftsIndexCreated = false;
317
+ }
318
+
319
+ this.db = db;
320
+ this.table = table;
321
+ }
322
+
323
+ private async createFtsIndex(table: LanceDB.Table): Promise<void> {
324
+ try {
325
+ // Check if FTS index already exists
326
+ const indices = await table.listIndices();
327
+ const hasFtsIndex = indices?.some((idx: any) =>
328
+ idx.indexType === "FTS" || idx.columns?.includes("text")
329
+ );
330
+
331
+ if (!hasFtsIndex) {
332
+ // LanceDB @lancedb/lancedb >=0.26: use Index.fts() config
333
+ const lancedb = await loadLanceDB();
334
+ await table.createIndex("text", {
335
+ config: (lancedb as any).Index.fts(),
336
+ });
337
+ }
338
+ } catch (err) {
339
+ throw new Error(`FTS index creation failed: ${err instanceof Error ? err.message : String(err)}`);
340
+ }
341
+ }
342
+
343
+ async store(entry: Omit<MemoryEntry, "id" | "timestamp"> & { id?: string }): Promise<MemoryEntry> {
344
+ await this.ensureInitialized();
345
+
346
+ const fullEntry: MemoryEntry = {
347
+ ...entry,
348
+ id: entry.id || deterministicId(entry.scope, entry.text),
349
+ timestamp: Date.now(),
350
+ metadata: entry.metadata || "{}",
351
+ };
352
+
353
+ try {
354
+ await this.table!.add([fullEntry]);
355
+ } catch (err: any) {
356
+ const code = err.code || "";
357
+ const message = err.message || String(err);
358
+ throw new Error(
359
+ `Failed to store memory in "${this.config.dbPath}": ${code} ${message}`
360
+ );
361
+ }
362
+ return fullEntry;
363
+ }
364
+
365
+ /**
366
+ * Batch store multiple entries at once — much faster than individual store() calls.
367
+ * LanceDB handles bulk inserts efficiently with a single index update.
368
+ */
369
+ async storeBatch(entries: Omit<MemoryEntry, "id" | "timestamp">[]): Promise<number> {
370
+ if (entries.length === 0) return 0;
371
+ await this.ensureInitialized();
372
+
373
+ const fullEntries: MemoryEntry[] = entries.map(entry => ({
374
+ ...entry,
375
+ id: deterministicId(entry.scope, entry.text),
376
+ timestamp: Date.now(),
377
+ metadata: entry.metadata || "{}",
378
+ }));
379
+
380
+ try {
381
+ await this.table!.add(fullEntries);
382
+ } catch (err: any) {
383
+ const code = err.code || "";
384
+ const message = err.message || String(err);
385
+ throw new Error(
386
+ `Failed to batch store ${entries.length} memories in "${this.config.dbPath}": ${code} ${message}`
387
+ );
388
+ }
389
+ return fullEntries.length;
390
+ }
391
+
392
+ /**
393
+ * Import a pre-built entry while preserving its id/timestamp.
394
+ * Used for re-embedding / migration / A/B testing across embedding models.
395
+ * Intentionally separate from `store()` to keep normal writes simple.
396
+ */
397
+ async importEntry(entry: MemoryEntry): Promise<MemoryEntry> {
398
+ await this.ensureInitialized();
399
+
400
+ if (!entry.id || typeof entry.id !== "string") {
401
+ throw new Error("importEntry requires a stable id");
402
+ }
403
+
404
+ const vector = entry.vector || [];
405
+ if (!Array.isArray(vector) || vector.length !== this.config.vectorDim) {
406
+ throw new Error(
407
+ `Vector dimension mismatch: expected ${this.config.vectorDim}, got ${Array.isArray(vector) ? vector.length : 'non-array'}`
408
+ );
409
+ }
410
+
411
+ const full: MemoryEntry = {
412
+ ...entry,
413
+ scope: entry.scope,
414
+ importance: Number.isFinite(entry.importance) ? entry.importance : 0.5,
415
+ timestamp: Number.isFinite(entry.timestamp) ? entry.timestamp : Date.now(),
416
+ metadata: entry.metadata || "{}",
417
+ };
418
+
419
+ await this.table!.add([full]);
420
+ return full;
421
+ }
422
+
423
+ async hasId(id: string): Promise<boolean> {
424
+ await this.ensureInitialized();
425
+ const safeId = escapeSqlLiteral(id);
426
+ const res = await this.table!.query().select(["id"]).where(`id = '${safeId}'`).limit(1).toArray();
427
+ return res.length > 0;
428
+ }
429
+
430
+ async vectorSearch(vector: number[], limit = 5, minScore = 0.3, scopeFilter?: string[]): Promise<MemorySearchResult[]> {
431
+ await this.ensureInitialized();
432
+
433
+ const safeLimit = clampInt(limit, 1, 20);
434
+ const fetchLimit = Math.min(safeLimit * 10, 200); // Over-fetch for scope filtering
435
+
436
+ let query = this.table!.vectorSearch(vector).distanceType('cosine').limit(fetchLimit);
437
+
438
+ // Apply scope filter if provided
439
+ // Support both exact match ("cc:abc123") and prefix match ("cc")
440
+ if (scopeFilter && scopeFilter.length > 0) {
441
+ const scopeConditions = scopeFilter
442
+ .map(scope => {
443
+ const safe = escapeSqlLiteral(scope);
444
+ // If scope contains ":", treat as exact match; otherwise prefix match
445
+ return scope.includes(":")
446
+ ? `scope = '${safe}'`
447
+ : `scope LIKE '${safe}%'`;
448
+ })
449
+ .join(" OR ");
450
+ query = query.where(`(${scopeConditions})`);
451
+ }
452
+
453
+ const results = await query.toArray();
454
+ const mapped: MemorySearchResult[] = [];
455
+
456
+ for (const row of results) {
457
+ const distance = Number(row._distance ?? 0);
458
+ const score = 1 / (1 + distance);
459
+
460
+ if (score < minScore) continue;
461
+
462
+ const rowScope = (row.scope as string | undefined) ?? "";
463
+
464
+ // Double-check scope filter in application layer (prefix-aware)
465
+ if (!matchesScopeFilter(rowScope, scopeFilter)) {
466
+ continue;
467
+ }
468
+
469
+ mapped.push({
470
+ entry: {
471
+ id: row.id as string,
472
+ text: row.text as string,
473
+ vector: row.vector as number[],
474
+ category: row.category as MemoryEntry["category"],
475
+ scope: rowScope,
476
+ importance: Number(row.importance),
477
+ timestamp: Number(row.timestamp),
478
+ metadata: (row.metadata as string) || "{}",
479
+ },
480
+ score,
481
+ });
482
+
483
+ if (mapped.length >= safeLimit) break;
484
+ }
485
+
486
+ return mapped;
487
+ }
488
+
489
+ async bm25Search(query: string, limit = 5, scopeFilter?: string[]): Promise<MemorySearchResult[]> {
490
+ await this.ensureInitialized();
491
+
492
+ if (!this.ftsIndexCreated) {
493
+ return []; // Fallback to vector-only if FTS unavailable
494
+ }
495
+
496
+ const safeLimit = clampInt(limit, 1, 20);
497
+
498
+ try {
499
+ // Use FTS query type explicitly
500
+ let searchQuery = this.table!.search(query, "fts").limit(safeLimit);
501
+
502
+ // Apply scope filter if provided (prefix-aware, same as vectorSearch)
503
+ if (scopeFilter && scopeFilter.length > 0) {
504
+ const scopeConditions = scopeFilter
505
+ .map(scope => {
506
+ const safe = escapeSqlLiteral(scope);
507
+ return scope.includes(":")
508
+ ? `scope = '${safe}'`
509
+ : `scope LIKE '${safe}%'`;
510
+ })
511
+ .join(" OR ");
512
+ searchQuery = searchQuery.where(`(${scopeConditions})`);
513
+ }
514
+
515
+ const results = await searchQuery.toArray();
516
+ const mapped: MemorySearchResult[] = [];
517
+
518
+ for (const row of results) {
519
+ const rowScope = (row.scope as string | undefined) ?? "";
520
+
521
+ // Double-check scope filter in application layer (prefix-aware)
522
+ if (!matchesScopeFilter(rowScope, scopeFilter)) {
523
+ continue;
524
+ }
525
+
526
+ // LanceDB FTS _score is raw BM25 (unbounded). Normalize with sigmoid.
527
+ // LanceDB may return BigInt for numeric columns; coerce safely.
528
+ const rawScore = (row._score != null) ? Number(row._score) : 0;
529
+ const normalizedScore = rawScore > 0 ? 1 / (1 + Math.exp(-rawScore / 5)) : 0.5;
530
+
531
+ mapped.push({
532
+ entry: {
533
+ id: row.id as string,
534
+ text: row.text as string,
535
+ vector: row.vector as number[],
536
+ category: row.category as MemoryEntry["category"],
537
+ scope: rowScope,
538
+ importance: Number(row.importance),
539
+ timestamp: Number(row.timestamp),
540
+ metadata: (row.metadata as string) || "{}",
541
+ },
542
+ score: normalizedScore,
543
+ });
544
+ }
545
+
546
+ return mapped;
547
+ } catch (err) {
548
+ logWarn("BM25 search failed, falling back to empty results:", err);
549
+ return [];
550
+ }
551
+ }
552
+
553
+ async delete(id: string, scopeFilter?: string[]): Promise<boolean> {
554
+ await this.ensureInitialized();
555
+
556
+ // Support both full UUID and short prefix (8+ hex chars)
557
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
558
+ const prefixRegex = /^[0-9a-f]{8,}$/i;
559
+ const isFullId = uuidRegex.test(id);
560
+ const isPrefix = !isFullId && prefixRegex.test(id);
561
+
562
+ if (!isFullId && !isPrefix) {
563
+ throw new Error(`Invalid memory ID format: ${id}`);
564
+ }
565
+
566
+ let candidates: any[];
567
+ if (isFullId) {
568
+ candidates = await this.table!.query().where(`id = '${id}'`).limit(1).toArray();
569
+ } else {
570
+ // Prefix match: fetch candidates and filter in app layer
571
+ const all = await this.table!.query().select(["id", "scope"]).limit(1000).toArray();
572
+ candidates = all.filter((r: any) => (r.id as string).startsWith(id));
573
+ if (candidates.length > 1) {
574
+ throw new Error(`Ambiguous prefix "${id}" matches ${candidates.length} memories. Use a longer prefix or full ID.`);
575
+ }
576
+ }
577
+ if (candidates.length === 0) {
578
+ return false;
579
+ }
580
+
581
+ const resolvedId = candidates[0].id as string;
582
+ const rowScope = (candidates[0].scope as string | undefined) ?? "";
583
+
584
+ // Check scope permissions
585
+ if (!matchesScopeFilter(rowScope, scopeFilter)) {
586
+ throw new Error(`Memory ${resolvedId} is outside accessible scopes`);
587
+ }
588
+
589
+ await this.table!.delete(`id = '${resolvedId}'`);
590
+ return true;
591
+ }
592
+
593
+ async list(scopeFilter?: string[], category?: string, limit = 20, offset = 0): Promise<MemoryEntry[]> {
594
+ await this.ensureInitialized();
595
+
596
+ let query = this.table!.query();
597
+
598
+ // Build where conditions
599
+ const conditions: string[] = [];
600
+
601
+ if (scopeFilter && scopeFilter.length > 0) {
602
+ const scopeConditions = scopeFilter
603
+ .map(scope => {
604
+ const safe = escapeSqlLiteral(scope);
605
+ return scope.includes(":")
606
+ ? `scope = '${safe}'`
607
+ : `scope LIKE '${safe}%'`;
608
+ })
609
+ .join(" OR ");
610
+ conditions.push(`((${scopeConditions}))`);
611
+ }
612
+
613
+ if (category) {
614
+ conditions.push(`category = '${escapeSqlLiteral(category)}'`);
615
+ }
616
+
617
+ if (conditions.length > 0) {
618
+ query = query.where(conditions.join(" AND "));
619
+ }
620
+
621
+ // Fetch all matching rows (no pre-limit) so app-layer sort is correct across full dataset
622
+ const results = await query
623
+ .select(["id", "text", "category", "scope", "importance", "timestamp", "metadata"])
624
+ .toArray();
625
+
626
+ return results
627
+ .map((row): MemoryEntry => ({
628
+ id: row.id as string,
629
+ text: row.text as string,
630
+ vector: [], // Don't include vectors in list results for performance
631
+ category: row.category as MemoryEntry["category"],
632
+ scope: (row.scope as string | undefined) ?? "",
633
+ importance: Number(row.importance),
634
+ timestamp: Number(row.timestamp),
635
+ metadata: (row.metadata as string) || "{}",
636
+ }))
637
+ .sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))
638
+ .slice(offset, offset + limit);
639
+ }
640
+
641
+ async get(id: string, scopeFilter?: string[]): Promise<MemoryEntry | null> {
642
+ await this.ensureInitialized();
643
+
644
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
645
+ const prefixRegex = /^[0-9a-f]{8,}$/i;
646
+ const isFullId = uuidRegex.test(id);
647
+ const isPrefix = !isFullId && prefixRegex.test(id);
648
+
649
+ if (!isFullId && !isPrefix) {
650
+ throw new Error(`Invalid memory ID format: ${id}`);
651
+ }
652
+
653
+ let rows: any[];
654
+ if (isFullId) {
655
+ const safeId = escapeSqlLiteral(id);
656
+ rows = await this.table!
657
+ .query()
658
+ .select(["id", "text", "vector", "category", "scope", "importance", "timestamp", "metadata"])
659
+ .where(`id = '${safeId}'`)
660
+ .limit(1)
661
+ .toArray();
662
+ } else {
663
+ const all = await this.table!
664
+ .query()
665
+ .select(["id", "text", "vector", "category", "scope", "importance", "timestamp", "metadata"])
666
+ .toArray();
667
+ rows = all.filter((r: any) => (r.id as string).startsWith(id));
668
+ if (rows.length > 1) {
669
+ throw new Error(`Ambiguous prefix "${id}" matches ${rows.length} memories. Use a longer prefix or full ID.`);
670
+ }
671
+ }
672
+
673
+ if (rows.length === 0) return null;
674
+
675
+ const row = rows[0];
676
+ const rowScope = (row.scope as string | undefined) ?? "";
677
+ if (!matchesScopeFilter(rowScope, scopeFilter)) {
678
+ throw new Error(`Memory ${id} is outside accessible scopes`);
679
+ }
680
+
681
+ return {
682
+ id: row.id as string,
683
+ text: row.text as string,
684
+ vector: Array.from(row.vector as Iterable<number>),
685
+ category: row.category as MemoryEntry["category"],
686
+ scope: rowScope,
687
+ importance: Number(row.importance),
688
+ timestamp: Number(row.timestamp),
689
+ metadata: (row.metadata as string) || "{}",
690
+ };
691
+ }
692
+
693
+ /**
694
+ * Get a single entry by exact ID without scope filtering.
695
+ * Lightweight read used by AccessTracker for metadata updates.
696
+ */
697
+ async getById(id: string): Promise<MemoryEntry | null> {
698
+ await this.ensureInitialized();
699
+ const safeId = escapeSqlLiteral(id);
700
+ const rows = await this.table!.query()
701
+ .select(["id", "text", "vector", "category", "scope", "importance", "timestamp", "metadata"])
702
+ .where(`id = '${safeId}'`)
703
+ .limit(1)
704
+ .toArray();
705
+ if (rows.length === 0) return null;
706
+ const row = rows[0];
707
+ return {
708
+ id: row.id as string,
709
+ text: row.text as string,
710
+ vector: Array.from(row.vector as Iterable<number>),
711
+ category: row.category as MemoryEntry["category"],
712
+ scope: (row.scope as string | undefined) ?? "",
713
+ importance: Number(row.importance),
714
+ timestamp: Number(row.timestamp),
715
+ metadata: (row.metadata as string) || "{}",
716
+ };
717
+ }
718
+
719
+ async stats(scopeFilter?: string[]): Promise<{
720
+ totalCount: number;
721
+ scopeCounts: Record<string, number>;
722
+ categoryCounts: Record<string, number>
723
+ }> {
724
+ await this.ensureInitialized();
725
+
726
+ let query = this.table!.query();
727
+
728
+ if (scopeFilter && scopeFilter.length > 0) {
729
+ const scopeConditions = scopeFilter
730
+ .map(scope => {
731
+ const safe = escapeSqlLiteral(scope);
732
+ return scope.includes(":")
733
+ ? `scope = '${safe}'`
734
+ : `scope LIKE '${safe}%'`;
735
+ })
736
+ .join(" OR ");
737
+ query = query.where(`((${scopeConditions}))`);
738
+ }
739
+
740
+ const results = await query.select(["scope", "category"]).toArray();
741
+
742
+ const scopeCounts: Record<string, number> = {};
743
+ const categoryCounts: Record<string, number> = {};
744
+
745
+ for (const row of results) {
746
+ const scope = (row.scope as string | undefined) ?? "";
747
+ const category = row.category as string;
748
+
749
+ scopeCounts[scope] = (scopeCounts[scope] || 0) + 1;
750
+ categoryCounts[category] = (categoryCounts[category] || 0) + 1;
751
+ }
752
+
753
+ return {
754
+ totalCount: results.length,
755
+ scopeCounts,
756
+ categoryCounts,
757
+ };
758
+ }
759
+
760
+ async auditLegacyScopes(limit = 20): Promise<LegacyScopeAudit> {
761
+ await this.ensureInitialized();
762
+
763
+ const safeLimit = clampInt(limit, 1, 200);
764
+ const rows = await this.table!
765
+ .query()
766
+ .select(["id", "text", "category", "scope", "timestamp"])
767
+ .toArray();
768
+
769
+ const counts: Record<LegacyScopeIssueKind, number> = {
770
+ missing: 0,
771
+ empty: 0,
772
+ global: 0,
773
+ };
774
+
775
+ const samples = rows
776
+ .map((row) => {
777
+ const scopeValue = (row.scope as string | null | undefined) ?? null;
778
+ const kind = classifyLegacyScope(scopeValue);
779
+ if (!kind) return null;
780
+ counts[kind] += 1;
781
+ return {
782
+ id: row.id as string,
783
+ kind,
784
+ scope: scopeValue,
785
+ category: row.category as MemoryEntry["category"],
786
+ timestamp: Number(row.timestamp),
787
+ text: row.text as string,
788
+ } satisfies LegacyScopeAuditSample;
789
+ })
790
+ .filter((row): row is LegacyScopeAuditSample => row !== null)
791
+ .sort((a, b) => b.timestamp - a.timestamp)
792
+ .slice(0, safeLimit);
793
+
794
+ return {
795
+ totalCount: counts.missing + counts.empty + counts.global,
796
+ counts,
797
+ samples,
798
+ };
799
+ }
800
+
801
+ async update(
802
+ id: string,
803
+ updates: { text?: string; vector?: number[]; importance?: number; category?: MemoryEntry["category"]; metadata?: string; timestamp?: number },
804
+ scopeFilter?: string[]
805
+ ): Promise<MemoryEntry | null> {
806
+ await this.ensureInitialized();
807
+
808
+ // Support both full UUID and short prefix (8+ hex chars), same as delete()
809
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
810
+ const prefixRegex = /^[0-9a-f]{8,}$/i;
811
+ const isFullId = uuidRegex.test(id);
812
+ const isPrefix = !isFullId && prefixRegex.test(id);
813
+
814
+ if (!isFullId && !isPrefix) {
815
+ throw new Error(`Invalid memory ID format: ${id}`);
816
+ }
817
+
818
+ let rows: any[];
819
+ if (isFullId) {
820
+ const safeId = escapeSqlLiteral(id);
821
+ rows = await this.table!.query().where(`id = '${safeId}'`).limit(1).toArray();
822
+ } else {
823
+ // Prefix match
824
+ const all = await this.table!.query().select(["id", "text", "vector", "category", "scope", "importance", "timestamp", "metadata"]).toArray();
825
+ rows = all.filter((r: any) => (r.id as string).startsWith(id));
826
+ if (rows.length > 1) {
827
+ throw new Error(`Ambiguous prefix "${id}" matches ${rows.length} memories. Use a longer prefix or full ID.`);
828
+ }
829
+ }
830
+
831
+ if (rows.length === 0) return null;
832
+
833
+ const row = rows[0];
834
+ const rowScope = (row.scope as string | undefined) ?? "";
835
+
836
+ // Check scope permissions
837
+ if (!matchesScopeFilter(rowScope, scopeFilter)) {
838
+ throw new Error(`Memory ${id} is outside accessible scopes`);
839
+ }
840
+
841
+ // Build updated entry, preserving original timestamp
842
+ const updated: MemoryEntry = {
843
+ id: row.id as string,
844
+ text: updates.text ?? (row.text as string),
845
+ vector: updates.vector ?? (Array.from(row.vector as Iterable<number>)),
846
+ category: updates.category ?? (row.category as MemoryEntry["category"]),
847
+ scope: rowScope,
848
+ importance: updates.importance ?? Number(row.importance),
849
+ timestamp: updates.timestamp ?? Number(row.timestamp),
850
+ metadata: updates.metadata ?? ((row.metadata as string) || "{}"),
851
+ };
852
+
853
+ // LanceDB doesn't support in-place update; delete + re-add
854
+ const resolvedId = escapeSqlLiteral(row.id as string);
855
+ await this.table!.delete(`id = '${resolvedId}'`);
856
+ await this.table!.add([updated]);
857
+
858
+ return updated;
859
+ }
860
+
861
+ async bulkDelete(scopeFilter: string[], beforeTimestamp?: number): Promise<number> {
862
+ await this.ensureInitialized();
863
+
864
+ const conditions: string[] = [];
865
+
866
+ if (scopeFilter.length > 0) {
867
+ const scopeConditions = scopeFilter
868
+ .map(scope => {
869
+ const safe = escapeSqlLiteral(scope);
870
+ return scope.includes(":")
871
+ ? `scope = '${safe}'`
872
+ : `scope LIKE '${safe}%'`;
873
+ })
874
+ .join(" OR ");
875
+ conditions.push(`(${scopeConditions})`);
876
+ }
877
+
878
+ if (beforeTimestamp) {
879
+ conditions.push(`timestamp < ${beforeTimestamp}`);
880
+ }
881
+
882
+ if (conditions.length === 0) {
883
+ throw new Error("Bulk delete requires at least scope or timestamp filter for safety");
884
+ }
885
+
886
+ const whereClause = conditions.join(" AND ");
887
+
888
+ // Count first
889
+ const countResults = await this.table!.query().where(whereClause).toArray();
890
+ const deleteCount = countResults.length;
891
+
892
+ // Then delete
893
+ if (deleteCount > 0) {
894
+ await this.table!.delete(whereClause);
895
+ }
896
+
897
+ return deleteCount;
898
+ }
899
+
900
+ get hasFtsSupport(): boolean {
901
+ return this.ftsIndexCreated;
902
+ }
903
+ }