audrey 0.16.0 → 0.17.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.
package/src/fts.js ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * FTS5 full-text search for Audrey memories.
3
+ * Creates virtual tables alongside vec0 tables for hybrid retrieval.
4
+ */
5
+
6
+ export function createFTSTables(db) {
7
+ db.exec(`
8
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts_episodes
9
+ USING fts5(id UNINDEXED, content, tags, tokenize='porter unicode61');
10
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts_semantics
11
+ USING fts5(id UNINDEXED, content, tokenize='porter unicode61');
12
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts_procedures
13
+ USING fts5(id UNINDEXED, content, tokenize='porter unicode61');
14
+ `);
15
+ }
16
+
17
+ export function hasFTSTables(db) {
18
+ const row = db.prepare(
19
+ "SELECT COUNT(*) AS c FROM sqlite_master WHERE type='table' AND name='fts_episodes'"
20
+ ).get();
21
+ return row.c > 0;
22
+ }
23
+
24
+ export function insertFTSEpisode(db, id, content, tags) {
25
+ db.prepare('INSERT OR REPLACE INTO fts_episodes(id, content, tags) VALUES (?, ?, ?)').run(
26
+ id, content, tags ? (Array.isArray(tags) ? tags.join(' ') : tags) : ''
27
+ );
28
+ }
29
+
30
+ export function insertFTSSemantic(db, id, content) {
31
+ db.prepare('INSERT OR REPLACE INTO fts_semantics(id, content) VALUES (?, ?)').run(id, content);
32
+ }
33
+
34
+ export function insertFTSProcedure(db, id, content) {
35
+ db.prepare('INSERT OR REPLACE INTO fts_procedures(id, content) VALUES (?, ?)').run(id, content);
36
+ }
37
+
38
+ export function deleteFTSEpisode(db, id) {
39
+ db.prepare('DELETE FROM fts_episodes WHERE id = ?').run(id);
40
+ }
41
+
42
+ export function deleteFTSSemantic(db, id) {
43
+ db.prepare('DELETE FROM fts_semantics WHERE id = ?').run(id);
44
+ }
45
+
46
+ export function deleteFTSProcedure(db, id) {
47
+ db.prepare('DELETE FROM fts_procedures WHERE id = ?').run(id);
48
+ }
49
+
50
+ /**
51
+ * Search episodes via FTS5 BM25.
52
+ * Returns [{ id, content, rank }] sorted by relevance.
53
+ */
54
+ export function searchFTSEpisodes(db, query, limit = 30, agentFilter = null) {
55
+ const agentClause = agentFilter ? 'AND e.agent = ?' : '';
56
+ const params = agentFilter ? [query, agentFilter, limit] : [query, limit];
57
+ return db.prepare(`
58
+ SELECT f.id, f.content, e.agent, bm25(fts_episodes) AS rank
59
+ FROM fts_episodes f
60
+ JOIN episodes e ON e.id = f.id
61
+ WHERE fts_episodes MATCH ?
62
+ AND e.superseded_by IS NULL
63
+ ${agentClause}
64
+ ORDER BY rank
65
+ LIMIT ?
66
+ `).all(...params);
67
+ }
68
+
69
+ export function searchFTSSemantics(db, query, limit = 30, agentFilter = null) {
70
+ const agentClause = agentFilter ? 'AND s.agent = ?' : '';
71
+ const params = agentFilter ? [query, agentFilter, limit] : [query, limit];
72
+ return db.prepare(`
73
+ SELECT f.id, f.content, s.agent, bm25(fts_semantics) AS rank
74
+ FROM fts_semantics f
75
+ JOIN semantics s ON s.id = f.id
76
+ WHERE fts_semantics MATCH ?
77
+ AND s.state = 'active'
78
+ ${agentClause}
79
+ ORDER BY rank
80
+ LIMIT ?
81
+ `).all(...params);
82
+ }
83
+
84
+ export function searchFTSProcedures(db, query, limit = 30, agentFilter = null) {
85
+ const agentClause = agentFilter ? 'AND p.agent = ?' : '';
86
+ const params = agentFilter ? [query, agentFilter, limit] : [query, limit];
87
+ return db.prepare(`
88
+ SELECT f.id, f.content, p.agent, bm25(fts_procedures) AS rank
89
+ FROM fts_procedures f
90
+ JOIN procedures p ON p.id = f.id
91
+ WHERE fts_procedures MATCH ?
92
+ AND p.state = 'active'
93
+ ${agentClause}
94
+ ORDER BY rank
95
+ LIMIT ?
96
+ `).all(...params);
97
+ }
98
+
99
+ /**
100
+ * Backfill FTS tables from existing data.
101
+ */
102
+ export function backfillFTS(db) {
103
+ const episodes = db.prepare('SELECT id, content, tags FROM episodes').all();
104
+ const insert = db.prepare('INSERT OR IGNORE INTO fts_episodes(id, content, tags) VALUES (?, ?, ?)');
105
+ for (const ep of episodes) {
106
+ const tags = ep.tags ? (typeof ep.tags === 'string' ? JSON.parse(ep.tags) : ep.tags) : [];
107
+ insert.run(ep.id, ep.content, Array.isArray(tags) ? tags.join(' ') : '');
108
+ }
109
+
110
+ const semantics = db.prepare('SELECT id, content FROM semantics').all();
111
+ const insertSem = db.prepare('INSERT OR IGNORE INTO fts_semantics(id, content) VALUES (?, ?)');
112
+ for (const sem of semantics) {
113
+ insertSem.run(sem.id, sem.content);
114
+ }
115
+
116
+ const procedures = db.prepare('SELECT id, content FROM procedures').all();
117
+ const insertProc = db.prepare('INSERT OR IGNORE INTO fts_procedures(id, content) VALUES (?, ?)');
118
+ for (const proc of procedures) {
119
+ insertProc.run(proc.id, proc.content);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Sanitize FTS5 query — escape special characters.
125
+ */
126
+ export function sanitizeFTSQuery(query) {
127
+ return query
128
+ .replace(/[*"(){}[\]^~\\:]/g, ' ')
129
+ .replace(/\bAND\b|\bOR\b|\bNOT\b|\bNEAR\b/gi, ' ')
130
+ .trim()
131
+ .split(/\s+/)
132
+ .filter(Boolean)
133
+ .join(' ');
134
+ }
package/src/import.js CHANGED
@@ -16,11 +16,39 @@ function isDatabaseEmpty(db) {
16
16
  return tables.every(table => db.prepare(`SELECT COUNT(*) AS c FROM ${table}`).get().c === 0);
17
17
  }
18
18
 
19
+ const VALID_SOURCES = new Set(['direct-observation', 'told-by-user', 'tool-result', 'inference', 'model-generated']);
20
+
21
+ function validateSnapshot(snapshot) {
22
+ const errors = [];
23
+ for (let i = 0; i < (snapshot.episodes || []).length; i++) {
24
+ const ep = snapshot.episodes[i];
25
+ if (!ep.id) errors.push(`episodes[${i}]: missing id`);
26
+ if (!ep.content) errors.push(`episodes[${i}]: missing content`);
27
+ if (!ep.source || !VALID_SOURCES.has(ep.source)) errors.push(`episodes[${i}]: invalid source "${ep.source}"`);
28
+ }
29
+ for (let i = 0; i < (snapshot.semantics || []).length; i++) {
30
+ const sem = snapshot.semantics[i];
31
+ if (!sem.id) errors.push(`semantics[${i}]: missing id`);
32
+ if (!sem.content) errors.push(`semantics[${i}]: missing content`);
33
+ }
34
+ for (let i = 0; i < (snapshot.procedures || []).length; i++) {
35
+ const proc = snapshot.procedures[i];
36
+ if (!proc.id) errors.push(`procedures[${i}]: missing id`);
37
+ if (!proc.content) errors.push(`procedures[${i}]: missing content`);
38
+ }
39
+ return errors;
40
+ }
41
+
19
42
  export async function importMemories(db, embeddingProvider, snapshot) {
20
43
  if (!isDatabaseEmpty(db)) {
21
44
  throw new Error('Cannot import into a database that is not empty');
22
45
  }
23
46
 
47
+ const validationErrors = validateSnapshot(snapshot);
48
+ if (validationErrors.length > 0) {
49
+ throw new Error(`Invalid snapshot: ${validationErrors.join('; ')}`);
50
+ }
51
+
24
52
  const episodes = snapshot.episodes || [];
25
53
  const semantics = snapshot.semantics || [];
26
54
  const procedures = snapshot.procedures || [];
package/src/llm.js CHANGED
@@ -4,6 +4,8 @@
4
4
  * @property {string} content
5
5
  */
6
6
 
7
+ import { describeHttpError, requireApiKey } from './utils.js';
8
+
7
9
  function extractJSON(text) {
8
10
  const fenced = text.match(/```(?:json)?\s*\n?([\s\S]*?)```/);
9
11
  return fenced ? fenced[1].trim() : text.trim();
@@ -112,6 +114,7 @@ export class AnthropicLLMProvider {
112
114
  * @returns {Promise<LLMCompletionResult>}
113
115
  */
114
116
  async complete(messages, options = {}) {
117
+ requireApiKey(this.apiKey, 'Anthropic LLM', 'ANTHROPIC_API_KEY');
115
118
  const systemMsg = messages.find(m => m.role === 'system')?.content;
116
119
  const nonSystemMsgs = messages.filter(m => m.role !== 'system');
117
120
 
@@ -137,8 +140,7 @@ export class AnthropicLLMProvider {
137
140
  });
138
141
 
139
142
  if (!response.ok) {
140
- const errorBody = await response.text().catch(() => '');
141
- throw new Error(`Anthropic API error: ${response.status} ${errorBody}`);
143
+ throw new Error(`Anthropic API error: ${await describeHttpError(response)}`);
142
144
  }
143
145
 
144
146
  const data = await response.json();
@@ -182,6 +184,7 @@ export class OpenAILLMProvider {
182
184
  * @returns {Promise<LLMCompletionResult>}
183
185
  */
184
186
  async complete(messages, options = {}) {
187
+ requireApiKey(this.apiKey, 'OpenAI LLM', 'OPENAI_API_KEY');
185
188
  const body = {
186
189
  model: this.model,
187
190
  max_tokens: options.maxTokens || this.maxTokens,
@@ -202,7 +205,7 @@ export class OpenAILLMProvider {
202
205
  });
203
206
 
204
207
  if (!response.ok) {
205
- throw new Error(`OpenAI API error: ${response.status}`);
208
+ throw new Error(`OpenAI API error: ${await describeHttpError(response)}`);
206
209
  }
207
210
 
208
211
  const data = await response.json();
package/src/migrate.js CHANGED
@@ -6,7 +6,7 @@ export async function reembedAll(db, embeddingProvider, { dropAndRecreate = fals
6
6
  createVec0Tables(db, embeddingProvider.dimensions);
7
7
  }
8
8
 
9
- const episodes = db.prepare('SELECT id, content, source FROM episodes').all();
9
+ const episodes = db.prepare('SELECT id, content, source, consolidated FROM episodes').all();
10
10
  const semantics = db.prepare('SELECT id, content, state FROM semantics').all();
11
11
  const procedures = db.prepare('SELECT id, content, state FROM procedures').all();
12
12
 
@@ -37,7 +37,7 @@ export async function reembedAll(db, embeddingProvider, { dropAndRecreate = fals
37
37
  const buf = embeddingProvider.vectorToBuffer(episodeVectors[i]);
38
38
  updateEpLegacy.run(buf, episodes[i].id);
39
39
  deleteVecEp.run(episodes[i].id);
40
- insertVecEp.run(episodes[i].id, buf, episodes[i].source, BigInt(0));
40
+ insertVecEp.run(episodes[i].id, buf, episodes[i].source, BigInt(episodes[i].consolidated ?? 0));
41
41
  }
42
42
  for (let i = 0; i < semantics.length; i++) {
43
43
  const buf = embeddingProvider.vectorToBuffer(semanticVectors[i]);
package/src/recall.js CHANGED
@@ -1,8 +1,120 @@
1
- import { computeConfidence, DEFAULT_HALF_LIVES, salienceModifier } from './confidence.js';
1
+ import { computeConfidence, DEFAULT_HALF_LIVES, salienceModifier, sourceReliability } from './confidence.js';
2
2
  import { interferenceModifier } from './interference.js';
3
3
  import { contextMatchRatio, contextModifier } from './context.js';
4
4
  import { moodCongruenceModifier, affectSimilarity } from './affect.js';
5
5
  import { daysBetween, safeJsonParse } from './utils.js';
6
+ import { hasFTSTables, searchFTSEpisodes, searchFTSSemantics, searchFTSProcedures, sanitizeFTSQuery } from './fts.js';
7
+
8
+ const STOPWORDS = new Set([
9
+ 'a', 'an', 'and', 'are', 'at', 'be', 'by', 'did', 'do', 'does', 'for', 'from', 'had', 'has', 'have',
10
+ 'how', 'i', 'in', 'is', 'it', 'me', 'my', 'now', 'of', 'on', 'or', 'our', 's', 'sam', 'she', 'that',
11
+ 'the', 'their', 'them', 'there', 'they', 'this', 'to', 'was', 'we', 'were', 'what', 'when', 'where',
12
+ 'which', 'who', 'why', 'with', 'would', 'you', 'your',
13
+ ]);
14
+
15
+ const IDENTIFIER_TERMS = new Set(['account', 'api', 'credential', 'id', 'identifier', 'key', 'number', 'password', 'secret', 'ssn', 'token']);
16
+
17
+ function tokenize(text) {
18
+ return String(text || '')
19
+ .toLowerCase()
20
+ .replace(/[^a-z0-9]+/g, ' ')
21
+ .trim()
22
+ .split(/\s+/)
23
+ .filter(Boolean);
24
+ }
25
+
26
+ function significantTokens(text) {
27
+ return tokenize(text).filter(token => !STOPWORDS.has(token));
28
+ }
29
+
30
+ function lexicalCoverage(query, content) {
31
+ const queryTokens = significantTokens(query);
32
+ if (queryTokens.length === 0) return 1;
33
+ const contentTokens = new Set(significantTokens(content));
34
+ let matched = 0;
35
+ for (const token of queryTokens) {
36
+ if (contentTokens.has(token)) matched++;
37
+ }
38
+ return matched / queryTokens.length;
39
+ }
40
+
41
+ function hasIdentifierIntent(query) {
42
+ const normalized = String(query || '').toLowerCase();
43
+ const asksForValue = /\b(find|give|lookup|show|tell|what|which)\b/.test(normalized);
44
+ const mentionsIdentifier = /\b(account number|api key|credential|id|identifier|key|number|passport number|password|secret|ssn|token)\b/.test(normalized);
45
+ return asksForValue && mentionsIdentifier;
46
+ }
47
+
48
+ function hasIdentifierEvidence(content) {
49
+ const tokens = significantTokens(content);
50
+ if (tokens.some(token => IDENTIFIER_TERMS.has(token))) {
51
+ return true;
52
+ }
53
+ return /(?:\b\d{4,}\b|sk-[a-z0-9_-]+)/i.test(content);
54
+ }
55
+
56
+ function adjustedScore(query, entry) {
57
+ const coverage = lexicalCoverage(query, entry.content);
58
+ let score = entry.score;
59
+
60
+ if (hasIdentifierIntent(query) && !hasIdentifierEvidence(entry.content)) {
61
+ score *= 0.02;
62
+ }
63
+
64
+ return { score, coverage };
65
+ }
66
+
67
+ function overlapRatio(contentA, contentB) {
68
+ const tokensA = significantTokens(contentA);
69
+ const tokensB = significantTokens(contentB);
70
+ if (tokensA.length === 0 || tokensB.length === 0) return 0;
71
+ const setB = new Set(tokensB);
72
+ let matched = 0;
73
+ for (const token of tokensA) {
74
+ if (setB.has(token)) matched++;
75
+ }
76
+ return matched / Math.min(tokensA.length, tokensB.length);
77
+ }
78
+
79
+ function reliabilityForRecallSource(source) {
80
+ if (source === 'consolidation') {
81
+ return sourceReliability('tool-result');
82
+ }
83
+ return sourceReliability(source);
84
+ }
85
+
86
+ function shouldSuppressDuplicate(existing, candidate) {
87
+ const overlap = overlapRatio(existing.content, candidate.content);
88
+ if (overlap < 0.5) return false;
89
+ if (existing.type !== candidate.type) return false;
90
+ const existingReliability = reliabilityForRecallSource(existing.source);
91
+ const candidateReliability = reliabilityForRecallSource(candidate.source);
92
+ if (existingReliability < candidateReliability) return false;
93
+ if (existingReliability - candidateReliability < 0.2) return false;
94
+ return existing.score >= candidate.score * 0.95;
95
+ }
96
+
97
+ function applyResultGuards(query, results, limit) {
98
+ const identifierIntent = hasIdentifierIntent(query);
99
+ const rescored = results
100
+ .map(entry => {
101
+ const { score, coverage } = adjustedScore(query, entry);
102
+ return { ...entry, score, lexicalCoverage: coverage };
103
+ })
104
+ .filter(entry => !identifierIntent || entry.score > 0.05)
105
+ .sort((a, b) => b.score - a.score);
106
+
107
+ const accepted = [];
108
+ for (const candidate of rescored) {
109
+ if (accepted.some(existing => shouldSuppressDuplicate(existing, candidate))) {
110
+ continue;
111
+ }
112
+ accepted.push(candidate);
113
+ if (accepted.length >= limit) break;
114
+ }
115
+
116
+ return accepted;
117
+ }
6
118
 
7
119
  function computeEpisodicConfidence(ep, now, confidenceConfig = {}) {
8
120
  const ageDays = daysBetween(ep.created_at, now);
@@ -75,6 +187,7 @@ function buildEpisodicEntry(ep, confidence, score, includeProvenance, contextMat
75
187
  score,
76
188
  source: ep.source,
77
189
  createdAt: ep.created_at,
190
+ agent: ep.agent || 'default',
78
191
  };
79
192
  if (contextMatch !== undefined) {
80
193
  entry.contextMatch = contextMatch;
@@ -103,6 +216,7 @@ function buildSemanticEntry(sem, confidence, score, includeProvenance) {
103
216
  source: 'consolidation',
104
217
  state: sem.state,
105
218
  createdAt: sem.created_at,
219
+ agent: sem.agent || 'default',
106
220
  };
107
221
  if (includeProvenance) {
108
222
  entry.provenance = {
@@ -126,6 +240,7 @@ function buildProceduralEntry(proc, confidence, score, includeProvenance) {
126
240
  source: 'consolidation',
127
241
  state: proc.state,
128
242
  createdAt: proc.created_at,
243
+ agent: proc.agent || 'default',
129
244
  };
130
245
  if (includeProvenance) {
131
246
  entry.provenance = {
@@ -155,10 +270,12 @@ function safeKForTable(db, table, candidateK) {
155
270
  return rowCount > 0 ? Math.min(candidateK, rowCount) : 0;
156
271
  }
157
272
 
158
- function knnEpisodic(db, queryBuffer, candidateK, now, minConfidence, includeProvenance, confidenceConfig, filters = {}, includePrivate = false) {
273
+ function knnEpisodic(db, queryBuffer, candidateK, now, minConfidence, includeProvenance, confidenceConfig, filters = {}, includePrivate = false, agentFilter = null) {
159
274
  const safeK = safeKForTable(db, 'vec_episodes', candidateK);
160
275
  if (safeK === 0) return [];
161
276
  const privateClause = includePrivate ? '' : 'AND e."private" = 0';
277
+ const agentClause = agentFilter ? 'AND e.agent = ?' : '';
278
+ const params = agentFilter ? [queryBuffer, safeK, agentFilter] : [queryBuffer, safeK];
162
279
  const rows = db.prepare(`
163
280
  SELECT e.*, (1.0 - v.distance) AS similarity
164
281
  FROM vec_episodes v
@@ -167,7 +284,8 @@ function knnEpisodic(db, queryBuffer, candidateK, now, minConfidence, includePro
167
284
  AND k = ?
168
285
  AND e.superseded_by IS NULL
169
286
  ${privateClause}
170
- `).all(queryBuffer, safeK);
287
+ ${agentClause}
288
+ `).all(...params);
171
289
 
172
290
  const results = [];
173
291
  for (const row of rows) {
@@ -202,9 +320,11 @@ function knnEpisodic(db, queryBuffer, candidateK, now, minConfidence, includePro
202
320
  return results;
203
321
  }
204
322
 
205
- function knnSemantic(db, queryBuffer, candidateK, now, minConfidence, includeProvenance, includeDormant, confidenceConfig, filters = {}) {
323
+ function knnSemantic(db, queryBuffer, candidateK, now, minConfidence, includeProvenance, includeDormant, confidenceConfig, filters = {}, agentFilter = null) {
206
324
  const safeK = safeKForTable(db, 'vec_semantics', candidateK);
207
325
  if (safeK === 0) return { results: [], matchedIds: [] };
326
+ const agentClause = agentFilter ? 'AND s.agent = ?' : '';
327
+ const params = agentFilter ? [queryBuffer, safeK, agentFilter] : [queryBuffer, safeK];
208
328
  const rows = db.prepare(`
209
329
  SELECT s.*, (1.0 - v.distance) AS similarity
210
330
  FROM vec_semantics v
@@ -212,7 +332,8 @@ function knnSemantic(db, queryBuffer, candidateK, now, minConfidence, includePro
212
332
  WHERE v.embedding MATCH ?
213
333
  AND k = ?
214
334
  ${stateClause(includeDormant)}
215
- `).all(queryBuffer, safeK);
335
+ ${agentClause}
336
+ `).all(...params);
216
337
 
217
338
  const results = [];
218
339
  const matchedIds = [];
@@ -227,9 +348,11 @@ function knnSemantic(db, queryBuffer, candidateK, now, minConfidence, includePro
227
348
  return { results, matchedIds };
228
349
  }
229
350
 
230
- function knnProcedural(db, queryBuffer, candidateK, now, minConfidence, includeProvenance, includeDormant, confidenceConfig, filters = {}) {
351
+ function knnProcedural(db, queryBuffer, candidateK, now, minConfidence, includeProvenance, includeDormant, confidenceConfig, filters = {}, agentFilter = null) {
231
352
  const safeK = safeKForTable(db, 'vec_procedures', candidateK);
232
353
  if (safeK === 0) return { results: [], matchedIds: [] };
354
+ const agentClause = agentFilter ? 'AND p.agent = ?' : '';
355
+ const params = agentFilter ? [queryBuffer, safeK, agentFilter] : [queryBuffer, safeK];
233
356
  const rows = db.prepare(`
234
357
  SELECT p.*, (1.0 - v.distance) AS similarity
235
358
  FROM vec_procedures v
@@ -237,7 +360,8 @@ function knnProcedural(db, queryBuffer, candidateK, now, minConfidence, includeP
237
360
  WHERE v.embedding MATCH ?
238
361
  AND k = ?
239
362
  ${stateClause(includeDormant)}
240
- `).all(queryBuffer, safeK);
363
+ ${agentClause}
364
+ `).all(...params);
241
365
 
242
366
  const results = [];
243
367
  const matchedIds = [];
@@ -252,14 +376,7 @@ function knnProcedural(db, queryBuffer, candidateK, now, minConfidence, includeP
252
376
  return { results, matchedIds };
253
377
  }
254
378
 
255
- /**
256
- * @param {import('better-sqlite3').Database} db
257
- * @param {import('./embedding.js').EmbeddingProvider} embeddingProvider
258
- * @param {string} query
259
- * @param {{ minConfidence?: number, types?: string[], limit?: number, includeProvenance?: boolean, includeDormant?: boolean, tags?: string[], sources?: string[], after?: string, before?: string }} [options]
260
- * @returns {AsyncGenerator<{ id: string, content: string, type: string, confidence: number, score: number, source: string, createdAt: string }>}
261
- */
262
- export async function* recallStream(db, embeddingProvider, query, options = {}) {
379
+ async function runRecallQuery(db, embeddingProvider, query, options = {}) {
263
380
  const {
264
381
  minConfidence = 0,
265
382
  types,
@@ -272,31 +389,76 @@ export async function* recallStream(db, embeddingProvider, query, options = {})
272
389
  after,
273
390
  before,
274
391
  includePrivate = false,
392
+ scope = 'shared',
393
+ agent,
394
+ retrieval = 'hybrid',
275
395
  } = options;
276
396
 
277
- const queryVector = await embeddingProvider.embed(query);
278
- const queryBuffer = embeddingProvider.vectorToBuffer(queryVector);
279
397
  const searchTypes = types || ['episodic', 'semantic', 'procedural'];
280
398
  const now = new Date();
399
+ const agentFilter = scope === 'agent' && agent ? agent : null;
400
+
401
+ // Keyword-only mode: FTS5 search without vector embeddings
402
+ if (retrieval === 'keyword') {
403
+ const ftsAvailable = hasFTSTables(db);
404
+ if (!ftsAvailable) {
405
+ return { top: [], errors: [] };
406
+ }
407
+ const sanitized = sanitizeFTSQuery(query);
408
+ if (!sanitized) return { top: [], errors: [] };
409
+
410
+ const keywordResults = [];
411
+ try {
412
+ if (searchTypes.includes('episodic')) {
413
+ for (const row of searchFTSEpisodes(db, sanitized, limit * 3, agentFilter)) {
414
+ keywordResults.push({ id: row.id, content: row.content, type: 'episodic', score: -row.rank, agent: row.agent || 'default' });
415
+ }
416
+ }
417
+ if (searchTypes.includes('semantic')) {
418
+ for (const row of searchFTSSemantics(db, sanitized, limit * 3, agentFilter)) {
419
+ keywordResults.push({ id: row.id, content: row.content, type: 'semantic', score: -row.rank, agent: row.agent || 'default' });
420
+ }
421
+ }
422
+ if (searchTypes.includes('procedural')) {
423
+ for (const row of searchFTSProcedures(db, sanitized, limit * 3, agentFilter)) {
424
+ keywordResults.push({ id: row.id, content: row.content, type: 'procedural', score: -row.rank, agent: row.agent || 'default' });
425
+ }
426
+ }
427
+ } catch {
428
+ // FTS query syntax error — fall through with whatever we have
429
+ }
430
+ keywordResults.sort((a, b) => b.score - a.score);
431
+ const top = keywordResults.slice(0, limit).map(entry => ({
432
+ ...entry,
433
+ confidence: 1,
434
+ source: 'keyword',
435
+ createdAt: now.toISOString(),
436
+ }));
437
+ return { top, errors: [] };
438
+ }
439
+
440
+ const queryVector = await embeddingProvider.embed(query);
441
+ const queryBuffer = embeddingProvider.vectorToBuffer(queryVector);
281
442
  const hasFilters = tags?.length || sources?.length || after || before;
282
443
  const candidateK = hasFilters ? limit * 5 : limit * 3;
283
444
  const filters = { tags, sources, after, before };
284
445
 
285
446
  const allResults = [];
447
+ const errors = [];
286
448
 
287
449
  if (searchTypes.includes('episodic')) {
288
450
  try {
289
- const episodic = knnEpisodic(db, queryBuffer, candidateK, now, minConfidence, includeProvenance, confidenceConfig, filters, includePrivate);
451
+ const episodic = knnEpisodic(db, queryBuffer, candidateK, now, minConfidence, includeProvenance, confidenceConfig, filters, includePrivate, agentFilter);
290
452
  allResults.push(...episodic);
291
- } catch {
292
- // A broken episodic index should not block semantic/procedural recall.
453
+ } catch (err) {
454
+ errors.push({ type: 'episodic', message: err.message });
293
455
  }
294
456
  }
295
457
 
296
458
  if (searchTypes.includes('semantic')) {
297
459
  try {
298
460
  const { results: semResults, matchedIds: semIds } =
299
- knnSemantic(db, queryBuffer, candidateK, now, minConfidence, includeProvenance, includeDormant, confidenceConfig, filters);
461
+ knnSemantic(db, queryBuffer, candidateK, now, minConfidence, includeProvenance, includeDormant, confidenceConfig, filters, agentFilter);
300
462
  allResults.push(...semResults);
301
463
 
302
464
  if (semIds.length > 0) {
@@ -306,15 +468,15 @@ export async function* recallStream(db, embeddingProvider, query, options = {})
306
468
  `UPDATE semantics SET retrieval_count = retrieval_count + 1, last_reinforced_at = ? WHERE id IN (${placeholders})`
307
469
  ).run(nowISO, ...semIds);
308
470
  }
309
- } catch {
310
- // A broken semantic index should not block other memory types.
471
+ } catch (err) {
472
+ errors.push({ type: 'semantic', message: err.message });
311
473
  }
312
474
  }
313
475
 
314
476
  if (searchTypes.includes('procedural')) {
315
477
  try {
316
478
  const { results: procResults, matchedIds: procIds } =
317
- knnProcedural(db, queryBuffer, candidateK, now, minConfidence, includeProvenance, includeDormant, confidenceConfig, filters);
479
+ knnProcedural(db, queryBuffer, candidateK, now, minConfidence, includeProvenance, includeDormant, confidenceConfig, filters, agentFilter);
318
480
  allResults.push(...procResults);
319
481
 
320
482
  if (procIds.length > 0) {
@@ -324,14 +486,73 @@ export async function* recallStream(db, embeddingProvider, query, options = {})
324
486
  `UPDATE procedures SET retrieval_count = retrieval_count + 1, last_reinforced_at = ? WHERE id IN (${placeholders})`
325
487
  ).run(nowISO, ...procIds);
326
488
  }
327
- } catch {
328
- // A broken procedural index should not block other memory types.
489
+ } catch (err) {
490
+ errors.push({ type: 'procedural', message: err.message });
329
491
  }
330
492
  }
331
493
 
332
- allResults.sort((a, b) => b.score - a.score);
333
- const top = allResults.slice(0, limit);
494
+ // Hybrid mode: merge vector results with FTS5 keyword results via RRF
495
+ if (retrieval === 'hybrid' && hasFTSTables(db)) {
496
+ const sanitized = sanitizeFTSQuery(query);
497
+ if (sanitized) {
498
+ const keywordHits = new Map();
499
+ try {
500
+ if (searchTypes.includes('episodic')) {
501
+ for (const row of searchFTSEpisodes(db, sanitized, limit * 3, agentFilter)) {
502
+ keywordHits.set(row.id, (keywordHits.get(row.id) || 0) + 1);
503
+ }
504
+ }
505
+ if (searchTypes.includes('semantic')) {
506
+ for (const row of searchFTSSemantics(db, sanitized, limit * 3, agentFilter)) {
507
+ keywordHits.set(row.id, (keywordHits.get(row.id) || 0) + 1);
508
+ }
509
+ }
510
+ if (searchTypes.includes('procedural')) {
511
+ for (const row of searchFTSProcedures(db, sanitized, limit * 3, agentFilter)) {
512
+ keywordHits.set(row.id, (keywordHits.get(row.id) || 0) + 1);
513
+ }
514
+ }
515
+ } catch {
516
+ // FTS query error — continue with vector-only results
517
+ }
518
+
519
+ // RRF boost: memories found by both vector AND keyword get a score bonus
520
+ const RRF_K = 60;
521
+ if (keywordHits.size > 0) {
522
+ // Rank keyword results by their BM25 order
523
+ const keywordRanks = new Map();
524
+ let rank = 1;
525
+ for (const id of keywordHits.keys()) {
526
+ keywordRanks.set(id, rank++);
527
+ }
528
+
529
+ for (const result of allResults) {
530
+ if (keywordRanks.has(result.id)) {
531
+ // Boost score for results found by both vector AND keyword search
532
+ const kRank = keywordRanks.get(result.id);
533
+ const rrfBoost = 1 / (RRF_K + kRank);
534
+ result.score = result.score + rrfBoost;
535
+ }
536
+ }
537
+ }
538
+ }
539
+ }
540
+
541
+ const top = applyResultGuards(query, allResults, limit);
542
+ return { top, errors };
543
+ }
544
+
545
+ /**
546
+ * @param {import('better-sqlite3').Database} db
547
+ * @param {import('./embedding.js').EmbeddingProvider} embeddingProvider
548
+ * @param {string} query
549
+ * @param {{ minConfidence?: number, types?: string[], limit?: number, includeProvenance?: boolean, includeDormant?: boolean, tags?: string[], sources?: string[], after?: string, before?: string }} [options]
550
+ * @returns {AsyncGenerator<{ id: string, content: string, type: string, confidence: number, score: number, source: string, createdAt: string }>}
551
+ */
552
+ export async function* recallStream(db, embeddingProvider, query, options = {}) {
553
+ const { top, errors } = await runRecallQuery(db, embeddingProvider, query, options);
334
554
  for (const entry of top) {
555
+ if (errors.length > 0) entry._recallErrors = errors;
335
556
  yield entry;
336
557
  }
337
558
  }
@@ -344,9 +565,9 @@ export async function* recallStream(db, embeddingProvider, query, options = {})
344
565
  * @returns {Promise<Array<{ id: string, content: string, type: string, confidence: number, score: number, source: string, createdAt: string }>>}
345
566
  */
346
567
  export async function recall(db, embeddingProvider, query, options = {}) {
347
- const results = [];
348
- for await (const entry of recallStream(db, embeddingProvider, query, options)) {
349
- results.push(entry);
350
- }
568
+ const { top, errors } = await runRecallQuery(db, embeddingProvider, query, options);
569
+ const results = [...top];
570
+ results.partialFailure = errors.length > 0;
571
+ results.errors = errors;
351
572
  return results;
352
573
  }
package/src/utils.js CHANGED
@@ -36,3 +36,28 @@ export function safeJsonParse(str, fallback = null) {
36
36
  try { return JSON.parse(str); }
37
37
  catch { return fallback; }
38
38
  }
39
+
40
+ /**
41
+ * @param {string | undefined | null} apiKey
42
+ * @param {string} operation
43
+ * @param {string} envVar
44
+ * @returns {void}
45
+ */
46
+ export function requireApiKey(apiKey, operation, envVar) {
47
+ if (typeof apiKey !== 'string' || apiKey.trim() === '') {
48
+ throw new Error(`${operation} requires ${envVar}`);
49
+ }
50
+ }
51
+
52
+ /**
53
+ * @param {{ status: number, text: () => Promise<string> }} response
54
+ * @returns {Promise<string>}
55
+ */
56
+ export async function describeHttpError(response) {
57
+ if (typeof response.text !== 'function') {
58
+ return `${response.status}`;
59
+ }
60
+ const body = await response.text().catch(() => '');
61
+ const normalized = body.replace(/\s+/g, ' ').trim().slice(0, 300);
62
+ return normalized ? `${response.status} ${normalized}` : `${response.status}`;
63
+ }