vesper-memory 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +16 -12
  3. package/config/sqlite-schema.sql +31 -0
  4. package/dist/cli.js +6 -49
  5. package/dist/cli.js.map +1 -1
  6. package/dist/consolidation/pipeline.d.ts +25 -6
  7. package/dist/consolidation/pipeline.d.ts.map +1 -1
  8. package/dist/consolidation/pipeline.js +310 -67
  9. package/dist/consolidation/pipeline.js.map +1 -1
  10. package/dist/memory-layers/semantic-memory.d.ts +8 -3
  11. package/dist/memory-layers/semantic-memory.d.ts.map +1 -1
  12. package/dist/memory-layers/semantic-memory.js +110 -50
  13. package/dist/memory-layers/semantic-memory.js.map +1 -1
  14. package/dist/memory-layers/skill-library.d.ts +9 -9
  15. package/dist/memory-layers/skill-library.d.ts.map +1 -1
  16. package/dist/memory-layers/skill-library.js +85 -56
  17. package/dist/memory-layers/skill-library.js.map +1 -1
  18. package/dist/memory-layers/working-memory.d.ts +2 -2
  19. package/dist/memory-layers/working-memory.d.ts.map +1 -1
  20. package/dist/memory-layers/working-memory.js +57 -47
  21. package/dist/memory-layers/working-memory.js.map +1 -1
  22. package/dist/retrieval/hybrid-search.d.ts.map +1 -1
  23. package/dist/retrieval/hybrid-search.js +10 -5
  24. package/dist/retrieval/hybrid-search.js.map +1 -1
  25. package/dist/retrieval/index-outbox.d.ts +50 -0
  26. package/dist/retrieval/index-outbox.d.ts.map +1 -0
  27. package/dist/retrieval/index-outbox.js +225 -0
  28. package/dist/retrieval/index-outbox.js.map +1 -0
  29. package/dist/retrieval/memory-retriever.d.ts +123 -0
  30. package/dist/retrieval/memory-retriever.d.ts.map +1 -0
  31. package/dist/retrieval/memory-retriever.js +723 -0
  32. package/dist/retrieval/memory-retriever.js.map +1 -0
  33. package/dist/router/smart-router.js +18 -18
  34. package/dist/server-wrapper.js +0 -0
  35. package/dist/server.d.ts +9 -1
  36. package/dist/server.d.ts.map +1 -1
  37. package/dist/server.js +859 -355
  38. package/dist/server.js.map +1 -1
  39. package/dist/utils/claude-mcp-config.d.ts +3 -0
  40. package/dist/utils/claude-mcp-config.d.ts.map +1 -0
  41. package/dist/utils/claude-mcp-config.js +42 -0
  42. package/dist/utils/claude-mcp-config.js.map +1 -0
  43. package/dist/utils/monitoring.d.ts +1 -0
  44. package/dist/utils/monitoring.d.ts.map +1 -1
  45. package/dist/utils/monitoring.js +1 -0
  46. package/dist/utils/monitoring.js.map +1 -1
  47. package/dist/utils/paths.d.ts +13 -1
  48. package/dist/utils/paths.d.ts.map +1 -1
  49. package/dist/utils/paths.js +24 -0
  50. package/dist/utils/paths.js.map +1 -1
  51. package/dist/utils/validation.d.ts +14 -2
  52. package/dist/utils/validation.d.ts.map +1 -1
  53. package/dist/utils/validation.js +64 -48
  54. package/dist/utils/validation.js.map +1 -1
  55. package/docker-compose.yml +3 -3
  56. package/package.json +7 -4
  57. package/scripts/README.md +1 -1
  58. package/scripts/health-check.sh +8 -4
  59. package/scripts/init-qdrant.ts +42 -60
  60. package/scripts/test-mcp-server.sh +2 -2
  61. package/dist/config/rate-limits.d.ts +0 -76
  62. package/dist/config/rate-limits.d.ts.map +0 -1
  63. package/dist/config/rate-limits.js +0 -161
  64. package/dist/config/rate-limits.js.map +0 -1
  65. package/dist/middleware/auth.d.ts +0 -287
  66. package/dist/middleware/auth.d.ts.map +0 -1
  67. package/dist/middleware/auth.js +0 -610
  68. package/dist/middleware/auth.js.map +0 -1
  69. package/dist/monitoring/health.d.ts +0 -241
  70. package/dist/monitoring/health.d.ts.map +0 -1
  71. package/dist/monitoring/health.js +0 -649
  72. package/dist/monitoring/health.js.map +0 -1
  73. package/dist/monitoring/metrics.d.ts +0 -153
  74. package/dist/monitoring/metrics.d.ts.map +0 -1
  75. package/dist/monitoring/metrics.js +0 -349
  76. package/dist/monitoring/metrics.js.map +0 -1
  77. package/dist/security/rate-limit-middleware.d.ts +0 -119
  78. package/dist/security/rate-limit-middleware.d.ts.map +0 -1
  79. package/dist/security/rate-limit-middleware.js +0 -193
  80. package/dist/security/rate-limit-middleware.js.map +0 -1
