@shadowforge0/aquifer-memory 0.2.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/README.md +354 -0
- package/consumers/cli.js +314 -0
- package/consumers/mcp.js +135 -0
- package/consumers/openclaw-plugin.js +235 -0
- package/consumers/shared/config.js +143 -0
- package/consumers/shared/factory.js +77 -0
- package/consumers/shared/llm.js +119 -0
- package/core/aquifer.js +634 -0
- package/core/entity.js +360 -0
- package/core/hybrid-rank.js +166 -0
- package/core/storage.js +550 -0
- package/index.js +6 -0
- package/package.json +57 -0
- package/pipeline/embed.js +230 -0
- package/pipeline/extract-entities.js +73 -0
- package/pipeline/summarize.js +245 -0
- package/schema/001-base.sql +180 -0
- package/schema/002-entities.sql +120 -0
package/core/aquifer.js
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Pool } = require('pg');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
|
|
7
|
+
const storage = require('./storage');
|
|
8
|
+
const entity = require('./entity');
|
|
9
|
+
const { hybridRank } = require('./hybrid-rank');
|
|
10
|
+
const { summarize } = require('../pipeline/summarize');
|
|
11
|
+
const { extractEntities } = require('../pipeline/extract-entities');
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Schema name validation
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
const SCHEMA_RE = /^[a-zA-Z_]\w{0,62}$/;
|
|
18
|
+
|
|
19
|
+
function validateSchema(schema) {
|
|
20
|
+
if (!SCHEMA_RE.test(schema)) {
|
|
21
|
+
throw new Error(`Invalid schema name: "${schema}". Must match /^[a-zA-Z_]\\w{0,62}$/`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// C1 fix: quote identifiers to handle reserved words safely
|
|
26
|
+
function qi(identifier) { return `"${identifier}"`; }
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// SQL file loader — replaces ${schema} placeholders
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
function loadSql(filename, schema) {
|
|
33
|
+
const filePath = path.join(__dirname, '..', 'schema', filename);
|
|
34
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
35
|
+
// C1: use quoted identifier for safety
|
|
36
|
+
return raw.replace(/\$\{schema\}/g, qi(schema));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// createAquifer
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
function createAquifer(config) {
|
|
44
|
+
if (!config || !config.db) {
|
|
45
|
+
throw new Error('config.db (pg.Pool or connection string) is required');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const schema = config.schema || 'aquifer';
|
|
49
|
+
validateSchema(schema);
|
|
50
|
+
|
|
51
|
+
if (config.tenantId === '') throw new Error('config.tenantId must not be empty');
|
|
52
|
+
const tenantId = config.tenantId || 'default';
|
|
53
|
+
|
|
54
|
+
// Pool management
|
|
55
|
+
let pool;
|
|
56
|
+
let ownsPool = false;
|
|
57
|
+
if (typeof config.db === 'string') {
|
|
58
|
+
pool = new Pool({ connectionString: config.db });
|
|
59
|
+
ownsPool = true;
|
|
60
|
+
} else {
|
|
61
|
+
pool = config.db;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Embed config (lazy — only required for recall/enrich)
|
|
65
|
+
const embedFn = config.embed && typeof config.embed.fn === 'function' ? config.embed.fn : null;
|
|
66
|
+
let embedDim = config.embed ? (config.embed.dim || null) : null;
|
|
67
|
+
|
|
68
|
+
function requireEmbed(op) {
|
|
69
|
+
if (!embedFn) throw new Error(`Aquifer.${op}() requires config.embed.fn (async (texts) => number[][])`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// LLM config (optional — only needed for enrich with built-in summarize)
|
|
73
|
+
const llmFn = config.llm && typeof config.llm.fn === 'function' ? config.llm.fn : null;
|
|
74
|
+
|
|
75
|
+
// Summarize config
|
|
76
|
+
const summarizePromptFn = config.summarize && config.summarize.prompt ? config.summarize.prompt : null;
|
|
77
|
+
|
|
78
|
+
// Entity config
|
|
79
|
+
let entitiesEnabled = config.entities && config.entities.enabled === true;
|
|
80
|
+
const mergeCall = config.entities && config.entities.mergeCall !== undefined ? config.entities.mergeCall : true;
|
|
81
|
+
const entityPromptFn = config.entities && config.entities.prompt ? config.entities.prompt : null;
|
|
82
|
+
|
|
83
|
+
// Rank weights
|
|
84
|
+
const rankWeights = {
|
|
85
|
+
rrf: 0.65,
|
|
86
|
+
timeDecay: 0.25,
|
|
87
|
+
access: 0.10,
|
|
88
|
+
entityBoost: 0.18,
|
|
89
|
+
...(config.rank || {}),
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Source registry (in-memory)
|
|
93
|
+
const sources = new Map();
|
|
94
|
+
|
|
95
|
+
// Track if migrate was called
|
|
96
|
+
let migrated = false;
|
|
97
|
+
|
|
98
|
+
// --- Helper: embed search on summaries ---
|
|
99
|
+
async function embeddingSearchSummaries(queryVec, opts) {
|
|
100
|
+
const { agentId, source, dateFrom, dateTo, limit = 20 } = opts;
|
|
101
|
+
const where = [`s.tenant_id = $1`];
|
|
102
|
+
const params = [tenantId];
|
|
103
|
+
|
|
104
|
+
params.push(`[${queryVec.join(',')}]`);
|
|
105
|
+
const vecPos = params.length;
|
|
106
|
+
|
|
107
|
+
if (dateFrom) {
|
|
108
|
+
params.push(dateFrom);
|
|
109
|
+
where.push(`($${params.length}::date IS NULL OR s.started_at::date >= $${params.length}::date)`);
|
|
110
|
+
}
|
|
111
|
+
if (dateTo) {
|
|
112
|
+
params.push(dateTo);
|
|
113
|
+
where.push(`($${params.length}::date IS NULL OR s.started_at::date <= $${params.length}::date)`);
|
|
114
|
+
}
|
|
115
|
+
if (agentId) {
|
|
116
|
+
params.push(agentId);
|
|
117
|
+
where.push(`s.agent_id = $${params.length}`);
|
|
118
|
+
}
|
|
119
|
+
if (source) {
|
|
120
|
+
params.push(source);
|
|
121
|
+
where.push(`s.source = $${params.length}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
params.push(limit);
|
|
125
|
+
|
|
126
|
+
const result = await pool.query(
|
|
127
|
+
`SELECT
|
|
128
|
+
s.id, s.session_id, s.agent_id, s.source, s.started_at, s.last_message_at,
|
|
129
|
+
ss.summary_text, ss.structured_summary, ss.access_count, ss.last_accessed_at,
|
|
130
|
+
(ss.embedding <=> $${vecPos}::vector) AS distance
|
|
131
|
+
FROM ${qi(schema)}.session_summaries ss
|
|
132
|
+
JOIN ${qi(schema)}.sessions s ON s.id = ss.session_row_id
|
|
133
|
+
WHERE ss.embedding IS NOT NULL
|
|
134
|
+
AND ${where.join(' AND ')}
|
|
135
|
+
ORDER BY distance ASC
|
|
136
|
+
LIMIT $${params.length}`,
|
|
137
|
+
params
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
return result.rows;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// =========================================================================
|
|
144
|
+
// Public API
|
|
145
|
+
// =========================================================================
|
|
146
|
+
|
|
147
|
+
const aquifer = {
|
|
148
|
+
// --- lifecycle ---
|
|
149
|
+
|
|
150
|
+
async migrate() {
|
|
151
|
+
// 1. Run base DDL
|
|
152
|
+
const baseSql = loadSql('001-base.sql', schema);
|
|
153
|
+
await pool.query(baseSql);
|
|
154
|
+
|
|
155
|
+
// 2. If entities enabled, run entity DDL
|
|
156
|
+
if (entitiesEnabled) {
|
|
157
|
+
const entitySql = loadSql('002-entities.sql', schema);
|
|
158
|
+
await pool.query(entitySql);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
migrated = true;
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
async close() {
|
|
165
|
+
if (ownsPool) {
|
|
166
|
+
await pool.end();
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
// --- source registration ---
|
|
171
|
+
|
|
172
|
+
registerSource(name, opts = {}) {
|
|
173
|
+
sources.set(name, {
|
|
174
|
+
type: opts.type || 'custom',
|
|
175
|
+
search: opts.search || null,
|
|
176
|
+
weight: opts.weight !== null && opts.weight !== undefined ? opts.weight : 1.0,
|
|
177
|
+
});
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
async enableEntities() {
|
|
181
|
+
entitiesEnabled = true;
|
|
182
|
+
// M4: if already migrated, run entity DDL now
|
|
183
|
+
if (migrated) {
|
|
184
|
+
const entitySql = loadSql('002-entities.sql', schema);
|
|
185
|
+
await pool.query(entitySql);
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
// --- write path ---
|
|
190
|
+
|
|
191
|
+
async commit(sessionId, messages, opts = {}) {
|
|
192
|
+
if (!sessionId) throw new Error('sessionId is required');
|
|
193
|
+
if (!messages || !Array.isArray(messages)) throw new Error('messages must be an array');
|
|
194
|
+
|
|
195
|
+
const agentId = opts.agentId || 'agent';
|
|
196
|
+
const source = opts.source || 'api';
|
|
197
|
+
|
|
198
|
+
// Count messages
|
|
199
|
+
let msgCount = messages.length;
|
|
200
|
+
let userCount = 0;
|
|
201
|
+
let assistantCount = 0;
|
|
202
|
+
for (const m of messages) {
|
|
203
|
+
if (m.role === 'user') userCount++;
|
|
204
|
+
else if (m.role === 'assistant') assistantCount++;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// rawMessages: pass through a pre-built messages payload without wrapping
|
|
208
|
+
const messagesPayload = opts.rawMessages || { normalized: messages };
|
|
209
|
+
|
|
210
|
+
const result = await storage.upsertSession(pool, {
|
|
211
|
+
schema,
|
|
212
|
+
tenantId,
|
|
213
|
+
sessionId,
|
|
214
|
+
sessionKey: opts.sessionKey || null,
|
|
215
|
+
agentId,
|
|
216
|
+
source,
|
|
217
|
+
messages: messagesPayload,
|
|
218
|
+
msgCount,
|
|
219
|
+
userCount,
|
|
220
|
+
assistantCount,
|
|
221
|
+
model: opts.model || null,
|
|
222
|
+
tokensIn: opts.tokensIn || 0,
|
|
223
|
+
tokensOut: opts.tokensOut || 0,
|
|
224
|
+
startedAt: opts.startedAt || null,
|
|
225
|
+
lastMessageAt: opts.lastMessageAt || null,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
id: result.id,
|
|
230
|
+
sessionId: result.sessionId,
|
|
231
|
+
isNew: result.isNew,
|
|
232
|
+
};
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
// --- enrichment ---
|
|
236
|
+
|
|
237
|
+
async enrich(sessionId, opts = {}) {
|
|
238
|
+
const agentId = opts.agentId || 'agent';
|
|
239
|
+
const skipSummary = opts.skipSummary || false;
|
|
240
|
+
const skipTurnEmbed = opts.skipTurnEmbed || false;
|
|
241
|
+
const skipEntities = opts.skipEntities || false;
|
|
242
|
+
|
|
243
|
+
// Custom hooks: let callers bring their own summarize/entity pipeline
|
|
244
|
+
const customSummaryFn = opts.summaryFn || null; // async (messages) => { summaryText, structuredSummary, entityRaw?, extra? }
|
|
245
|
+
const customEntityParseFn = opts.entityParseFn || null; // (text) => [{ name, normalizedName, aliases, type }]
|
|
246
|
+
|
|
247
|
+
// 1. Optimistic lock: claim session for processing
|
|
248
|
+
const claimResult = await pool.query(
|
|
249
|
+
`UPDATE ${qi(schema)}.sessions
|
|
250
|
+
SET processing_status = 'processing'
|
|
251
|
+
WHERE session_id = $1 AND agent_id = $2 AND tenant_id = $3
|
|
252
|
+
AND processing_status IN ('pending', 'failed')
|
|
253
|
+
RETURNING *`,
|
|
254
|
+
[sessionId, agentId, tenantId]
|
|
255
|
+
);
|
|
256
|
+
const session = claimResult.rows[0];
|
|
257
|
+
if (!session) {
|
|
258
|
+
// Check if session exists but is already processing/succeeded
|
|
259
|
+
const existing = await storage.getSession(pool, sessionId, agentId, {}, { schema, tenantId });
|
|
260
|
+
if (!existing) throw new Error(`Session not found: ${sessionId} (agentId=${agentId})`);
|
|
261
|
+
if (existing.processing_status === 'processing') throw new Error(`Session ${sessionId} is already being enriched`);
|
|
262
|
+
if (existing.processing_status === 'succeeded') throw new Error(`Session ${sessionId} is already enriched. Re-commit to reset.`);
|
|
263
|
+
throw new Error(`Session ${sessionId} has unexpected status: ${existing.processing_status}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const rawMessages = session.messages;
|
|
267
|
+
const messages = rawMessages
|
|
268
|
+
? (typeof rawMessages === 'string' ? JSON.parse(rawMessages) : rawMessages)
|
|
269
|
+
: null;
|
|
270
|
+
const normalized = messages ? (messages.normalized || messages) : [];
|
|
271
|
+
|
|
272
|
+
// 2. Extract user turns
|
|
273
|
+
const turns = storage.extractUserTurns(normalized);
|
|
274
|
+
|
|
275
|
+
// 3. Summarize (custom or built-in)
|
|
276
|
+
let summaryResult = null;
|
|
277
|
+
let entityRaw = null;
|
|
278
|
+
let extra = null;
|
|
279
|
+
|
|
280
|
+
if (!skipSummary && normalized.length > 0) {
|
|
281
|
+
if (customSummaryFn) {
|
|
282
|
+
// Custom pipeline: caller handles LLM call and parsing
|
|
283
|
+
summaryResult = await customSummaryFn(normalized);
|
|
284
|
+
if (summaryResult.entityRaw) entityRaw = summaryResult.entityRaw;
|
|
285
|
+
if (summaryResult.extra) extra = summaryResult.extra;
|
|
286
|
+
} else {
|
|
287
|
+
// Built-in pipeline
|
|
288
|
+
const doMergeEntities = entitiesEnabled && mergeCall && !skipEntities;
|
|
289
|
+
summaryResult = await summarize(normalized, {
|
|
290
|
+
llmFn,
|
|
291
|
+
promptFn: summarizePromptFn,
|
|
292
|
+
mergeEntities: doMergeEntities,
|
|
293
|
+
});
|
|
294
|
+
if (summaryResult.entityRaw) {
|
|
295
|
+
entityRaw = summaryResult.entityRaw;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 4. Pre-compute all LLM/embed results BEFORE opening transaction
|
|
301
|
+
// (avoids holding pool connection during slow LLM/embed calls)
|
|
302
|
+
const warnings = [];
|
|
303
|
+
let summaryEmbedding = null;
|
|
304
|
+
let turnVectors = null;
|
|
305
|
+
let parsedEntities = [];
|
|
306
|
+
|
|
307
|
+
// 4a. Summary embedding
|
|
308
|
+
if (summaryResult && summaryResult.summaryText) {
|
|
309
|
+
try {
|
|
310
|
+
const embResult = await embedFn([summaryResult.summaryText]);
|
|
311
|
+
summaryEmbedding = embResult[0] || null;
|
|
312
|
+
} catch (e) { warnings.push(`summary embed failed: ${e.message}`); }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// 4b. Turn embeddings
|
|
316
|
+
if (!skipTurnEmbed && turns.length > 0) {
|
|
317
|
+
try {
|
|
318
|
+
turnVectors = await embedFn(turns.map(t => t.text));
|
|
319
|
+
} catch (e) { warnings.push(`turn embed failed: ${e.message}`); }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// 4c. Entity extraction (custom parser or built-in)
|
|
323
|
+
if (entitiesEnabled && !skipEntities) {
|
|
324
|
+
try {
|
|
325
|
+
if (entityRaw && customEntityParseFn) {
|
|
326
|
+
parsedEntities = customEntityParseFn(entityRaw);
|
|
327
|
+
} else if (entityRaw) {
|
|
328
|
+
parsedEntities = entity.parseEntityOutput(entityRaw);
|
|
329
|
+
} else if (llmFn && !customSummaryFn) {
|
|
330
|
+
parsedEntities = await extractEntities(normalized, { llmFn, promptFn: entityPromptFn });
|
|
331
|
+
}
|
|
332
|
+
} catch (e) { warnings.push(`entity extraction failed: ${e.message}`); }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// 5. Now open transaction — only DB writes, no external calls
|
|
336
|
+
const client = await pool.connect();
|
|
337
|
+
let turnsEmbedded = 0;
|
|
338
|
+
let entitiesFound = 0;
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
await client.query('BEGIN');
|
|
342
|
+
|
|
343
|
+
// 5a. Upsert summary
|
|
344
|
+
if (summaryResult && summaryResult.summaryText) {
|
|
345
|
+
await storage.upsertSummary(client, session.id, {
|
|
346
|
+
schema, tenantId, agentId, sessionId,
|
|
347
|
+
summaryText: summaryResult.summaryText,
|
|
348
|
+
structuredSummary: summaryResult.structuredSummary,
|
|
349
|
+
model: null, sourceHash: null,
|
|
350
|
+
msgCount: normalized.length,
|
|
351
|
+
userCount: turns.length,
|
|
352
|
+
assistantCount: normalized.filter(m => m.role === 'assistant').length,
|
|
353
|
+
startedAt: session.started_at, endedAt: session.ended_at,
|
|
354
|
+
embedding: summaryEmbedding,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// 5b. Turn embeddings
|
|
359
|
+
if (turnVectors && turns.length > 0) {
|
|
360
|
+
try {
|
|
361
|
+
await storage.upsertTurnEmbeddings(client, session.id, {
|
|
362
|
+
schema, tenantId, sessionId, agentId,
|
|
363
|
+
source: session.source, turns, vectors: turnVectors,
|
|
364
|
+
});
|
|
365
|
+
turnsEmbedded = turns.length;
|
|
366
|
+
} catch (e) { warnings.push(`turn upsert failed: ${e.message}`); }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// 5c. Entity upsert chain (extraction already done in step 4c)
|
|
370
|
+
if (parsedEntities.length > 0) {
|
|
371
|
+
const entityIds = [];
|
|
372
|
+
for (const ent of parsedEntities) {
|
|
373
|
+
try {
|
|
374
|
+
const { id } = await entity.upsertEntity(client, {
|
|
375
|
+
schema,
|
|
376
|
+
tenantId,
|
|
377
|
+
name: ent.name,
|
|
378
|
+
normalizedName: ent.normalizedName,
|
|
379
|
+
aliases: ent.aliases,
|
|
380
|
+
type: ent.type,
|
|
381
|
+
agentId,
|
|
382
|
+
createdBy: 'aquifer',
|
|
383
|
+
occurredAt: session.started_at ? new Date(session.started_at).toISOString() : null,
|
|
384
|
+
});
|
|
385
|
+
entityIds.push(id);
|
|
386
|
+
|
|
387
|
+
// Upsert mention
|
|
388
|
+
await entity.upsertEntityMention(client, {
|
|
389
|
+
schema,
|
|
390
|
+
entityId: id,
|
|
391
|
+
sessionRowId: session.id,
|
|
392
|
+
source: session.source,
|
|
393
|
+
mentionText: ent.name,
|
|
394
|
+
occurredAt: session.started_at ? new Date(session.started_at).toISOString() : null,
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// Upsert entity-session link
|
|
398
|
+
await entity.upsertEntitySession(client, {
|
|
399
|
+
schema,
|
|
400
|
+
entityId: id,
|
|
401
|
+
sessionRowId: session.id,
|
|
402
|
+
occurredAt: session.started_at ? new Date(session.started_at).toISOString() : null,
|
|
403
|
+
});
|
|
404
|
+
} catch (e) { warnings.push(`entity upsert failed: ${e.message}`); }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Entity relations: all pairs
|
|
408
|
+
if (entityIds.length > 1) {
|
|
409
|
+
const pairs = [];
|
|
410
|
+
for (let i = 0; i < entityIds.length; i++) {
|
|
411
|
+
for (let j = i + 1; j < entityIds.length; j++) {
|
|
412
|
+
pairs.push({ srcEntityId: entityIds[i], dstEntityId: entityIds[j] });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
try {
|
|
416
|
+
await entity.upsertEntityRelations(client, {
|
|
417
|
+
schema,
|
|
418
|
+
pairs,
|
|
419
|
+
occurredAt: session.started_at ? new Date(session.started_at).toISOString() : null,
|
|
420
|
+
});
|
|
421
|
+
} catch (e) { warnings.push(`entity relations failed: ${e.message}`); }
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
entitiesFound = entityIds.length;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// 8. Mark status + commit (M5: use 'partial' if warnings)
|
|
428
|
+
const finalStatus = warnings.length > 0 ? 'partial' : 'succeeded';
|
|
429
|
+
await storage.markStatus(client, session.id, finalStatus, warnings.length > 0 ? warnings.join('; ') : null, { schema });
|
|
430
|
+
await client.query('COMMIT');
|
|
431
|
+
} catch (err) {
|
|
432
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
433
|
+
try {
|
|
434
|
+
await storage.markStatus(pool, session.id, 'failed', err.message, { schema });
|
|
435
|
+
} catch (_) { /* swallow */ }
|
|
436
|
+
throw err;
|
|
437
|
+
} finally {
|
|
438
|
+
client.release();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
summary: summaryResult ? summaryResult.summaryText : null,
|
|
443
|
+
structuredSummary: summaryResult ? summaryResult.structuredSummary : null,
|
|
444
|
+
turnsEmbedded,
|
|
445
|
+
entitiesFound,
|
|
446
|
+
warnings,
|
|
447
|
+
extra,
|
|
448
|
+
};
|
|
449
|
+
},
|
|
450
|
+
|
|
451
|
+
// --- read path ---
|
|
452
|
+
|
|
453
|
+
async recall(query, opts = {}) {
|
|
454
|
+
if (!query) return [];
|
|
455
|
+
requireEmbed('recall');
|
|
456
|
+
|
|
457
|
+
const {
|
|
458
|
+
agentId,
|
|
459
|
+
source,
|
|
460
|
+
dateFrom,
|
|
461
|
+
dateTo,
|
|
462
|
+
limit = 5,
|
|
463
|
+
weights: overrideWeights,
|
|
464
|
+
} = opts;
|
|
465
|
+
|
|
466
|
+
const fetchLimit = limit * 4;
|
|
467
|
+
|
|
468
|
+
// 1. Embed query
|
|
469
|
+
const queryVecResult = await embedFn([query]);
|
|
470
|
+
const queryVec = queryVecResult[0];
|
|
471
|
+
if (!queryVec || !queryVec.length) return []; // m3: guard empty array too
|
|
472
|
+
|
|
473
|
+
// 2. Run 3 search paths in parallel
|
|
474
|
+
const [ftsRows, embRows, turnResult] = await Promise.all([
|
|
475
|
+
storage.searchSessions(pool, query, {
|
|
476
|
+
schema, tenantId, agentId, source, dateFrom, dateTo, limit: fetchLimit,
|
|
477
|
+
}).catch(() => []),
|
|
478
|
+
embeddingSearchSummaries(queryVec, {
|
|
479
|
+
agentId, source, dateFrom, dateTo, limit: fetchLimit,
|
|
480
|
+
}).catch(() => []),
|
|
481
|
+
storage.searchTurnEmbeddings(pool, {
|
|
482
|
+
schema, tenantId, queryVec, dateFrom, dateTo, agentId, source, limit: fetchLimit,
|
|
483
|
+
}).catch(() => ({ rows: [] })),
|
|
484
|
+
]);
|
|
485
|
+
|
|
486
|
+
const turnRows = turnResult.rows || [];
|
|
487
|
+
|
|
488
|
+
if (ftsRows.length === 0 && embRows.length === 0 && turnRows.length === 0) {
|
|
489
|
+
return [];
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// 3. Entity boost (if enabled)
|
|
493
|
+
let entityScoreBySession = new Map();
|
|
494
|
+
if (entitiesEnabled) {
|
|
495
|
+
try {
|
|
496
|
+
const matchedEntities = await entity.searchEntities(pool, {
|
|
497
|
+
schema, tenantId, query, agentId, limit: 10,
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
if (matchedEntities.length > 0) {
|
|
501
|
+
// M1 fix: single JOIN instead of N+1
|
|
502
|
+
const entityIds = matchedEntities.map(e => e.id);
|
|
503
|
+
const esResult = await pool.query(
|
|
504
|
+
`SELECT es.session_row_id, s.session_id, COUNT(*) AS entity_count
|
|
505
|
+
FROM ${qi(schema)}.entity_sessions es
|
|
506
|
+
JOIN ${qi(schema)}.sessions s ON s.id = es.session_row_id
|
|
507
|
+
WHERE es.entity_id = ANY($1)
|
|
508
|
+
GROUP BY es.session_row_id, s.session_id`,
|
|
509
|
+
[entityIds]
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
const maxCount = Math.max(1, ...esResult.rows.map(r => parseInt(r.entity_count)));
|
|
513
|
+
for (const row of esResult.rows) {
|
|
514
|
+
entityScoreBySession.set(row.session_id, parseInt(row.entity_count) / maxCount);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
} catch (_) { /* entity search failure non-fatal */ }
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// 4. Run external source searches (parallel + timeout)
|
|
521
|
+
const EXTERNAL_TIMEOUT = 10000;
|
|
522
|
+
const externalRows = [];
|
|
523
|
+
const externalPromises = [];
|
|
524
|
+
for (const [, sourceConfig] of sources) {
|
|
525
|
+
if (typeof sourceConfig.search === 'function') {
|
|
526
|
+
const w = sourceConfig.weight !== null && sourceConfig.weight !== undefined ? sourceConfig.weight : 1.0;
|
|
527
|
+
externalPromises.push(
|
|
528
|
+
Promise.race([
|
|
529
|
+
sourceConfig.search(query, opts),
|
|
530
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error('external source timeout')), EXTERNAL_TIMEOUT)),
|
|
531
|
+
]).then(results => {
|
|
532
|
+
if (Array.isArray(results)) {
|
|
533
|
+
for (const r of results) {
|
|
534
|
+
if (r && r.session_id) externalRows.push({ ...r, _externalWeight: w });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}).catch(() => { /* external source failure/timeout non-fatal */ })
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
if (externalPromises.length > 0) await Promise.all(externalPromises);
|
|
542
|
+
|
|
543
|
+
// 5. Hybrid rank — external results as separate embedding-like signal
|
|
544
|
+
const mergedWeights = { ...rankWeights, ...overrideWeights };
|
|
545
|
+
const ranked = hybridRank(
|
|
546
|
+
ftsRows,
|
|
547
|
+
[...embRows, ...externalRows],
|
|
548
|
+
limit,
|
|
549
|
+
mergedWeights,
|
|
550
|
+
turnRows,
|
|
551
|
+
entityScoreBySession,
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
// 6. Record access
|
|
555
|
+
const sessionRowIds = ranked
|
|
556
|
+
.map(r => r.id || r.session_row_id)
|
|
557
|
+
.filter(Boolean);
|
|
558
|
+
|
|
559
|
+
if (sessionRowIds.length > 0) {
|
|
560
|
+
try {
|
|
561
|
+
await storage.recordAccess(pool, sessionRowIds, { schema });
|
|
562
|
+
} catch (_) { /* access recording non-fatal */ }
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// 7. Format results
|
|
566
|
+
return ranked.map(r => ({
|
|
567
|
+
sessionId: r.session_id,
|
|
568
|
+
agentId: r.agent_id,
|
|
569
|
+
source: r.source,
|
|
570
|
+
startedAt: r.started_at,
|
|
571
|
+
summaryText: r.summary_text || null,
|
|
572
|
+
structuredSummary: r.structured_summary || null,
|
|
573
|
+
summarySnippet: r.summary_snippet || null,
|
|
574
|
+
matchedTurnText: r.matched_turn_text || null,
|
|
575
|
+
matchedTurnIndex: r.matched_turn_index || null,
|
|
576
|
+
score: r._score,
|
|
577
|
+
_debug: {
|
|
578
|
+
rrf: r._rrf,
|
|
579
|
+
timeDecay: r._timeDecay,
|
|
580
|
+
access: r._access,
|
|
581
|
+
entityScore: r._entityScore,
|
|
582
|
+
},
|
|
583
|
+
}));
|
|
584
|
+
},
|
|
585
|
+
|
|
586
|
+
// --- admin ---
|
|
587
|
+
|
|
588
|
+
async getSession(sessionId, opts = {}) {
|
|
589
|
+
const agentId = opts.agentId || 'agent';
|
|
590
|
+
return storage.getSession(pool, sessionId, agentId, opts, { schema, tenantId });
|
|
591
|
+
},
|
|
592
|
+
|
|
593
|
+
async getSessionFull(sessionId) {
|
|
594
|
+
// Try to find the session across agents by querying directly
|
|
595
|
+
const result = await pool.query(
|
|
596
|
+
`SELECT * FROM ${qi(schema)}.sessions
|
|
597
|
+
WHERE session_id = $1 AND tenant_id = $2
|
|
598
|
+
LIMIT 1`,
|
|
599
|
+
[sessionId, tenantId]
|
|
600
|
+
);
|
|
601
|
+
const session = result.rows[0];
|
|
602
|
+
if (!session) return null;
|
|
603
|
+
|
|
604
|
+
const [segResult, sumResult] = await Promise.all([
|
|
605
|
+
pool.query(
|
|
606
|
+
`SELECT * FROM ${qi(schema)}.session_segments
|
|
607
|
+
WHERE session_row_id = $1
|
|
608
|
+
ORDER BY segment_no ASC`,
|
|
609
|
+
[session.id]
|
|
610
|
+
),
|
|
611
|
+
pool.query(
|
|
612
|
+
`SELECT * FROM ${qi(schema)}.session_summaries
|
|
613
|
+
WHERE session_row_id = $1
|
|
614
|
+
LIMIT 1`,
|
|
615
|
+
[session.id]
|
|
616
|
+
),
|
|
617
|
+
]);
|
|
618
|
+
|
|
619
|
+
return {
|
|
620
|
+
session,
|
|
621
|
+
segments: segResult.rows,
|
|
622
|
+
summary: sumResult.rows[0] || null,
|
|
623
|
+
};
|
|
624
|
+
},
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
return aquifer;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ---------------------------------------------------------------------------
|
|
631
|
+
// Exports
|
|
632
|
+
// ---------------------------------------------------------------------------
|
|
633
|
+
|
|
634
|
+
module.exports = { createAquifer };
|