@shadowforge0/aquifer-memory 1.3.0 → 1.5.9

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.
@@ -0,0 +1,499 @@
1
+ 'use strict';
2
+
3
+ // aquifer.insights.* — higher-order observations distilled from sessions.
4
+ //
5
+ // Insight types: preference / pattern / frustration / workflow.
6
+ // Recall blends semantic similarity (vector), importance, and recency
7
+ // (linear decay over recencyWindowDays, default 90).
8
+ //
9
+ // Lifecycle is EXPLICIT — no read-time "auto-stale". Statuses:
10
+ // 'active' — returned by recall by default
11
+ // 'stale' — set via markStale(id); recall excludes unless includeStale
12
+ // 'superseded' — set via supersede(oldId, newId); excluded unless includeStale
13
+ // The scripts/extract-insights-from-recent-sessions.js cron job is the
14
+ // only thing that typically calls supersede() (when a newer extraction run
15
+ // fully covers the old evidence).
16
+
17
+ const crypto = require('crypto');
18
+ const { ok, err } = require('./errors');
19
+ const { normalizeEntityName } = require('./entity');
20
+
21
+ const VALID_TYPES = new Set(['preference', 'pattern', 'frustration', 'workflow']);
22
+
23
+ const DEFAULT_RECALL_WEIGHTS = Object.freeze({
24
+ semantic: 0.65,
25
+ importance: 0.25,
26
+ recency: 0.10,
27
+ });
28
+
29
+ // Recency linear decay horizon — an insight is treated as "fully recent" at
30
+ // creation (age=0) and "zero recency" at age >= recencyWindowDays. Beyond,
31
+ // recency contribution is clamped to 0 rather than going negative. Configurable
32
+ // via createAquifer({ insights: { recencyWindowDays } }).
33
+ const DEFAULT_RECENCY_WINDOW_DAYS = 90;
34
+
35
+ const LEADING_PUNCT_RE = /^[\s\-_.,;:!?'"()\[\]{}@#]+/;
36
+ const TRAILING_PUNCT_RE = /[\s\-_.,;:!?'"()\[\]{}@#]+$/;
37
+
38
+ function _normalizeText(input) {
39
+ if (typeof input !== 'string' || !input) return '';
40
+ let s = input.normalize('NFKC');
41
+ s = s.toLowerCase();
42
+ s = s.replace(/\s+/g, ' ');
43
+ s = s.replace(LEADING_PUNCT_RE, '');
44
+ s = s.replace(TRAILING_PUNCT_RE, '');
45
+ return s;
46
+ }
47
+
48
+ function normalizeCanonicalClaim(text) {
49
+ return _normalizeText(text);
50
+ }
51
+
52
+ function normalizeBody(text) {
53
+ return _normalizeText(text);
54
+ }
55
+
56
+ function normalizeEntitySet(entities) {
57
+ if (!entities || !Array.isArray(entities)) return '';
58
+ const { normalizeEntityName } = require('./entity');
59
+ const normalized = entities
60
+ .map(e => normalizeEntityName(e))
61
+ .filter(Boolean);
62
+ const deduped = [...new Set(normalized)];
63
+ deduped.sort();
64
+ return deduped.join('|');
65
+ }
66
+
67
+ function defaultCanonicalKey({ tenantId, agentId, type, canonicalClaim, entities }) {
68
+ const normClaim = normalizeCanonicalClaim(canonicalClaim);
69
+ const normEntities = normalizeEntitySet(entities);
70
+ const input = `${tenantId || ''}|${agentId || ''}|${type || ''}|${normClaim}|${normEntities}`;
71
+ return crypto.createHash('sha256').update(input).digest('hex');
72
+ }
73
+
74
+ function defaultIdempotencyKey({
75
+ tenantId, agentId, type, title, body, sourceSessionIds, evidenceWindow,
76
+ }) {
77
+ const sorted = (sourceSessionIds || []).slice().sort().join('|');
78
+ const winFrom = evidenceWindow && evidenceWindow.from ? new Date(evidenceWindow.from).toISOString() : '';
79
+ const winTo = evidenceWindow && evidenceWindow.to ? new Date(evidenceWindow.to).toISOString() : '';
80
+ // Hash must include body + window so legitimate revisions (same sessions but
81
+ // tightened body, or extended window) get a new key and replace the old row
82
+ // via supersede, not get swallowed as a duplicate.
83
+ return crypto.createHash('sha256')
84
+ .update(`${tenantId}|${agentId}|${type}|${title}|${body || ''}|${sorted}|${winFrom}|${winTo}`)
85
+ .digest('hex');
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Canonical identity helpers (Phase 2 C1)
90
+ //
91
+ // Two-layer identity:
92
+ // canonical_key_v2 — "which claim is this" (type + canonicalClaim + entitySet)
93
+ // idempotency_key — "which revision of that claim" (legacy, unchanged)
94
+ //
95
+ // canonicalClaim is produced by the extractor LLM (a normalized declarative
96
+ // claim without rhetoric/examples/time words). Title/body/sessions/window
97
+ // are revision-level and stay out of canonical_key_v2.
98
+ // ---------------------------------------------------------------------------
99
+
100
+ function normalizeCanonicalClaim(text) {
101
+ if (typeof text !== 'string') return '';
102
+
103
+ let s = text.normalize('NFKC');
104
+ s = s.toLowerCase();
105
+ s = s.replace(/\s+/g, ' ');
106
+ s = s.trim();
107
+ s = s.replace(/^[\s\-_.,;:!?'"()\[\]{}]+/, '');
108
+ s = s.replace(/[\s\-_.,;:!?'"()\[\]{}]+$/, '');
109
+
110
+ return s;
111
+ }
112
+
113
+ function normalizeBody(text) {
114
+ return normalizeCanonicalClaim(text);
115
+ }
116
+
117
+ function normalizeEntitySet(entities) {
118
+ if (!Array.isArray(entities) || entities.length === 0) return '';
119
+
120
+ return [...new Set(
121
+ entities
122
+ .map(entity => normalizeEntityName(entity))
123
+ .filter(Boolean)
124
+ )]
125
+ .sort()
126
+ .join('|');
127
+ }
128
+
129
+ function defaultCanonicalKey({ tenantId, agentId, type, canonicalClaim, entities }) {
130
+ return crypto.createHash('sha256')
131
+ .update(`${tenantId ?? ''}|${agentId ?? ''}|${type ?? ''}|${normalizeCanonicalClaim(canonicalClaim)}|${normalizeEntitySet(entities)}`)
132
+ .digest('hex');
133
+ }
134
+
135
+ // Parse the upper bound of a tstzrange returned by node-postgres as a raw
136
+ // string (default mapping when range types aren't explicitly parsed). Accepts
137
+ // the forms `[lower,upper)` / `(lower,upper]` / infinity sentinels.
138
+ function parseUpperFromRange(raw) {
139
+ if (!raw || typeof raw !== 'string') return null;
140
+ const m = raw.match(/^[[(]([^,]*),([^)\]]*)[\])]$/);
141
+ if (!m) return null;
142
+ const upper = m[2].trim().replace(/^"|"$/g, '');
143
+ if (!upper || upper === 'infinity') return null;
144
+ const d = new Date(upper);
145
+ return Number.isFinite(d.getTime()) ? d : null;
146
+ }
147
+
148
+ // Revision-level idempotency key: same claim (canonicalKeyV2) + same body +
149
+ // same source sessions + same evidence window = duplicate. Body tightening or
150
+ // window extension produces a new revision (old one is superseded).
151
+ function revisionIdempotencyKey({ canonicalKeyV2, body, sourceSessionIds, fromIso, toIso }) {
152
+ const sorted = (sourceSessionIds || []).slice().sort().join('|');
153
+ return crypto.createHash('sha256')
154
+ .update(`${canonicalKeyV2}|${normalizeBody(body)}|${sorted}|${fromIso || ''}|${toIso || ''}`)
155
+ .digest('hex');
156
+ }
157
+
158
+ function vecToPgLiteral(v) {
159
+ if (!Array.isArray(v) || v.length === 0) return null;
160
+ return `[${v.join(',')}]`;
161
+ }
162
+
163
+ function mapRow(row) {
164
+ if (!row) return null;
165
+ return {
166
+ id: Number(row.id),
167
+ tenantId: row.tenant_id,
168
+ agentId: row.agent_id,
169
+ insightType: row.insight_type,
170
+ title: row.title,
171
+ body: row.body,
172
+ sourceSessionIds: row.source_session_ids || [],
173
+ evidenceWindow: row.evidence_window, // raw tstzrange string from PG
174
+ importance: (row.importance !== null && row.importance !== undefined) ? Number(row.importance) : null,
175
+ status: row.status,
176
+ supersededBy: (row.superseded_by !== null && row.superseded_by !== undefined) ? Number(row.superseded_by) : null,
177
+ idempotencyKey: row.idempotency_key || null,
178
+ canonicalKeyV2: row.canonical_key_v2 || null,
179
+ metadata: row.metadata || {},
180
+ createdAt: row.created_at,
181
+ updatedAt: row.updated_at,
182
+ score: (row._score !== null && row._score !== undefined) ? Number(row._score) : undefined,
183
+ semanticScore: (row._semantic_score !== null && row._semantic_score !== undefined) ? Number(row._semantic_score) : undefined,
184
+ };
185
+ }
186
+
187
+ function createInsights({ pool, schema, defaultTenantId, embedFn, recallWeights, recencyWindowDays }) {
188
+ if (!pool) throw new Error('createInsights: pool is required');
189
+ if (!schema) throw new Error('createInsights: schema is required');
190
+
191
+ const weights = { ...DEFAULT_RECALL_WEIGHTS, ...(recallWeights || {}) };
192
+ const recencyWindow = Number.isFinite(recencyWindowDays) && recencyWindowDays > 0
193
+ ? recencyWindowDays : DEFAULT_RECENCY_WINDOW_DAYS;
194
+ const tbl = `${schema}.insights`;
195
+
196
+ // -------------------------------------------------------------------------
197
+ // commitInsight
198
+ // -------------------------------------------------------------------------
199
+ async function commitInsight(input = {}) {
200
+ try {
201
+ const tenantId = input.tenantId || defaultTenantId || 'default';
202
+ const agentId = input.agentId;
203
+ if (!agentId) return err('AQ_INVALID_INPUT', 'agentId is required');
204
+ const type = input.type;
205
+ if (!VALID_TYPES.has(type)) return err('AQ_INVALID_INPUT', `type must be one of ${[...VALID_TYPES].join('|')}`);
206
+ const title = typeof input.title === 'string' ? input.title.trim() : '';
207
+ if (!title) return err('AQ_INVALID_INPUT', 'title must be non-empty string');
208
+ const body = typeof input.body === 'string' ? input.body.trim() : '';
209
+ if (!body) return err('AQ_INVALID_INPUT', 'body must be non-empty string');
210
+ const sourceSessionIds = Array.isArray(input.sourceSessionIds) ? input.sourceSessionIds : [];
211
+ if (!sourceSessionIds.length) return err('AQ_INVALID_INPUT', 'sourceSessionIds must contain at least one id');
212
+ const win = input.evidenceWindow || {};
213
+ if (!win.from || !win.to) return err('AQ_INVALID_INPUT', 'evidenceWindow.from and .to are required');
214
+ const fromIso = new Date(win.from).toISOString();
215
+ const toIso = new Date(win.to).toISOString();
216
+ if (!Number.isFinite(new Date(fromIso).getTime()) || !Number.isFinite(new Date(toIso).getTime())) {
217
+ return err('AQ_INVALID_INPUT', 'evidenceWindow.from / .to must parse to timestamps');
218
+ }
219
+ const importance = (input.importance !== null && input.importance !== undefined) ? Number(input.importance) : 0.5;
220
+ if (!Number.isFinite(importance) || importance < 0 || importance > 1) {
221
+ return err('AQ_INVALID_INPUT', 'importance must be in [0,1]');
222
+ }
223
+ let metadata = input.metadata && typeof input.metadata === 'object' ? input.metadata : {};
224
+
225
+ // ---------------------------------------------------------------------
226
+ // Phase 2 C1: two-layer identity.
227
+ // canonicalKeyV2 = "which claim" (type + canonicalClaim + entitySet)
228
+ // idempotencyKey = "which revision of that claim"
229
+ // canonicalClaim comes from the extractor LLM; when absent we fall back
230
+ // to title and flag dedupQuality so callers know the dedupe is weak.
231
+ // ---------------------------------------------------------------------
232
+ const canonicalClaim = typeof input.canonicalClaim === 'string' ? input.canonicalClaim : '';
233
+ const entities = Array.isArray(input.entities) ? input.entities : [];
234
+ const canonicalKeyV2 = input.canonicalKey
235
+ || defaultCanonicalKey({
236
+ tenantId, agentId, type,
237
+ canonicalClaim: canonicalClaim || title,
238
+ entities,
239
+ });
240
+
241
+ if (!input.canonicalClaim && !input.canonicalKey) {
242
+ metadata = { ...metadata, dedupQuality: 'title_fallback' };
243
+ }
244
+
245
+ const idempotencyKey = input.idempotencyKey
246
+ || revisionIdempotencyKey({
247
+ canonicalKeyV2, body, sourceSessionIds, fromIso, toIso,
248
+ });
249
+
250
+ // Step A — revision dedupe. Exact same claim/body/sessions/window.
251
+ const existing = await pool.query(
252
+ `SELECT * FROM ${tbl} WHERE idempotency_key = $1 LIMIT 1`,
253
+ [idempotencyKey]
254
+ );
255
+ if (existing.rowCount > 0) return ok({ insight: mapRow(existing.rows[0]), duplicate: true });
256
+
257
+ // Step B — canonical lookup: is this claim already active? If so, decide
258
+ // between stale replay (incoming window older than active) vs revision
259
+ // (incoming same or newer, body/window differ enough that Step A missed).
260
+ const canonLookup = await pool.query(
261
+ `SELECT * FROM ${tbl}
262
+ WHERE tenant_id = $1
263
+ AND agent_id = $2
264
+ AND insight_type = $3
265
+ AND canonical_key_v2 = $4
266
+ AND status = 'active'
267
+ ORDER BY created_at DESC
268
+ LIMIT 1`,
269
+ [tenantId, agentId, type, canonicalKeyV2]
270
+ );
271
+
272
+ let toSupersede = null;
273
+ if (canonLookup.rowCount > 0) {
274
+ const activeRow = canonLookup.rows[0];
275
+ const activeUpper = parseUpperFromRange(activeRow.evidence_window);
276
+ // Rule 4 — stale replay: incoming evidence is older than what's
277
+ // already active. Keep the active row, tell caller it's a duplicate.
278
+ if (activeUpper && new Date(toIso).getTime() < activeUpper.getTime()) {
279
+ return ok({ insight: mapRow(activeRow), duplicate: true });
280
+ }
281
+ // Rule 2/3 — revision: different revision key, incoming window is not
282
+ // stale. Insert new and mark the previous active row as superseded.
283
+ toSupersede = Number(activeRow.id);
284
+ }
285
+
286
+ // Optional embedding.
287
+ let embedding = null;
288
+ if (embedFn) {
289
+ try {
290
+ const v = await embedFn([`${title}\n\n${body}`]);
291
+ if (Array.isArray(v) && Array.isArray(v[0])) embedding = vecToPgLiteral(v[0]);
292
+ } catch {
293
+ // Embed failure is non-fatal — insight saved without semantic recall path.
294
+ }
295
+ }
296
+
297
+ const evidenceRange = `[${fromIso},${toIso})`;
298
+ const inserted = await pool.query(
299
+ `INSERT INTO ${tbl}
300
+ (tenant_id, agent_id, insight_type, title, body, source_session_ids,
301
+ evidence_window, embedding, importance, status, idempotency_key,
302
+ canonical_key_v2, metadata)
303
+ VALUES ($1,$2,$3,$4,$5,$6, $7::tstzrange, $8::vector, $9, 'active', $10, $11, $12::jsonb)
304
+ RETURNING *`,
305
+ [tenantId, agentId, type, title, body, sourceSessionIds,
306
+ evidenceRange, embedding, importance, idempotencyKey,
307
+ canonicalKeyV2, JSON.stringify(metadata)]
308
+ );
309
+ const newRow = inserted.rows[0];
310
+
311
+ // Best-effort supersede of the prior active revision. Insights are
312
+ // eventually consistent — if the old row was already superseded by a
313
+ // racing writer, log and continue without failing the new insert.
314
+ if (toSupersede && Number(newRow.id) !== toSupersede) {
315
+ try {
316
+ await pool.query(
317
+ `UPDATE ${tbl}
318
+ SET status = 'superseded', superseded_by = $2, updated_at = now()
319
+ WHERE id = $1 AND status = 'active'`,
320
+ [toSupersede, Number(newRow.id)]
321
+ );
322
+ } catch {
323
+ // swallow — new row is already persisted
324
+ }
325
+ }
326
+
327
+ return ok({ insight: mapRow(newRow), duplicate: false });
328
+ } catch (e) {
329
+ if (/duplicate key/.test(e.message)) return err('AQ_CONFLICT', e.message);
330
+ return err('AQ_INTERNAL', e.message);
331
+ }
332
+ }
333
+
334
+ // -------------------------------------------------------------------------
335
+ // recallInsights
336
+ // -------------------------------------------------------------------------
337
+ async function recallInsights(query, input = {}) {
338
+ try {
339
+ const tenantId = input.tenantId || defaultTenantId || 'default';
340
+ const agentId = input.agentId;
341
+ if (!agentId) return err('AQ_INVALID_INPUT', 'agentId is required');
342
+ const type = input.type || null;
343
+ if (type && !VALID_TYPES.has(type)) {
344
+ return err('AQ_INVALID_INPUT', `type must be one of ${[...VALID_TYPES].join('|')}`);
345
+ }
346
+ const limit = Math.max(1, Math.min(50, Number(input.limit) || 5));
347
+ const minImportance = (input.minImportance !== null && input.minImportance !== undefined) ? Number(input.minImportance) : 0;
348
+ const includeStale = input.includeStale === true;
349
+
350
+ const where = ['tenant_id = $1', 'agent_id = $2', 'importance >= $3'];
351
+ const params = [tenantId, agentId, minImportance];
352
+ if (!includeStale) where.push(`status = 'active'`);
353
+ if (type) {
354
+ params.push(type);
355
+ where.push(`insight_type = $${params.length}`);
356
+ }
357
+
358
+ // Empty query → blend importance × recency (linear decay over
359
+ // recencyWindow days), no semantic component. Falls back to created_at
360
+ // DESC as tiebreak so identical blended scores remain deterministic.
361
+ if (!query || typeof query !== 'string' || !query.trim()) {
362
+ params.push(recencyWindow);
363
+ const winPos = params.length;
364
+ params.push(weights.importance);
365
+ const wImpPos = params.length;
366
+ params.push(weights.recency);
367
+ const wRecPos = params.length;
368
+ params.push(limit);
369
+ const r = await pool.query(
370
+ `SELECT *,
371
+ (
372
+ $${wImpPos}::real * importance +
373
+ $${wRecPos}::real * GREATEST(0, 1.0 - (extract(epoch FROM (now() - created_at)) / 86400.0) / $${winPos}::real)
374
+ ) AS _score
375
+ FROM ${tbl}
376
+ WHERE ${where.join(' AND ')}
377
+ ORDER BY _score DESC, created_at DESC
378
+ LIMIT $${params.length}`,
379
+ params
380
+ );
381
+ return ok({ rows: r.rows.map(mapRow) });
382
+ }
383
+
384
+ // Vector recall: requires embedFn.
385
+ if (!embedFn) return err('AQ_DEPENDENCY', 'recallInsights with query requires embedFn');
386
+ let queryVec;
387
+ try {
388
+ const v = await embedFn([query]);
389
+ queryVec = vecToPgLiteral(v[0]);
390
+ } catch (e) {
391
+ return err('AQ_DEPENDENCY', `embedFn failed: ${e.message}`);
392
+ }
393
+ if (!queryVec) return err('AQ_DEPENDENCY', 'embedFn returned empty vector');
394
+
395
+ params.push(queryVec);
396
+ const vecPos = params.length;
397
+ params.push(weights.semantic);
398
+ const wSemPos = params.length;
399
+ params.push(weights.importance);
400
+ const wImpPos = params.length;
401
+ params.push(weights.recency);
402
+ const wRecPos = params.length;
403
+ params.push(limit);
404
+ const limitPos = params.length;
405
+
406
+ params.push(recencyWindow);
407
+ const winPos = params.length;
408
+ const r = await pool.query(
409
+ `WITH scored AS (
410
+ SELECT *,
411
+ 1.0 - (embedding <=> $${vecPos}::vector) AS _semantic_score,
412
+ extract(epoch FROM (now() - created_at)) / 86400.0 AS _age_days
413
+ FROM ${tbl}
414
+ WHERE embedding IS NOT NULL
415
+ AND ${where.join(' AND ')}
416
+ )
417
+ SELECT *,
418
+ (
419
+ $${wSemPos}::real * GREATEST(0, _semantic_score) +
420
+ $${wImpPos}::real * importance +
421
+ $${wRecPos}::real * GREATEST(0, 1.0 - _age_days / $${winPos}::real)
422
+ ) AS _score
423
+ FROM scored
424
+ ORDER BY _score DESC
425
+ LIMIT $${limitPos}`,
426
+ params
427
+ );
428
+ return ok({ rows: r.rows.map(mapRow) });
429
+ } catch (e) {
430
+ return err('AQ_INTERNAL', e.message);
431
+ }
432
+ }
433
+
434
+ // -------------------------------------------------------------------------
435
+ // markStale / supersede — explicit lifecycle (callers / scripts use these).
436
+ // -------------------------------------------------------------------------
437
+ async function markStale(insightId) {
438
+ try {
439
+ const id = Number(insightId);
440
+ if (!Number.isInteger(id) || id <= 0) return err('AQ_INVALID_INPUT', 'insightId must be positive integer');
441
+ const r = await pool.query(
442
+ `UPDATE ${tbl} SET status='stale', updated_at=now()
443
+ WHERE id=$1 AND status <> 'stale' RETURNING id, status`,
444
+ [id]
445
+ );
446
+ if (r.rowCount === 0) return err('AQ_NOT_FOUND', `insight ${id} not found or already stale`);
447
+ return ok({ id: Number(r.rows[0].id), status: r.rows[0].status });
448
+ } catch (e) {
449
+ return err('AQ_INTERNAL', e.message);
450
+ }
451
+ }
452
+
453
+ async function supersede(oldId, newId) {
454
+ try {
455
+ const o = Number(oldId), n = Number(newId);
456
+ if (!Number.isInteger(o) || !Number.isInteger(n)) return err('AQ_INVALID_INPUT', 'oldId/newId must be integers');
457
+ if (o === n) return err('AQ_INVALID_INPUT', 'oldId and newId must differ (no self-supersede)');
458
+ // Verify both exist and share tenant + agent. FK alone would allow a
459
+ // caller with a cross-tenant id to form an illegal supersession chain.
460
+ const vr = await pool.query(
461
+ `SELECT id, tenant_id, agent_id FROM ${tbl} WHERE id = ANY($1)`,
462
+ [[o, n]]
463
+ );
464
+ if (vr.rowCount < 2) return err('AQ_NOT_FOUND', `insight ${o} or ${n} not found`);
465
+ const oldRow = vr.rows.find(r => Number(r.id) === o);
466
+ const newRow = vr.rows.find(r => Number(r.id) === n);
467
+ if (!oldRow || !newRow) return err('AQ_NOT_FOUND', `insight ${o} or ${n} not found`);
468
+ if (oldRow.tenant_id !== newRow.tenant_id || oldRow.agent_id !== newRow.agent_id) {
469
+ return err('AQ_CONFLICT', `supersede crosses tenant/agent: old=${oldRow.tenant_id}/${oldRow.agent_id}, new=${newRow.tenant_id}/${newRow.agent_id}`);
470
+ }
471
+ const r = await pool.query(
472
+ `UPDATE ${tbl} SET status='superseded', superseded_by=$2, updated_at=now()
473
+ WHERE id=$1 AND status <> 'superseded' RETURNING id, status, superseded_by`,
474
+ [o, n]
475
+ );
476
+ if (r.rowCount === 0) return err('AQ_NOT_FOUND', `insight ${o} not found or already superseded`);
477
+ return ok({ id: Number(r.rows[0].id), status: r.rows[0].status, supersededBy: Number(r.rows[0].superseded_by) });
478
+ } catch (e) {
479
+ return err('AQ_INTERNAL', e.message);
480
+ }
481
+ }
482
+
483
+ return {
484
+ commitInsight,
485
+ recallInsights,
486
+ markStale,
487
+ supersede,
488
+ _internal: { defaultIdempotencyKey, vecToPgLiteral, mapRow, weights },
489
+ };
490
+ }
491
+
492
+ module.exports = {
493
+ createInsights,
494
+ defaultIdempotencyKey,
495
+ defaultCanonicalKey,
496
+ normalizeCanonicalClaim,
497
+ normalizeBody,
498
+ normalizeEntitySet,
499
+ };
@@ -20,7 +20,7 @@ const MCP_SERVER_NAME = 'aquifer-memory';
20
20
  const MCP_TOOL_MANIFEST = Object.freeze([
21
21
  {
22
22
  name: 'session_recall',
23
- description: 'Search stored sessions by keyword. Supports entity intersection for precise multi-entity queries.',
23
+ description: 'Search stored sessions by keyword or natural language. Use entities when the user names specific people, projects, files, tools, or concepts; entityMode="all" hard-filters to sessions containing every entity (default "any" boosts). Use mode to force fts/vector/hybrid (default hybrid). Use dateFrom/dateTo for time-bounded recall.',
24
24
  inputSchema: {
25
25
  type: 'object',
26
26
  additionalProperties: false,
package/core/storage.js CHANGED
@@ -59,7 +59,7 @@ async function upsertSession(pool, {
59
59
  (tenant_id, session_id, session_key, agent_id, source, messages,
60
60
  msg_count, user_count, assistant_count, model, tokens_in, tokens_out,
61
61
  started_at, ended_at, last_message_at, processing_status)
62
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,now(),$14,'pending')
62
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,COALESCE($13,now()),COALESCE($14,now()),$14,'pending')
63
63
  ON CONFLICT (tenant_id, agent_id, session_id) DO UPDATE SET
64
64
  session_key = EXCLUDED.session_key,
65
65
  source = COALESCE(EXCLUDED.source, ${qi(schema)}.sessions.source),
@@ -71,7 +71,7 @@ async function upsertSession(pool, {
71
71
  tokens_in = EXCLUDED.tokens_in,
72
72
  tokens_out = EXCLUDED.tokens_out,
73
73
  started_at = COALESCE(EXCLUDED.started_at, ${qi(schema)}.sessions.started_at),
74
- ended_at = now(),
74
+ ended_at = COALESCE(EXCLUDED.last_message_at, ${qi(schema)}.sessions.ended_at),
75
75
  last_message_at = COALESCE(EXCLUDED.last_message_at, ${qi(schema)}.sessions.last_message_at),
76
76
  processing_status = 'pending',
77
77
  processing_error = NULL
@@ -223,9 +223,13 @@ async function searchSessions(pool, query, {
223
223
  dateFrom,
224
224
  dateTo,
225
225
  limit = 20,
226
+ ftsConfig = 'simple',
226
227
  } = {}) {
227
228
  const clampedLimit = Math.max(1, Math.min(100, limit));
228
229
 
230
+ // Whitelist tsconfig to prevent injection
231
+ const cfg = (ftsConfig === 'zhcfg' || ftsConfig === 'simple') ? ftsConfig : 'simple';
232
+
229
233
  // Normalize agentId/agentIds
230
234
  const agentIds = rawAgentIds && rawAgentIds.length > 0
231
235
  ? rawAgentIds
@@ -237,7 +241,7 @@ async function searchSessions(pool, query, {
237
241
  // Primary: trigram ILIKE on search_text (works for CJK + Latin)
238
242
  // Fallback: tsvector FTS (for installations without search_text populated)
239
243
  const where = [
240
- `(ss.search_text ILIKE '%' || $1 || '%' OR ss.search_tsv @@ plainto_tsquery('simple', $2))`,
244
+ `(ss.search_text ILIKE '%' || $1 || '%' OR ss.search_tsv @@ plainto_tsquery('${cfg}', $2))`,
241
245
  `s.tenant_id = $3`,
242
246
  ];
243
247
  const params = [likeQuery, query, tenantId];
@@ -276,10 +280,15 @@ async function searchSessions(pool, query, {
276
280
  ss.trust_score,
277
281
  CASE WHEN ss.search_text IS NOT NULL
278
282
  THEN similarity(ss.search_text, $2)
279
- ELSE ts_rank(ss.search_tsv, plainto_tsquery('simple', $2))
283
+ ELSE ts_rank(ss.search_tsv, plainto_tsquery('${cfg}', $2))
280
284
  END AS fts_rank
281
285
  FROM ${qi(schema)}.sessions s
282
- LEFT JOIN ${qi(schema)}.session_summaries ss ON ss.session_row_id = s.id
286
+ -- INNER JOIN: the WHERE clause references ss.search_text / ss.search_tsv,
287
+ -- which a LEFT JOIN would leave NULL for unenriched sessions — filtering
288
+ -- them out. Be explicit: FTS recall is a SUMMARIZED-sessions search. Raw
289
+ -- unenriched sessions don't participate. Named searchSessions for historic
290
+ -- reasons; semantically it is search-over-enriched-sessions.
291
+ JOIN ${qi(schema)}.session_summaries ss ON ss.session_row_id = s.id
283
292
  WHERE ${where.join(' AND ')}
284
293
  ORDER BY
285
294
  COALESCE(ss.search_text ILIKE '%' || $1 || '%', FALSE) DESC,
@@ -512,6 +521,73 @@ async function searchTurnEmbeddings(pool, {
512
521
  return { rows: fallback.rows };
513
522
  }
514
523
 
524
+ // ---------------------------------------------------------------------------
525
+ // searchSummaryEmbeddings — pgvector cosine search on session_summaries.embedding
526
+ // ---------------------------------------------------------------------------
527
+
528
+ async function searchSummaryEmbeddings(pool, {
529
+ schema,
530
+ tenantId,
531
+ queryVec,
532
+ agentId,
533
+ agentIds: rawAgentIds,
534
+ source,
535
+ dateFrom,
536
+ dateTo,
537
+ candidateSessionIds,
538
+ limit = 15,
539
+ } = {}) {
540
+ const where = ['s.tenant_id = $1'];
541
+ const params = [tenantId];
542
+
543
+ params.push(`[${queryVec.join(',')}]`);
544
+ const vecPos = params.length;
545
+
546
+ const agentIds = rawAgentIds && rawAgentIds.length > 0
547
+ ? rawAgentIds
548
+ : (agentId ? [agentId] : null);
549
+
550
+ if (dateFrom) {
551
+ params.push(dateFrom);
552
+ where.push(`s.started_at::date >= $${params.length}::date`);
553
+ }
554
+ if (dateTo) {
555
+ params.push(dateTo);
556
+ where.push(`s.started_at::date <= $${params.length}::date`);
557
+ }
558
+ if (agentIds) {
559
+ params.push(agentIds);
560
+ where.push(`s.agent_id = ANY($${params.length})`);
561
+ }
562
+ if (source) {
563
+ params.push(source);
564
+ where.push(`s.source = $${params.length}`);
565
+ }
566
+ if (candidateSessionIds && candidateSessionIds.length > 0) {
567
+ params.push(candidateSessionIds);
568
+ where.push(`s.session_id = ANY($${params.length})`);
569
+ }
570
+
571
+ params.push(limit);
572
+
573
+ const result = await pool.query(
574
+ `SELECT
575
+ s.id, s.session_id, s.agent_id, s.source, s.started_at, s.last_message_at,
576
+ ss.summary_text, ss.structured_summary, ss.access_count, ss.last_accessed_at,
577
+ ss.trust_score,
578
+ (ss.embedding <=> $${vecPos}::vector) AS distance
579
+ FROM ${qi(schema)}.session_summaries ss
580
+ JOIN ${qi(schema)}.sessions s ON s.id = ss.session_row_id
581
+ WHERE ss.embedding IS NOT NULL
582
+ AND ${where.join(' AND ')}
583
+ ORDER BY distance ASC
584
+ LIMIT $${params.length}`,
585
+ params
586
+ );
587
+
588
+ return { rows: result.rows };
589
+ }
590
+
515
591
  // ---------------------------------------------------------------------------
516
592
  // recordFeedback — explicit trust feedback with audit trail
517
593
  // ---------------------------------------------------------------------------
@@ -605,5 +681,6 @@ module.exports = {
605
681
  extractUserTurns,
606
682
  upsertTurnEmbeddings,
607
683
  searchTurnEmbeddings,
684
+ searchSummaryEmbeddings,
608
685
  recordFeedback,
609
686
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shadowforge0/aquifer-memory",
3
- "version": "1.3.0",
3
+ "version": "1.5.9",
4
4
  "description": "PG-native long-term memory for AI agents. Turn-level embedding, hybrid RRF ranking, optional knowledge graph. MCP server, CLI, and library API.",
5
5
  "main": "index.js",
6
6
  "files": [