@@ -0,0 +1,723 @@
1
+ /**
2
+ * Canonical memory retrieval.
3
+ *
4
+ * SQLite owns memory contents and authorization-relevant attributes. FTS5 and
5
+ * Qdrant are derived indexes: they may nominate ids, but their payloads are
6
+ * never returned or trusted for filtering.
7
+ */
8
+ const DEFAULT_MAX_RESULTS = 5;
9
+ const MAX_RESULTS = 100;
10
+ const DEFAULT_RRF_K = 60;
11
+ const DEFAULT_CANDIDATE_MULTIPLIER = 8;
12
+ const MAX_CANDIDATES = 800;
13
+ const MAX_FTS_TERMS = 64;
14
+ export class MemoryRetriever {
15
+ db;
16
+ embeddingClient;
17
+ hybridSearch;
18
+ now;
19
+ rrfK;
20
+ candidateMultiplier;
21
+ memoryColumns;
22
+ constructor(dependencies) {
23
+ this.db = dependencies.db;
24
+ this.embeddingClient = dependencies.embeddingClient;
25
+ this.hybridSearch = dependencies.hybridSearch;
26
+ this.now = dependencies.now ?? Date.now;
27
+ this.rrfK = dependencies.rrfK ?? DEFAULT_RRF_K;
28
+ this.candidateMultiplier = dependencies.candidateMultiplier ?? DEFAULT_CANDIDATE_MULTIPLIER;
29
+ if (!Number.isFinite(this.rrfK) || this.rrfK <= 0) {
30
+ throw new Error("rrfK must be a positive finite number");
31
+ }
32
+ if (!Number.isInteger(this.candidateMultiplier) || this.candidateMultiplier < 1) {
33
+ throw new Error("candidateMultiplier must be a positive integer");
34
+ }
35
+ }
36
+ /**
37
+ * Create the external-content FTS5 index and synchronization triggers.
38
+ * Existing rows are backfilled only when the index is first created.
39
+ */
40
+ initialize() {
41
+ const memoriesTable = this.db
42
+ .prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'memories'")
43
+ .get();
44
+ if (!memoriesTable) {
45
+ throw new Error("Cannot initialize retrieval before the memories table exists");
46
+ }
47
+ const existingFts = this.db
48
+ .prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'memories_fts'")
49
+ .get();
50
+ this.db.exec(`
51
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
52
+ content,
53
+ content = 'memories',
54
+ content_rowid = 'rowid',
55
+ tokenize = 'unicode61 remove_diacritics 2'
56
+ );
57
+
58
+ CREATE TRIGGER IF NOT EXISTS memories_fts_after_insert
59
+ AFTER INSERT ON memories BEGIN
60
+ INSERT INTO memories_fts(rowid, content) VALUES (new.rowid, new.content);
61
+ END;
62
+
63
+ CREATE TRIGGER IF NOT EXISTS memories_fts_after_delete
64
+ AFTER DELETE ON memories BEGIN
65
+ INSERT INTO memories_fts(memories_fts, rowid, content)
66
+ VALUES ('delete', old.rowid, old.content);
67
+ END;
68
+
69
+ CREATE TRIGGER IF NOT EXISTS memories_fts_after_update
70
+ AFTER UPDATE OF content ON memories BEGIN
71
+ INSERT INTO memories_fts(memories_fts, rowid, content)
72
+ VALUES ('delete', old.rowid, old.content);
73
+ INSERT INTO memories_fts(rowid, content) VALUES (new.rowid, new.content);
74
+ END;
75
+ `);
76
+ if (!existingFts) {
77
+ this.db.prepare("INSERT INTO memories_fts(memories_fts) VALUES ('rebuild')").run();
78
+ }
79
+ this.memoryColumns = undefined;
80
+ }
81
+ async retrieve(input) {
82
+ const normalized = this.validateInput(input);
83
+ const requestedStrategy = normalized.strategy;
84
+ const denseAvailable = Boolean(this.embeddingClient && this.hybridSearch);
85
+ let effectiveStrategy = this.effectiveStrategy(requestedStrategy, denseAvailable);
86
+ const candidateLimit = Math.min(MAX_CANDIDATES, normalized.maxResults * this.candidateMultiplier);
87
+ const stages = [];
88
+ let lexicalCandidates = [];
89
+ if (effectiveStrategy === "full_text" || effectiveStrategy === "hybrid") {
90
+ lexicalCandidates = this.lexicalSearch(normalized, candidateLimit);
91
+ stages.push({
92
+ stage: "lexical",
93
+ candidateCount: lexicalCandidates.length,
94
+ status: "ok",
95
+ });
96
+ }
97
+ else {
98
+ stages.push({ stage: "lexical", candidateCount: 0, status: "skipped" });
99
+ }
100
+ let denseCandidates = [];
101
+ let denseStage = {
102
+ stage: "dense",
103
+ candidateCount: 0,
104
+ status: denseAvailable ? "skipped" : "unavailable",
105
+ };
106
+ if (effectiveStrategy === "semantic" || effectiveStrategy === "hybrid") {
107
+ if (denseAvailable) {
108
+ const denseResult = await this.denseSearch(normalized, candidateLimit);
109
+ denseCandidates = denseResult.candidates;
110
+ denseStage = {
111
+ stage: "dense",
112
+ candidateCount: denseCandidates.length,
113
+ status: denseResult.available ? "ok" : "unavailable",
114
+ ...(denseResult.usedLegacyFallback
115
+ ? { fallback: "unfiltered_legacy_vectors" }
116
+ : {}),
117
+ };
118
+ if (!denseResult.available && effectiveStrategy === "hybrid") {
119
+ effectiveStrategy = "full_text";
120
+ }
121
+ }
122
+ }
123
+ stages.push(denseStage);
124
+ // RRF is meaningful only when two non-empty ranked lists are present.
125
+ // Preserve native channel scores when the other channel has no candidates.
126
+ if (effectiveStrategy === "hybrid") {
127
+ if (lexicalCandidates.length === 0 && denseCandidates.length > 0) {
128
+ effectiveStrategy = "semantic";
129
+ }
130
+ else if (denseCandidates.length === 0) {
131
+ effectiveStrategy = "full_text";
132
+ }
133
+ }
134
+ const rankedCandidates = this.rankCandidates(effectiveStrategy, lexicalCandidates, denseCandidates);
135
+ stages.push({
136
+ stage: "fusion",
137
+ candidateCount: rankedCandidates.length,
138
+ status: effectiveStrategy === "hybrid" ? "ok" : "skipped",
139
+ });
140
+ const hydrated = this.hydrate(rankedCandidates.map((candidate) => candidate.id));
141
+ stages.push({
142
+ stage: "hydration",
143
+ candidateCount: hydrated.size,
144
+ status: "ok",
145
+ });
146
+ const filtered = rankedCandidates
147
+ .map((candidate) => {
148
+ const row = hydrated.get(candidate.id);
149
+ return row ? { candidate, row, metadata: parseMetadata(row.metadata) } : null;
150
+ })
151
+ .filter((value) => value !== null)
152
+ .filter(({ row, metadata }) => this.matchesFilters(row, metadata, normalized))
153
+ .slice(0, normalized.maxResults);
154
+ stages.push({
155
+ stage: "filtering",
156
+ candidateCount: filtered.length,
157
+ status: "ok",
158
+ });
159
+ this.reinforceAccess(filtered.map(({ row }) => row.id));
160
+ const results = filtered.map(({ candidate, row, metadata }, index) => {
161
+ const similarityScore = effectiveStrategy === "hybrid"
162
+ ? candidate.fusedScore
163
+ : effectiveStrategy === "semantic"
164
+ ? candidate.denseScore
165
+ : candidate.lexicalScore;
166
+ return {
167
+ id: row.id,
168
+ content: row.content,
169
+ memoryType: row.memory_type,
170
+ createdAt: row.created_at,
171
+ updatedAt: row.updated_at,
172
+ metadata,
173
+ namespace: row.namespace ?? "default",
174
+ ...(row.agent_id !== null ? { agentId: row.agent_id } : {}),
175
+ ...(row.agent_role !== null ? { agentRole: row.agent_role } : {}),
176
+ ...(row.task_id !== null ? { taskId: row.task_id } : {}),
177
+ ...(similarityScore !== undefined ? { similarityScore } : {}),
178
+ ...(candidate.denseScore !== undefined ? { denseScore: candidate.denseScore } : {}),
179
+ ...(candidate.lexicalScore !== undefined ? { lexicalScore: candidate.lexicalScore } : {}),
180
+ ...(candidate.fusedScore !== undefined ? { fusedScore: candidate.fusedScore } : {}),
181
+ rank: index + 1,
182
+ sources: candidate.sources,
183
+ };
184
+ });
185
+ return {
186
+ strategy: effectiveStrategy,
187
+ results,
188
+ ...(normalized.explain
189
+ ? {
190
+ trace: {
191
+ requestedStrategy,
192
+ effectiveStrategy,
193
+ candidateLimit,
194
+ stages,
195
+ returnedCount: results.length,
196
+ },
197
+ }
198
+ : {}),
199
+ };
200
+ }
201
+ /**
202
+ * Canonically hydrate an ordered candidate list from a cache or legacy index.
203
+ * Supplied order is preserved, duplicate/missing ids are removed, all filters
204
+ * are applied to SQLite rows, and only returned rows receive access credit.
205
+ */
206
+ retrieveByIds(ids, input = {}) {
207
+ const maxResults = input.maxResults ?? DEFAULT_MAX_RESULTS;
208
+ if (!Number.isInteger(maxResults) || maxResults < 1 || maxResults > MAX_RESULTS) {
209
+ throw new Error(`maxResults must be an integer between 1 and ${MAX_RESULTS}`);
210
+ }
211
+ if (input.asOf !== undefined && !Number.isFinite(input.asOf)) {
212
+ throw new Error("asOf must be a finite Unix epoch millisecond timestamp");
213
+ }
214
+ const normalized = {
215
+ query: "candidate hydration",
216
+ namespace: input.namespace ?? "default",
217
+ memoryTypes: [...new Set(input.memoryTypes ?? [])],
218
+ maxResults,
219
+ strategy: "full_text",
220
+ includeSuperseded: input.includeSuperseded ?? false,
221
+ includeArchived: input.includeArchived ?? false,
222
+ explain: false,
223
+ ...(input.agentId !== undefined ? { agentId: input.agentId } : {}),
224
+ ...(input.taskId !== undefined ? { taskId: input.taskId } : {}),
225
+ ...(input.excludeAgent !== undefined ? { excludeAgent: input.excludeAgent } : {}),
226
+ ...(input.asOf !== undefined ? { asOf: input.asOf } : {}),
227
+ };
228
+ const orderedIds = [...new Set(ids)].slice(0, MAX_CANDIDATES);
229
+ const hydrated = this.hydrate(orderedIds);
230
+ const source = input.source ?? "fast_path";
231
+ const selected = orderedIds
232
+ .map((id) => {
233
+ const row = hydrated.get(id);
234
+ return row ? { row, metadata: parseMetadata(row.metadata) } : null;
235
+ })
236
+ .filter((value) => value !== null)
237
+ .filter(({ row, metadata }) => this.matchesFilters(row, metadata, normalized))
238
+ .slice(0, maxResults);
239
+ this.reinforceAccess(selected.map(({ row }) => row.id));
240
+ return selected.map(({ row, metadata }, index) => {
241
+ const similarityScore = input.similarityScores?.get(row.id);
242
+ return {
243
+ id: row.id,
244
+ content: row.content,
245
+ memoryType: row.memory_type,
246
+ createdAt: row.created_at,
247
+ updatedAt: row.updated_at,
248
+ metadata,
249
+ namespace: row.namespace ?? "default",
250
+ ...(row.agent_id !== null ? { agentId: row.agent_id } : {}),
251
+ ...(row.agent_role !== null ? { agentRole: row.agent_role } : {}),
252
+ ...(row.task_id !== null ? { taskId: row.task_id } : {}),
253
+ ...(similarityScore !== undefined ? { similarityScore } : {}),
254
+ rank: index + 1,
255
+ sources: [source],
256
+ };
257
+ });
258
+ }
259
+ validateInput(input) {
260
+ if (!input || typeof input.query !== "string" || input.query.trim() === "") {
261
+ throw new Error("Query cannot be empty");
262
+ }
263
+ const maxResults = input.maxResults ?? DEFAULT_MAX_RESULTS;
264
+ if (!Number.isInteger(maxResults) || maxResults < 1 || maxResults > MAX_RESULTS) {
265
+ throw new Error(`maxResults must be an integer between 1 and ${MAX_RESULTS}`);
266
+ }
267
+ if (input.asOf !== undefined && !Number.isFinite(input.asOf)) {
268
+ throw new Error("asOf must be a finite Unix epoch millisecond timestamp");
269
+ }
270
+ return {
271
+ ...input,
272
+ query: input.query.trim(),
273
+ namespace: input.namespace ?? "default",
274
+ memoryTypes: [...new Set(input.memoryTypes ?? [])],
275
+ maxResults,
276
+ strategy: input.strategy ?? "auto",
277
+ includeSuperseded: input.includeSuperseded ?? false,
278
+ includeArchived: input.includeArchived ?? false,
279
+ explain: input.explain ?? false,
280
+ };
281
+ }
282
+ effectiveStrategy(strategy, denseAvailable) {
283
+ if (strategy === "auto") {
284
+ return denseAvailable ? "hybrid" : "full_text";
285
+ }
286
+ if (strategy === "hybrid" && !denseAvailable) {
287
+ return "full_text";
288
+ }
289
+ return strategy;
290
+ }
291
+ lexicalSearch(input, limit) {
292
+ const ftsQuery = sanitizeFtsQuery(input.query);
293
+ if (!ftsQuery)
294
+ return [];
295
+ // These canonical predicates reduce candidate starvation in large shared
296
+ // databases. They are intentionally repeated after full hydration below.
297
+ const columns = this.getMemoryColumns();
298
+ const predicates = ["memories_fts MATCH ?"];
299
+ const parameters = [ftsQuery];
300
+ if (columns.has("namespace")) {
301
+ predicates.push("COALESCE(m.namespace, 'default') = ?");
302
+ parameters.push(input.namespace);
303
+ }
304
+ if (input.memoryTypes.length > 0) {
305
+ predicates.push(`m.memory_type IN (${input.memoryTypes.map(() => "?").join(", ")})`);
306
+ parameters.push(...input.memoryTypes);
307
+ }
308
+ if (input.agentId !== undefined && columns.has("agent_id")) {
309
+ predicates.push("m.agent_id = ?");
310
+ parameters.push(input.agentId);
311
+ }
312
+ if (input.taskId !== undefined && columns.has("task_id")) {
313
+ predicates.push("m.task_id = ?");
314
+ parameters.push(input.taskId);
315
+ }
316
+ if (input.excludeAgent !== undefined && columns.has("agent_id")) {
317
+ predicates.push("(m.agent_id IS NULL OR m.agent_id != ?)");
318
+ parameters.push(input.excludeAgent);
319
+ }
320
+ // Push lifecycle predicates into the canonical FTS query so invalid rows
321
+ // cannot consume the bounded candidate window before hydration.
322
+ const referenceTime = input.asOf ?? this.now();
323
+ if (columns.has("valid_from")) {
324
+ predicates.push(`(m.valid_from IS NULL OR ${sqliteEpochMillis("m.valid_from")} IS NULL OR ` +
325
+ `${sqliteEpochMillis("m.valid_from")} <= ?)`);
326
+ parameters.push(referenceTime);
327
+ }
328
+ if (columns.has("expired_at")) {
329
+ predicates.push(`(m.expired_at IS NULL OR ${sqliteEpochMillis("m.expired_at")} IS NULL OR ` +
330
+ `${sqliteEpochMillis("m.expired_at")} > ?)`);
331
+ parameters.push(referenceTime);
332
+ }
333
+ if (columns.has("valid_until")) {
334
+ if (input.includeSuperseded && columns.has("metadata")) {
335
+ predicates.push(`(m.valid_until IS NULL OR ${sqliteEpochMillis("m.valid_until")} IS NULL OR ` +
336
+ `${sqliteEpochMillis("m.valid_until")} > ? OR (` +
337
+ `json_valid(m.metadata) AND json_extract(m.metadata, '$.superseded_by') IS NOT NULL ` +
338
+ `AND ${sqliteEpochMillis("json_extract(m.metadata, '$.superseded_at')")} = ` +
339
+ `${sqliteEpochMillis("m.valid_until")}))`);
340
+ }
341
+ else {
342
+ predicates.push(`(m.valid_until IS NULL OR ${sqliteEpochMillis("m.valid_until")} IS NULL OR ` +
343
+ `${sqliteEpochMillis("m.valid_until")} > ?)`);
344
+ }
345
+ parameters.push(referenceTime);
346
+ }
347
+ if (!input.includeArchived) {
348
+ if (columns.has("is_archived"))
349
+ predicates.push("COALESCE(m.is_archived, 0) = 0");
350
+ if (columns.has("archived_at")) {
351
+ predicates.push(`(m.archived_at IS NULL OR ${sqliteEpochMillis("m.archived_at")} IS NULL OR ` +
352
+ `${sqliteEpochMillis("m.archived_at")} > ?)`);
353
+ parameters.push(referenceTime);
354
+ }
355
+ }
356
+ if (columns.has("metadata")) {
357
+ predicates.push(`(m.metadata IS NULL OR json_valid(m.metadata) = 0 OR (` +
358
+ `COALESCE(json_extract(m.metadata, '$.is_valid'), 1) != 0 AND ` +
359
+ `COALESCE(json_extract(m.metadata, '$.invalid'), 0) = 0))`);
360
+ predicates.push(`(m.metadata IS NULL OR json_valid(m.metadata) = 0 OR ` +
361
+ (columns.has("valid_from") ? `m.valid_from IS NOT NULL OR ` : "") +
362
+ `json_extract(m.metadata, '$.valid_from') IS NULL OR ` +
363
+ `${sqliteEpochMillis("json_extract(m.metadata, '$.valid_from')")} IS NULL OR ` +
364
+ `${sqliteEpochMillis("json_extract(m.metadata, '$.valid_from')")} <= ?)`);
365
+ parameters.push(referenceTime);
366
+ predicates.push(`(m.metadata IS NULL OR json_valid(m.metadata) = 0 OR ` +
367
+ (columns.has("expired_at") ? `m.expired_at IS NOT NULL OR ` : "") +
368
+ `json_extract(m.metadata, '$.expired_at') IS NULL OR ` +
369
+ `${sqliteEpochMillis("json_extract(m.metadata, '$.expired_at')")} IS NULL OR ` +
370
+ `${sqliteEpochMillis("json_extract(m.metadata, '$.expired_at')")} > ?)`);
371
+ parameters.push(referenceTime);
372
+ if (input.includeSuperseded) {
373
+ predicates.push(`(m.metadata IS NULL OR json_valid(m.metadata) = 0 OR ` +
374
+ (columns.has("valid_until") ? `m.valid_until IS NOT NULL OR ` : "") +
375
+ `json_extract(m.metadata, '$.valid_until') IS NULL OR ` +
376
+ `${sqliteEpochMillis("json_extract(m.metadata, '$.valid_until')")} IS NULL OR ` +
377
+ `${sqliteEpochMillis("json_extract(m.metadata, '$.valid_until')")} > ? OR (` +
378
+ `json_extract(m.metadata, '$.superseded_by') IS NOT NULL AND ` +
379
+ `json_extract(m.metadata, '$.superseded_at') IS NOT NULL AND ` +
380
+ `${sqliteEpochMillis("json_extract(m.metadata, '$.superseded_at')")} = ` +
381
+ `${sqliteEpochMillis("json_extract(m.metadata, '$.valid_until')")}))`);
382
+ }
383
+ else {
384
+ predicates.push(`(m.metadata IS NULL OR json_valid(m.metadata) = 0 OR ` +
385
+ (columns.has("valid_until") ? `m.valid_until IS NOT NULL OR ` : "") +
386
+ `json_extract(m.metadata, '$.valid_until') IS NULL OR ` +
387
+ `${sqliteEpochMillis("json_extract(m.metadata, '$.valid_until')")} IS NULL OR ` +
388
+ `${sqliteEpochMillis("json_extract(m.metadata, '$.valid_until')")} > ?)`);
389
+ }
390
+ parameters.push(referenceTime);
391
+ if (!input.includeArchived) {
392
+ predicates.push(`(m.metadata IS NULL OR json_valid(m.metadata) = 0 OR (` +
393
+ `COALESCE(json_extract(m.metadata, '$.is_archived'), 0) = 0 AND ` +
394
+ `COALESCE(json_extract(m.metadata, '$.archived'), 0) = 0 AND (` +
395
+ (columns.has("archived_at") ? `m.archived_at IS NOT NULL OR ` : "") +
396
+ `json_extract(m.metadata, '$.archived_at') IS NULL OR ` +
397
+ `${sqliteEpochMillis("json_extract(m.metadata, '$.archived_at')")} IS NULL OR ` +
398
+ `${sqliteEpochMillis("json_extract(m.metadata, '$.archived_at')")} > ?)))`);
399
+ parameters.push(referenceTime);
400
+ }
401
+ if (!input.includeSuperseded) {
402
+ predicates.push(`(m.metadata IS NULL OR json_valid(m.metadata) = 0 OR ` +
403
+ `json_extract(m.metadata, '$.superseded_by') IS NULL OR (` +
404
+ `json_extract(m.metadata, '$.superseded_at') IS NOT NULL AND ` +
405
+ `${sqliteEpochMillis("json_extract(m.metadata, '$.superseded_at')")} > ?))`);
406
+ parameters.push(referenceTime);
407
+ }
408
+ }
409
+ const rows = this.db
410
+ .prepare(`SELECT m.id AS id, bm25(memories_fts) AS bm25_score
411
+ FROM memories_fts
412
+ JOIN memories AS m ON m.rowid = memories_fts.rowid
413
+ WHERE ${predicates.join(" AND ")}
414
+ ORDER BY bm25_score ASC, m.id ASC
415
+ LIMIT ?`)
416
+ .all(...parameters, limit);
417
+ return rows.map((row, index) => ({
418
+ id: row.id,
419
+ score: Math.max(0, -row.bm25_score),
420
+ rank: index + 1,
421
+ }));
422
+ }
423
+ async denseSearch(input, limit) {
424
+ if (!this.embeddingClient || !this.hybridSearch) {
425
+ return { candidates: [], available: false, usedLegacyFallback: false };
426
+ }
427
+ try {
428
+ const vector = await this.embeddingClient.embed(input.query);
429
+ const filter = buildDenseFilter(input);
430
+ const searchProgressively = async (candidateFilter) => {
431
+ let depth = Math.max(input.maxResults, limit);
432
+ while (true) {
433
+ const results = candidateFilter
434
+ ? await this.hybridSearch.denseSearch(vector, depth, candidateFilter)
435
+ : await this.hybridSearch.denseSearch(vector, depth);
436
+ const candidates = deduplicateDenseResults(results);
437
+ const hydrated = this.hydrate(candidates.map((candidate) => candidate.id));
438
+ const eligibleCount = candidates.filter((candidate) => {
439
+ const row = hydrated.get(candidate.id);
440
+ return row ? this.matchesFilters(row, parseMetadata(row.metadata), input) : false;
441
+ }).length;
442
+ if (eligibleCount >= input.maxResults ||
443
+ results.length < depth ||
444
+ depth >= MAX_CANDIDATES) {
445
+ return { candidates, rawCount: results.length, eligibleCount };
446
+ }
447
+ depth = Math.min(MAX_CANDIDATES, depth * 2);
448
+ }
449
+ };
450
+ let searchResult = await searchProgressively(filter);
451
+ let usedLegacyFallback = false;
452
+ // Vectors written before namespaces were introduced have no namespace
453
+ // payload. They are candidates only; SQLite filtering below still
454
+ // enforces the default namespace and every requested restriction.
455
+ if (searchResult.eligibleCount < input.maxResults && input.namespace === "default") {
456
+ const legacyResult = await searchProgressively();
457
+ const byId = new Map();
458
+ for (const candidate of [...searchResult.candidates, ...legacyResult.candidates]) {
459
+ const existing = byId.get(candidate.id);
460
+ if (!existing || candidate.score > existing.score)
461
+ byId.set(candidate.id, candidate);
462
+ }
463
+ const merged = [...byId.values()]
464
+ .sort((left, right) => right.score - left.score || left.id.localeCompare(right.id))
465
+ .map((candidate, index) => ({ ...candidate, rank: index + 1 }));
466
+ searchResult = {
467
+ candidates: merged,
468
+ rawCount: legacyResult.rawCount,
469
+ eligibleCount: legacyResult.eligibleCount,
470
+ };
471
+ usedLegacyFallback = true;
472
+ }
473
+ return {
474
+ candidates: searchResult.candidates,
475
+ available: true,
476
+ usedLegacyFallback,
477
+ };
478
+ }
479
+ catch {
480
+ return { candidates: [], available: false, usedLegacyFallback: false };
481
+ }
482
+ }
483
+ rankCandidates(strategy, lexical, dense) {
484
+ if (strategy === "full_text") {
485
+ return lexical.map((candidate) => ({
486
+ id: candidate.id,
487
+ lexicalScore: candidate.score,
488
+ lexicalRank: candidate.rank,
489
+ sources: ["lexical"],
490
+ }));
491
+ }
492
+ if (strategy === "semantic") {
493
+ return dense.map((candidate) => ({
494
+ id: candidate.id,
495
+ denseScore: candidate.score,
496
+ denseRank: candidate.rank,
497
+ sources: ["dense"],
498
+ }));
499
+ }
500
+ const fused = new Map();
501
+ for (const candidate of lexical) {
502
+ fused.set(candidate.id, {
503
+ id: candidate.id,
504
+ lexicalScore: candidate.score,
505
+ lexicalRank: candidate.rank,
506
+ fusedScore: 1 / (this.rrfK + candidate.rank),
507
+ sources: ["lexical"],
508
+ });
509
+ }
510
+ for (const candidate of dense) {
511
+ const existing = fused.get(candidate.id);
512
+ if (existing) {
513
+ existing.denseScore = candidate.score;
514
+ existing.denseRank = candidate.rank;
515
+ existing.fusedScore = (existing.fusedScore ?? 0) + 1 / (this.rrfK + candidate.rank);
516
+ existing.sources.push("dense");
517
+ }
518
+ else {
519
+ fused.set(candidate.id, {
520
+ id: candidate.id,
521
+ denseScore: candidate.score,
522
+ denseRank: candidate.rank,
523
+ fusedScore: 1 / (this.rrfK + candidate.rank),
524
+ sources: ["dense"],
525
+ });
526
+ }
527
+ }
528
+ return [...fused.values()].sort((left, right) => {
529
+ const scoreDifference = (right.fusedScore ?? 0) - (left.fusedScore ?? 0);
530
+ if (scoreDifference !== 0)
531
+ return scoreDifference;
532
+ const leftBestRank = Math.min(left.lexicalRank ?? Number.POSITIVE_INFINITY, left.denseRank ?? Number.POSITIVE_INFINITY);
533
+ const rightBestRank = Math.min(right.lexicalRank ?? Number.POSITIVE_INFINITY, right.denseRank ?? Number.POSITIVE_INFINITY);
534
+ return leftBestRank - rightBestRank || left.id.localeCompare(right.id);
535
+ });
536
+ }
537
+ hydrate(ids) {
538
+ if (ids.length === 0)
539
+ return new Map();
540
+ const columns = this.getMemoryColumns();
541
+ const optionalColumn = (name, fallback) => columns.has(name) ? name : `${fallback} AS ${name}`;
542
+ const placeholders = ids.map(() => "?").join(", ");
543
+ const rows = this.db
544
+ .prepare(`SELECT id, content, memory_type, created_at, updated_at,
545
+ ${optionalColumn("metadata", "NULL")},
546
+ ${optionalColumn("namespace", "'default'")},
547
+ ${optionalColumn("agent_id", "NULL")},
548
+ ${optionalColumn("agent_role", "NULL")},
549
+ ${optionalColumn("task_id", "NULL")},
550
+ ${optionalColumn("access_count", "NULL")},
551
+ ${optionalColumn("last_accessed", "NULL")},
552
+ ${optionalColumn("valid_from", "NULL")},
553
+ ${optionalColumn("valid_until", "NULL")},
554
+ ${optionalColumn("is_archived", "0")},
555
+ ${optionalColumn("archived_at", "NULL")},
556
+ ${optionalColumn("expired_at", "NULL")}
557
+ FROM memories
558
+ WHERE id IN (${placeholders})`)
559
+ .all(...ids);
560
+ return new Map(rows.map((row) => [row.id, row]));
561
+ }
562
+ matchesFilters(row, metadata, input) {
563
+ if ((row.namespace ?? "default") !== input.namespace)
564
+ return false;
565
+ if (input.memoryTypes.length > 0 && !input.memoryTypes.includes(row.memory_type)) {
566
+ return false;
567
+ }
568
+ if (input.agentId !== undefined && row.agent_id !== input.agentId)
569
+ return false;
570
+ if (input.taskId !== undefined && row.task_id !== input.taskId)
571
+ return false;
572
+ if (input.excludeAgent !== undefined && row.agent_id === input.excludeAgent) {
573
+ return false;
574
+ }
575
+ const referenceTime = input.asOf ?? this.now();
576
+ const archivedAt = toEpochMilliseconds(row.archived_at ?? metadata.archived_at);
577
+ const archived = Boolean(row.is_archived ||
578
+ metadata.is_archived ||
579
+ metadata.archived ||
580
+ (archivedAt !== undefined && archivedAt <= referenceTime));
581
+ if (!input.includeArchived && archived)
582
+ return false;
583
+ if (metadata.is_valid === false || metadata.invalid === true)
584
+ return false;
585
+ const validFrom = toEpochMilliseconds(row.valid_from ?? metadata.valid_from ?? row.created_at);
586
+ const validityEnds = [
587
+ toEpochMilliseconds(row.valid_until ?? metadata.valid_until),
588
+ toEpochMilliseconds(row.expired_at ?? metadata.expired_at),
589
+ ].filter((value) => value !== undefined);
590
+ const validUntil = validityEnds.length > 0 ? Math.min(...validityEnds) : undefined;
591
+ const supersededAt = toEpochMilliseconds(metadata.superseded_at);
592
+ const closedBySupersession = Boolean(metadata.superseded_by &&
593
+ supersededAt !== undefined &&
594
+ validUntil !== undefined &&
595
+ validUntil === supersededAt);
596
+ if (validFrom !== undefined && validFrom > referenceTime)
597
+ return false;
598
+ if (validUntil !== undefined &&
599
+ validUntil <= referenceTime &&
600
+ !(input.includeSuperseded && closedBySupersession)) {
601
+ return false;
602
+ }
603
+ if (!input.includeSuperseded && metadata.superseded_by) {
604
+ if (supersededAt === undefined || supersededAt <= referenceTime) {
605
+ return false;
606
+ }
607
+ }
608
+ return true;
609
+ }
610
+ reinforceAccess(ids) {
611
+ if (ids.length === 0)
612
+ return;
613
+ const columns = this.getMemoryColumns();
614
+ const assignments = [];
615
+ const leadingParameters = [];
616
+ if (columns.has("access_count")) {
617
+ assignments.push("access_count = COALESCE(access_count, 0) + 1");
618
+ }
619
+ if (columns.has("last_accessed")) {
620
+ assignments.push("last_accessed = ?");
621
+ leadingParameters.push(this.now());
622
+ }
623
+ if (assignments.length === 0)
624
+ return;
625
+ const placeholders = ids.map(() => "?").join(", ");
626
+ this.db
627
+ .prepare(`UPDATE memories SET ${assignments.join(", ")} WHERE id IN (${placeholders})`)
628
+ .run(...leadingParameters, ...ids);
629
+ }
630
+ getMemoryColumns() {
631
+ if (!this.memoryColumns) {
632
+ const rows = this.db.prepare("PRAGMA table_info(memories)").all();
633
+ this.memoryColumns = new Set(rows.map((row) => row.name));
634
+ }
635
+ return this.memoryColumns;
636
+ }
637
+ }
638
+ /** Convert arbitrary text into literal FTS tokens, never executable FTS syntax. */
639
+ export function sanitizeFtsQuery(query) {
640
+ const tokens = query.match(/[\p{L}\p{N}_]+/gu)?.slice(0, MAX_FTS_TERMS) ?? [];
641
+ if (tokens.length === 0)
642
+ return null;
643
+ return tokens.map((token) => `"${token}"`).join(" OR ");
644
+ }
645
+ function deduplicateDenseResults(results) {
646
+ const seen = new Set();
647
+ const candidates = [];
648
+ for (const result of results) {
649
+ if (seen.has(result.id) || !Number.isFinite(result.score))
650
+ continue;
651
+ seen.add(result.id);
652
+ candidates.push({ id: result.id, score: result.score, rank: candidates.length + 1 });
653
+ }
654
+ return candidates;
655
+ }
656
+ function buildDenseFilter(input) {
657
+ const must = [
658
+ { key: "namespace", match: { value: input.namespace } },
659
+ ];
660
+ if (input.memoryTypes.length > 0) {
661
+ must.push({ key: "memory_type", match: { any: input.memoryTypes } });
662
+ }
663
+ if (input.agentId !== undefined) {
664
+ must.push({ key: "agent_id", match: { value: input.agentId } });
665
+ }
666
+ if (input.taskId !== undefined) {
667
+ must.push({ key: "task_id", match: { value: input.taskId } });
668
+ }
669
+ return {
670
+ must,
671
+ ...(input.excludeAgent !== undefined
672
+ ? {
673
+ must_not: [{ key: "agent_id", match: { value: input.excludeAgent } }],
674
+ }
675
+ : {}),
676
+ };
677
+ }
678
+ function parseMetadata(value) {
679
+ if (!value)
680
+ return {};
681
+ try {
682
+ const parsed = JSON.parse(value);
683
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
684
+ ? parsed
685
+ : {};
686
+ }
687
+ catch {
688
+ return {};
689
+ }
690
+ }
691
+ function toEpochMilliseconds(value) {
692
+ if (typeof value === "number") {
693
+ if (!Number.isFinite(value))
694
+ return undefined;
695
+ // SQLite CURRENT_TIMESTAMP values are text, while older Vesper rows use
696
+ // epoch milliseconds. Treat small numeric epochs as seconds for resilience.
697
+ return Math.abs(value) < 100_000_000_000 ? value * 1000 : value;
698
+ }
699
+ if (typeof value !== "string" || value.trim() === "")
700
+ return undefined;
701
+ const numeric = Number(value);
702
+ if (Number.isFinite(numeric)) {
703
+ return Math.abs(numeric) < 100_000_000_000 ? numeric * 1000 : numeric;
704
+ }
705
+ const parsed = Date.parse(value);
706
+ return Number.isFinite(parsed) ? parsed : undefined;
707
+ }
708
+ /** SQLite expression that normalizes numeric epochs and ISO timestamps to milliseconds. */
709
+ function sqliteEpochMillis(expression) {
710
+ return `(CASE
711
+ WHEN typeof(${expression}) IN ('integer', 'real')
712
+ THEN CASE WHEN ABS(${expression}) < 100000000000 THEN ${expression} * 1000 ELSE ${expression} END
713
+ WHEN trim(${expression}) != ''
714
+ AND trim(${expression}) NOT GLOB '*[^0-9.]*'
715
+ THEN CASE
716
+ WHEN ABS(CAST(${expression} AS REAL)) < 100000000000
717
+ THEN CAST(${expression} AS REAL) * 1000
718
+ ELSE CAST(${expression} AS REAL)
719
+ END
720
+ ELSE CAST(strftime('%s', ${expression}) AS INTEGER) * 1000
721
+ END)`;
722
+ }
723
+ //# sourceMappingURL=memory-retriever.js.map