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/LICENSE +21 -21
- package/README.md +310 -643
- package/benchmarks/baselines.js +169 -0
- package/benchmarks/cases.js +421 -0
- package/benchmarks/reference-results.js +70 -0
- package/benchmarks/report.js +255 -0
- package/benchmarks/run.js +514 -0
- package/docs/assets/benchmarks/local-benchmark.svg +45 -0
- package/docs/assets/benchmarks/operations-benchmark.svg +45 -0
- package/docs/assets/benchmarks/published-memory-standards.svg +50 -0
- package/docs/benchmarking.md +151 -0
- package/docs/production-readiness.md +96 -0
- package/examples/fintech-ops-demo.js +67 -0
- package/examples/healthcare-ops-demo.js +67 -0
- package/examples/stripe-demo.js +105 -0
- package/mcp-server/config.js +81 -24
- package/mcp-server/index.js +611 -75
- package/mcp-server/serve.js +482 -0
- package/package.json +24 -5
- package/src/audrey.js +51 -13
- package/src/consolidate.js +70 -54
- package/src/db.js +22 -1
- package/src/embedding.js +16 -12
- package/src/encode.js +8 -2
- package/src/fts.js +134 -0
- package/src/import.js +28 -0
- package/src/llm.js +6 -3
- package/src/migrate.js +2 -2
- package/src/recall.js +253 -32
- package/src/utils.js +25 -0
- package/types/index.d.ts +434 -0
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
489
|
+
} catch (err) {
|
|
490
|
+
errors.push({ type: 'procedural', message: err.message });
|
|
329
491
|
}
|
|
330
492
|
}
|
|
331
493
|
|
|
332
|
-
|
|
333
|
-
|
|
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
|
|
348
|
-
|
|
349
|
-
|
|
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
|
+
}
|