@shadowforge0/aquifer-memory 1.7.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.env.example +8 -0
  2. package/README.md +66 -0
  3. package/aquifer.config.example.json +19 -0
  4. package/consumers/cli.js +217 -14
  5. package/consumers/codex-active-checkpoint.js +186 -0
  6. package/consumers/codex-current-memory.js +106 -0
  7. package/consumers/codex-handoff.js +442 -3
  8. package/consumers/codex.js +164 -107
  9. package/consumers/mcp.js +144 -6
  10. package/consumers/shared/config.js +60 -1
  11. package/consumers/shared/factory.js +10 -3
  12. package/core/aquifer.js +351 -840
  13. package/core/backends/capabilities.js +89 -0
  14. package/core/backends/local.js +430 -0
  15. package/core/legacy-bootstrap.js +140 -0
  16. package/core/mcp-manifest.js +66 -2
  17. package/core/memory-promotion.js +157 -26
  18. package/core/memory-recall.js +341 -22
  19. package/core/memory-records.js +128 -8
  20. package/core/memory-serving.js +132 -0
  21. package/core/postgres-migrations.js +533 -0
  22. package/core/public-session-filter.js +40 -0
  23. package/core/recall-runtime.js +115 -0
  24. package/core/scope-attribution.js +279 -0
  25. package/core/session-checkpoint-producer.js +412 -0
  26. package/core/session-checkpoints.js +432 -0
  27. package/core/session-finalization.js +82 -1
  28. package/core/storage-checkpoints.js +546 -0
  29. package/core/storage.js +121 -8
  30. package/docs/setup.md +22 -0
  31. package/package.json +8 -4
  32. package/schema/014-v1-checkpoint-runs.sql +349 -0
  33. package/schema/015-v1-evidence-items.sql +92 -0
  34. package/schema/016-v1-evidence-ref-multi-item.sql +19 -0
  35. package/schema/017-v1-memory-record-embeddings.sql +25 -0
  36. package/schema/018-v1-finalization-candidate-envelope.sql +39 -0
  37. package/scripts/codex-checkpoint-commands.js +464 -0
  38. package/scripts/codex-checkpoint-runtime.js +520 -0
  39. package/scripts/codex-recovery.js +105 -0
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { resolveApplicableRecords } = require('./memory-bootstrap');
4
+ const { hybridRank } = require('./hybrid-rank');
4
5
 
5
6
  const TYPE_RANK = {
6
7
  constraint: 80,
@@ -22,6 +23,13 @@ const FEEDBACK_WEIGHT = {
22
23
  incorrect: -0.50,
23
24
  };
24
25
 
26
+ const RETRIEVAL_TYPE_BOOST = 0.05;
27
+ const SIGNAL_PRIORITY = {
28
+ linked_summary: 1,
29
+ evidence_item: 2,
30
+ memory_row: 3,
31
+ };
32
+
25
33
  const TYPE_RANK_SQL = `
26
34
  CASE m.memory_type
27
35
  WHEN 'constraint' THEN 0.80
@@ -35,6 +43,8 @@ const TYPE_RANK_SQL = `
35
43
  ELSE 0
36
44
  END`;
37
45
 
46
+ const TYPE_BOOST_SQL = `(${TYPE_RANK_SQL}) * ${RETRIEVAL_TYPE_BOOST}`;
47
+
38
48
  function feedbackScoreSql(schema) {
39
49
  return `
40
50
  COALESCE((
@@ -109,6 +119,10 @@ function rankValue(record, key) {
109
119
  }
110
120
 
111
121
  function sortRecallRows(a, b) {
122
+ const aSignalPriority = rankValue(a, 'signal_priority');
123
+ const bSignalPriority = rankValue(b, 'signal_priority');
124
+ if (bSignalPriority !== aSignalPriority) return bSignalPriority - aSignalPriority;
125
+
112
126
  const aTitleMatch = a.title_match === true ? 1 : 0;
113
127
  const bTitleMatch = b.title_match === true ? 1 : 0;
114
128
  if (bTitleMatch !== aTitleMatch) return bTitleMatch - aTitleMatch;
@@ -124,6 +138,82 @@ function sortRecallRows(a, b) {
124
138
  return getId(a).localeCompare(getId(b));
125
139
  }
126
140
 
141
+ function memoryRecallKey(row) {
142
+ return String(row && (row.id || row.memory_id || row.memoryId || row.canonical_key || row.canonicalKey || ''));
143
+ }
144
+
145
+ function rankHybridMemoryRows(lexicalRows = [], embeddingRows = [], opts = {}) {
146
+ const limit = Math.max(1, Math.min(50, opts.limit || 10));
147
+ const rowsById = new Map();
148
+ function remember(row, signal) {
149
+ const id = memoryRecallKey(row);
150
+ if (!id) return;
151
+ const existing = rowsById.get(id);
152
+ const next = existing ? { ...existing, ...row } : { ...row };
153
+ const signals = new Set(existing && Array.isArray(existing._matchSignals) ? existing._matchSignals : []);
154
+ signals.add(signal);
155
+ next._matchSignals = [...signals];
156
+ next.match_signal = signals.size > 1 ? 'memory_row_hybrid' : 'memory_row';
157
+ delete next.signal_priority;
158
+ rowsById.set(id, next);
159
+ }
160
+ for (const row of lexicalRows || []) remember(row, 'lexical');
161
+ for (const row of embeddingRows || []) remember(row, 'semantic');
162
+
163
+ function adapt(row) {
164
+ const id = memoryRecallKey(row);
165
+ return {
166
+ ...row,
167
+ session_id: id,
168
+ started_at: row.accepted_at || row.observed_at || row.updated_at || row.created_at || row.started_at,
169
+ trust_score: row.trust_score ?? 0.5,
170
+ };
171
+ }
172
+
173
+ const fused = hybridRank(
174
+ (lexicalRows || []).map(adapt),
175
+ (embeddingRows || []).map(adapt),
176
+ [],
177
+ {
178
+ limit: Math.max(limit, rowsById.size || limit),
179
+ weights: { rrf: 0.82, timeDecay: 0.12, access: 0.06, entityBoost: 0, openLoop: 0 },
180
+ },
181
+ );
182
+
183
+ const scored = fused.map(fusedRow => {
184
+ const id = memoryRecallKey(fusedRow);
185
+ const row = rowsById.get(id) || fusedRow;
186
+ const rowScore = rankValue(row, 'recall_score') || rankValue(row, 'score') || rankValue(row, 'semantic_score') || rankValue(row, 'lexical_rank');
187
+ const typeScore = rankValue(row, 'type_rank');
188
+ const feedback = rankValue(row, 'feedback_score');
189
+ const score = (0.82 * rankValue(fusedRow, '_score')) + (0.14 * Math.min(1, Math.max(0, rowScore))) + (0.02 * typeScore) + (0.02 * feedback);
190
+ const ranked = {
191
+ ...row,
192
+ recall_score: score,
193
+ score,
194
+ _score: score,
195
+ _rrf: fusedRow._rrf,
196
+ _timeDecay: fusedRow._timeDecay,
197
+ _access: fusedRow._access,
198
+ };
199
+ delete ranked.session_id;
200
+ delete ranked.signal_priority;
201
+ return ranked;
202
+ });
203
+
204
+ scored.sort((a, b) => {
205
+ const aScore = rankValue(a, '_score');
206
+ const bScore = rankValue(b, '_score');
207
+ if (bScore !== aScore) return bScore - aScore;
208
+ const aAccepted = Date.parse(a.accepted_at || a.acceptedAt || '') || 0;
209
+ const bAccepted = Date.parse(b.accepted_at || b.acceptedAt || '') || 0;
210
+ if (bAccepted !== aAccepted) return bAccepted - aAccepted;
211
+ return memoryRecallKey(a).localeCompare(memoryRecallKey(b));
212
+ });
213
+
214
+ return scored.slice(0, limit);
215
+ }
216
+
127
217
  function feedbackScore(record, feedbackEvents = []) {
128
218
  const id = getId(record);
129
219
  let score = 0;
@@ -144,6 +234,14 @@ function lexicalScore(haystack, query) {
144
234
  return hits / tokens.length;
145
235
  }
146
236
 
237
+ function vecToStr(vec) {
238
+ if (!vec || !Array.isArray(vec) || vec.length === 0) return null;
239
+ for (let i = 0; i < vec.length; i++) {
240
+ if (!Number.isFinite(vec[i])) throw new Error(`Vector contains non-finite value at index ${i}`);
241
+ }
242
+ return `[${vec.join(',')}]`;
243
+ }
244
+
147
245
  function recallMemoryRecords(records = [], query, opts = {}) {
148
246
  const q = String(query || '').trim().toLowerCase();
149
247
  if (!q) throw new Error('memory.recall(query): query must be a non-empty string');
@@ -160,11 +258,13 @@ function recallMemoryRecords(records = [], query, opts = {}) {
160
258
  .map(record => {
161
259
  const haystack = textOf(record).toLowerCase();
162
260
  const lexical = lexicalScore(haystack, q);
163
- const typeRank = (TYPE_RANK[record.memoryType || record.memory_type] || 0) / 100;
261
+ const typeRank = ((TYPE_RANK[record.memoryType || record.memory_type] || 0) / 100) * RETRIEVAL_TYPE_BOOST;
164
262
  const feedback = feedbackScore(record, feedbackEvents);
165
263
  return {
166
264
  ...record,
167
265
  score: lexical + typeRank + feedback,
266
+ signal_priority: SIGNAL_PRIORITY.memory_row,
267
+ match_signal: 'memory_row',
168
268
  _debug: { lexical, typeRank, feedback },
169
269
  };
170
270
  })
@@ -174,11 +274,32 @@ function recallMemoryRecords(records = [], query, opts = {}) {
174
274
  }
175
275
 
176
276
  function createMemoryRecall({ pool, schema, defaultTenantId }) {
277
+ function applyCurrentMemoryFilters(where, params, opts = {}) {
278
+ const scopeKeys = activeScopeKeys(opts);
279
+ if (opts.scopeId) {
280
+ params.push(opts.scopeId);
281
+ where.push(`m.scope_id = $${params.length}`);
282
+ }
283
+ if (scopeKeys) {
284
+ params.push(scopeKeys);
285
+ where.push(`s.scope_key = ANY($${params.length}::text[])`);
286
+ }
287
+ if (opts.asOf) {
288
+ params.push(opts.asOf);
289
+ const at = `$${params.length}::timestamptz`;
290
+ where.push(`(m.valid_from IS NULL OR m.valid_from <= ${at})`);
291
+ where.push(`(m.valid_to IS NULL OR m.valid_to > ${at})`);
292
+ where.push(`(m.stale_after IS NULL OR m.stale_after > ${at})`);
293
+ }
294
+ return scopeKeys;
295
+ }
296
+
177
297
  async function recall(query, opts = {}) {
178
298
  const q = String(query || '').trim();
179
299
  if (!q) throw new Error('memory.recall(query): query must be a non-empty string');
180
300
  const tenantId = opts.tenantId || defaultTenantId;
181
301
  const limit = Math.max(1, Math.min(50, opts.limit || 10));
302
+ const cfg = (opts.ftsConfig === 'zhcfg' || opts.ftsConfig === 'simple') ? opts.ftsConfig : 'simple';
182
303
  const scopeKeys = activeScopeKeys(opts);
183
304
  const fetchLimit = Math.max(limit, Math.min(200, scopeKeys ? limit * 4 : limit));
184
305
  const feedbackScoreExpr = feedbackScoreSql(schema);
@@ -187,37 +308,25 @@ function createMemoryRecall({ pool, schema, defaultTenantId }) {
187
308
  `m.tenant_id = $1`,
188
309
  `m.status = 'active'`,
189
310
  `m.visible_in_recall = true`,
190
- `(m.search_tsv @@ plainto_tsquery('simple', $2)
311
+ `(m.search_tsv @@ plainto_tsquery('${cfg}', $2)
191
312
  OR m.title ILIKE '%' || $2 || '%'
192
313
  OR m.summary ILIKE '%' || $2 || '%'
193
314
  OR m.context_key ILIKE '%' || $2 || '%'
194
315
  OR m.topic_key ILIKE '%' || $2 || '%')`,
195
316
  ];
196
- if (opts.scopeId) {
197
- params.push(opts.scopeId);
198
- where.push(`m.scope_id = $${params.length}`);
199
- }
200
- if (scopeKeys) {
201
- params.push(scopeKeys);
202
- where.push(`s.scope_key = ANY($${params.length}::text[])`);
203
- }
204
- if (opts.asOf) {
205
- params.push(opts.asOf);
206
- const at = `$${params.length}::timestamptz`;
207
- where.push(`(m.valid_from IS NULL OR m.valid_from <= ${at})`);
208
- where.push(`(m.valid_to IS NULL OR m.valid_to > ${at})`);
209
- where.push(`(m.stale_after IS NULL OR m.stale_after > ${at})`);
210
- }
317
+ applyCurrentMemoryFilters(where, params, opts);
211
318
  params.push(fetchLimit);
212
319
  const result = await pool.query(
213
320
  `SELECT
214
321
  m.*, s.scope_kind, s.scope_key, s.inheritance_mode AS scope_inheritance_mode,
322
+ 'memory_row'::text AS match_signal,
323
+ ${SIGNAL_PRIORITY.memory_row}::int AS signal_priority,
215
324
  (m.title ILIKE '%' || $2 || '%') AS title_match,
216
- ts_rank(m.search_tsv, plainto_tsquery('simple', $2)) AS lexical_rank,
217
- ${TYPE_RANK_SQL} AS type_rank,
325
+ ts_rank(m.search_tsv, plainto_tsquery('${cfg}', $2)) AS lexical_rank,
326
+ ${TYPE_BOOST_SQL} AS type_rank,
218
327
  ${feedbackScoreExpr} AS feedback_score,
219
- ts_rank(m.search_tsv, plainto_tsquery('simple', $2))
220
- + ${TYPE_RANK_SQL}
328
+ ts_rank(m.search_tsv, plainto_tsquery('${cfg}', $2))
329
+ + ${TYPE_BOOST_SQL}
221
330
  + ${feedbackScoreExpr} AS recall_score
222
331
  FROM ${schema}.memory_records m
223
332
  JOIN ${schema}.scopes s ON s.id = m.scope_id
@@ -238,10 +347,220 @@ function createMemoryRecall({ pool, schema, defaultTenantId }) {
238
347
  .slice(0, limit);
239
348
  }
240
349
 
241
- return { recall };
350
+ async function recallViaEvidenceItems(query, opts = {}) {
351
+ const q = String(query || '').trim();
352
+ if (!q) throw new Error('memory.recall(query): query must be a non-empty string');
353
+ const tenantId = opts.tenantId || defaultTenantId;
354
+ const limit = Math.max(1, Math.min(50, opts.limit || 10));
355
+ const scopeKeys = activeScopeKeys(opts);
356
+ const fetchLimit = Math.max(limit, Math.min(200, scopeKeys ? limit * 4 : limit));
357
+ const feedbackScoreExpr = feedbackScoreSql(schema);
358
+ const params = [tenantId, q];
359
+ const where = [
360
+ `m.tenant_id = $1`,
361
+ `m.status = 'active'`,
362
+ `m.visible_in_recall = true`,
363
+ ];
364
+ applyCurrentMemoryFilters(where, params, opts);
365
+ const queryVec = vecToStr(opts.queryVec);
366
+ let vectorScoreExpr = '0';
367
+ let evidencePredicate = `(ei.excerpt_text ILIKE '%' || $2 || '%'
368
+ OR ei.search_tsv @@ plainto_tsquery('simple', $2))`;
369
+ if (queryVec) {
370
+ params.push(queryVec);
371
+ const vecPos = params.length;
372
+ vectorScoreExpr = `COALESCE(1.0 - (ei.embedding <=> $${vecPos}::vector), 0)`;
373
+ evidencePredicate = opts.vectorOnly === true
374
+ ? `ei.embedding IS NOT NULL`
375
+ : `(${evidencePredicate} OR ei.embedding IS NOT NULL)`;
376
+ }
377
+ params.push(fetchLimit);
378
+ const result = await pool.query(
379
+ `WITH eligible_memories AS (
380
+ SELECT m.*, s.scope_kind, s.scope_key, s.inheritance_mode AS scope_inheritance_mode
381
+ FROM ${schema}.memory_records m
382
+ JOIN ${schema}.scopes s ON s.id = m.scope_id
383
+ WHERE ${where.join(' AND ')}
384
+ ),
385
+ evidence_hits AS (
386
+ SELECT
387
+ e.owner_id AS memory_id,
388
+ MAX(
389
+ CASE WHEN ei.excerpt_text ILIKE '%' || $2 || '%' THEN 1 ELSE 0 END
390
+ + ts_rank(ei.search_tsv, plainto_tsquery('simple', $2))
391
+ + similarity(ei.excerpt_text, $2)
392
+ + ${vectorScoreExpr}
393
+ ) AS evidence_score,
394
+ MAX(ei.created_at) AS latest_evidence_at
395
+ FROM ${schema}.evidence_items ei
396
+ JOIN ${schema}.evidence_refs e
397
+ ON e.tenant_id = ei.tenant_id
398
+ AND e.evidence_item_id = ei.id
399
+ AND e.owner_kind = 'memory_record'
400
+ JOIN eligible_memories em ON em.id = e.owner_id
401
+ WHERE ei.tenant_id = $1
402
+ AND ${evidencePredicate}
403
+ GROUP BY e.owner_id
404
+ )
405
+ SELECT
406
+ m.*,
407
+ 'evidence_item'::text AS match_signal,
408
+ ${SIGNAL_PRIORITY.evidence_item}::int AS signal_priority,
409
+ FALSE AS title_match,
410
+ 0::real AS lexical_rank,
411
+ eh.evidence_score,
412
+ ${TYPE_BOOST_SQL} AS type_rank,
413
+ ${feedbackScoreExpr} AS feedback_score,
414
+ eh.evidence_score
415
+ + ${TYPE_BOOST_SQL}
416
+ + ${feedbackScoreExpr} AS recall_score
417
+ FROM evidence_hits eh
418
+ JOIN eligible_memories m ON m.id = eh.memory_id
419
+ ORDER BY
420
+ recall_score DESC,
421
+ eh.latest_evidence_at DESC NULLS LAST,
422
+ m.accepted_at DESC NULLS LAST,
423
+ m.id ASC
424
+ LIMIT $${params.length}`,
425
+ params,
426
+ );
427
+ const applicableRows = scopeKeys
428
+ ? resolveApplicableRecords(result.rows, opts)
429
+ : result.rows;
430
+ return applicableRows
431
+ .sort(sortRecallRows)
432
+ .slice(0, limit);
433
+ }
434
+
435
+ async function recallViaMemoryEmbeddings(queryVec, opts = {}) {
436
+ const vector = vecToStr(queryVec);
437
+ if (!vector) return [];
438
+ const tenantId = opts.tenantId || defaultTenantId;
439
+ const limit = Math.max(1, Math.min(50, opts.limit || 10));
440
+ const scopeKeys = activeScopeKeys(opts);
441
+ const fetchLimit = Math.max(limit, Math.min(200, scopeKeys ? limit * 4 : limit));
442
+ const feedbackScoreExpr = feedbackScoreSql(schema);
443
+ const params = [tenantId, vector];
444
+ const where = [
445
+ `m.tenant_id = $1`,
446
+ `m.status = 'active'`,
447
+ `m.visible_in_recall = true`,
448
+ `m.embedding IS NOT NULL`,
449
+ ];
450
+ applyCurrentMemoryFilters(where, params, opts);
451
+ params.push(fetchLimit);
452
+ const result = await pool.query(
453
+ `SELECT
454
+ m.*, s.scope_kind, s.scope_key, s.inheritance_mode AS scope_inheritance_mode,
455
+ 'memory_row'::text AS match_signal,
456
+ ${SIGNAL_PRIORITY.memory_row}::int AS signal_priority,
457
+ FALSE AS title_match,
458
+ 0::real AS lexical_rank,
459
+ 1.0 - (m.embedding <=> $2::vector) AS semantic_score,
460
+ ${TYPE_BOOST_SQL} AS type_rank,
461
+ ${feedbackScoreExpr} AS feedback_score,
462
+ 1.0 - (m.embedding <=> $2::vector)
463
+ + ${TYPE_BOOST_SQL}
464
+ + ${feedbackScoreExpr} AS recall_score
465
+ FROM ${schema}.memory_records m
466
+ JOIN ${schema}.scopes s ON s.id = m.scope_id
467
+ WHERE ${where.join(' AND ')}
468
+ ORDER BY
469
+ m.embedding <=> $2::vector ASC,
470
+ m.accepted_at DESC NULLS LAST,
471
+ m.id ASC
472
+ LIMIT $${params.length}`,
473
+ params,
474
+ );
475
+ const applicableRows = scopeKeys
476
+ ? resolveApplicableRecords(result.rows, opts)
477
+ : result.rows;
478
+ return applicableRows
479
+ .sort(sortRecallRows)
480
+ .slice(0, limit);
481
+ }
482
+
483
+ async function recallViaLinkedSummaryEmbeddings(queryVec, opts = {}) {
484
+ const vector = vecToStr(queryVec);
485
+ if (!vector) return [];
486
+ const tenantId = opts.tenantId || defaultTenantId;
487
+ const limit = Math.max(1, Math.min(50, opts.limit || 10));
488
+ const scopeKeys = activeScopeKeys(opts);
489
+ const fetchLimit = Math.max(limit, Math.min(200, scopeKeys ? limit * 4 : limit));
490
+ const feedbackScoreExpr = feedbackScoreSql(schema);
491
+ const params = [tenantId, vector];
492
+ const where = [
493
+ `m.tenant_id = $1`,
494
+ `m.status = 'active'`,
495
+ `m.visible_in_recall = true`,
496
+ ];
497
+ applyCurrentMemoryFilters(where, params, opts);
498
+ params.push(fetchLimit);
499
+ const result = await pool.query(
500
+ `WITH eligible_memories AS (
501
+ SELECT m.*, s.scope_kind, s.scope_key, s.inheritance_mode AS scope_inheritance_mode
502
+ FROM ${schema}.memory_records m
503
+ JOIN ${schema}.scopes s ON s.id = m.scope_id
504
+ WHERE ${where.join(' AND ')}
505
+ ),
506
+ linked_summary_hits AS (
507
+ SELECT
508
+ e.owner_id AS memory_id,
509
+ MAX(1.0 - (ss.embedding <=> $2::vector)) AS linked_summary_score,
510
+ MAX(ss.updated_at) AS latest_summary_at
511
+ FROM ${schema}.evidence_refs e
512
+ JOIN ${schema}.sessions src
513
+ ON src.tenant_id = e.tenant_id
514
+ AND src.session_id = e.source_ref
515
+ JOIN ${schema}.session_summaries ss
516
+ ON ss.session_row_id = src.id
517
+ WHERE e.tenant_id = $1
518
+ AND e.owner_kind = 'memory_record'
519
+ AND e.source_kind = 'session_summary'
520
+ AND ss.embedding IS NOT NULL
521
+ AND EXISTS (SELECT 1 FROM eligible_memories em WHERE em.id = e.owner_id)
522
+ GROUP BY e.owner_id
523
+ )
524
+ SELECT
525
+ m.*,
526
+ 'linked_summary'::text AS match_signal,
527
+ ${SIGNAL_PRIORITY.linked_summary}::int AS signal_priority,
528
+ FALSE AS title_match,
529
+ 0::real AS lexical_rank,
530
+ lsh.linked_summary_score,
531
+ 0::real AS type_rank,
532
+ ${feedbackScoreExpr} AS feedback_score,
533
+ (lsh.linked_summary_score * 0.35)
534
+ + ${feedbackScoreExpr} AS recall_score
535
+ FROM linked_summary_hits lsh
536
+ JOIN eligible_memories m ON m.id = lsh.memory_id
537
+ ORDER BY
538
+ recall_score DESC,
539
+ lsh.latest_summary_at DESC NULLS LAST,
540
+ m.accepted_at DESC NULLS LAST,
541
+ m.id ASC
542
+ LIMIT $${params.length}`,
543
+ params,
544
+ );
545
+ const applicableRows = scopeKeys
546
+ ? resolveApplicableRecords(result.rows, opts)
547
+ : result.rows;
548
+ return applicableRows
549
+ .sort(sortRecallRows)
550
+ .slice(0, limit);
551
+ }
552
+
553
+ return {
554
+ recall,
555
+ recallViaEvidenceItems,
556
+ recallViaMemoryEmbeddings,
557
+ recallViaLinkedSummaryEmbeddings,
558
+ rankHybridMemoryRows,
559
+ };
242
560
  }
243
561
 
244
562
  module.exports = {
245
563
  recallMemoryRecords,
246
564
  createMemoryRecall,
565
+ rankHybridMemoryRows,
247
566
  };
@@ -17,6 +17,14 @@ function toJsonOrNull(value) {
17
17
  return value === undefined || value === null ? null : JSON.stringify(value);
18
18
  }
19
19
 
20
+ function vecToStr(vec) {
21
+ if (!vec || !Array.isArray(vec) || vec.length === 0) return null;
22
+ for (let i = 0; i < vec.length; i++) {
23
+ if (!Number.isFinite(vec[i])) throw new Error(`Vector contains non-finite value at index ${i}`);
24
+ }
25
+ return `[${vec.join(',')}]`;
26
+ }
27
+
20
28
  function advisoryLockKeys(namespace, value) {
21
29
  const digest = crypto.createHash('sha256').update(`${namespace}:${value}`).digest();
22
30
  return [digest.readInt32BE(0), digest.readInt32BE(4)];
@@ -104,11 +112,13 @@ function compareRecordIdAsc(a, b) {
104
112
  }
105
113
 
106
114
  function normalizeCurrentMemoryRow(row = {}) {
115
+ const { embedding: _embedding, ...publicRow } = row;
116
+ void _embedding;
107
117
  const memoryId = row.memoryId ?? row.memory_id ?? row.id ?? null;
108
118
  const evidenceRefsValue = row.evidenceRefs ?? row.evidence_refs ?? [];
109
119
  const evidenceRefs = Array.isArray(evidenceRefsValue) ? evidenceRefsValue : [];
110
120
  return {
111
- ...row,
121
+ ...publicRow,
112
122
  memoryId: memoryId === null ? null : String(memoryId),
113
123
  canonicalKey: row.canonicalKey ?? row.canonical_key ?? null,
114
124
  memoryType: row.memoryType ?? row.memory_type ?? null,
@@ -170,6 +180,7 @@ function createMemoryRecords({ pool, schema, defaultTenantId, inTransaction = fa
170
180
  const memories = `${schema}.memory_records`;
171
181
  const factAssertions = `${schema}.fact_assertions_v1`;
172
182
  const evidenceRefs = `${schema}.evidence_refs`;
183
+ const evidenceItems = `${schema}.evidence_items`;
173
184
  const feedback = `${schema}.feedback`;
174
185
  const canTransact = typeof pool.connect === 'function';
175
186
 
@@ -251,12 +262,12 @@ function createMemoryRecords({ pool, schema, defaultTenantId, inTransaction = fa
251
262
  valid_to, stale_after, superseded_by, backing_fact_id, observed_at,
252
263
  revoked_at, superseded_at, version_id, visible_in_bootstrap,
253
264
  visible_in_recall, rank_features, created_by_finalization_id,
254
- created_by_compaction_run_id
265
+ created_by_compaction_run_id, embedding
255
266
  )
256
267
  VALUES (
257
268
  $1,$2,$3,$4,$5,$6,$7,COALESCE($8,''),COALESCE($9::jsonb,'{}'::jsonb),
258
269
  COALESCE($10,'candidate'),COALESCE($11,'llm_inference'),$12,$13,$14,$15,
259
- $16,$17,$18,$19,$20,$21,COALESCE($22,false),COALESCE($23,false),COALESCE($24::jsonb,'{}'::jsonb),$25,$26
270
+ $16,$17,$18,$19,$20,$21,COALESCE($22,false),COALESCE($23,false),COALESCE($24::jsonb,'{}'::jsonb),$25,$26,$27::vector
260
271
  )
261
272
  ON CONFLICT (tenant_id, canonical_key) WHERE status = 'active' DO UPDATE SET
262
273
  scope_id = EXCLUDED.scope_id,
@@ -279,7 +290,8 @@ function createMemoryRecords({ pool, schema, defaultTenantId, inTransaction = fa
279
290
  visible_in_recall = EXCLUDED.visible_in_recall,
280
291
  rank_features = COALESCE(NULLIF(EXCLUDED.rank_features, '{}'::jsonb), ${memories}.rank_features),
281
292
  created_by_finalization_id = COALESCE(${memories}.created_by_finalization_id, EXCLUDED.created_by_finalization_id),
282
- created_by_compaction_run_id = COALESCE(${memories}.created_by_compaction_run_id, EXCLUDED.created_by_compaction_run_id)
293
+ created_by_compaction_run_id = COALESCE(${memories}.created_by_compaction_run_id, EXCLUDED.created_by_compaction_run_id),
294
+ embedding = COALESCE(EXCLUDED.embedding, ${memories}.embedding)
283
295
  RETURNING *`,
284
296
  [
285
297
  tenantId,
@@ -308,6 +320,7 @@ function createMemoryRecords({ pool, schema, defaultTenantId, inTransaction = fa
308
320
  toJson(input.rankFeatures, {}),
309
321
  input.createdByFinalizationId || input.created_by_finalization_id || null,
310
322
  input.createdByCompactionRunId || input.created_by_compaction_run_id || null,
323
+ vecToStr(input.embedding),
311
324
  ]
312
325
  );
313
326
  return result.rows[0] || null;
@@ -394,18 +407,27 @@ function createMemoryRecords({ pool, schema, defaultTenantId, inTransaction = fa
394
407
  requireField(input, 'sourceKind');
395
408
  requireField(input, 'sourceRef');
396
409
  const tenantId = input.tenantId || defaultTenantId;
410
+ const evidenceItemId = input.evidenceItemId || input.evidence_item_id || null;
411
+ const conflictTarget = evidenceItemId
412
+ ? `(tenant_id, owner_kind, owner_id, evidence_item_id, relation_kind)
413
+ WHERE evidence_item_id IS NOT NULL`
414
+ : `(tenant_id, owner_kind, owner_id, source_kind, source_ref, relation_kind)
415
+ WHERE evidence_item_id IS NULL`;
397
416
  const result = await pool.query(
398
417
  `INSERT INTO ${evidenceRefs} (
399
418
  tenant_id, owner_kind, owner_id, source_kind, source_ref,
400
419
  relation_kind, weight, metadata, created_by_finalization_id,
401
- created_by_compaction_run_id
420
+ created_by_compaction_run_id, evidence_item_id
402
421
  )
403
- VALUES ($1,$2,$3,$4,$5,COALESCE($6,'supporting'),COALESCE($7,1.0),COALESCE($8::jsonb,'{}'::jsonb),$9,$10)
404
- ON CONFLICT (tenant_id, owner_kind, owner_id, source_kind, source_ref, relation_kind)
422
+ VALUES ($1,$2,$3,$4,$5,COALESCE($6,'supporting'),COALESCE($7,1.0),COALESCE($8::jsonb,'{}'::jsonb),$9,$10,$11)
423
+ ON CONFLICT ${conflictTarget}
405
424
  DO UPDATE SET weight = EXCLUDED.weight,
425
+ source_kind = EXCLUDED.source_kind,
426
+ source_ref = EXCLUDED.source_ref,
406
427
  metadata = COALESCE(NULLIF(EXCLUDED.metadata, '{}'::jsonb), ${evidenceRefs}.metadata),
407
428
  created_by_finalization_id = COALESCE(${evidenceRefs}.created_by_finalization_id, EXCLUDED.created_by_finalization_id),
408
- created_by_compaction_run_id = COALESCE(${evidenceRefs}.created_by_compaction_run_id, EXCLUDED.created_by_compaction_run_id)
429
+ created_by_compaction_run_id = COALESCE(${evidenceRefs}.created_by_compaction_run_id, EXCLUDED.created_by_compaction_run_id),
430
+ evidence_item_id = COALESCE(${evidenceRefs}.evidence_item_id, EXCLUDED.evidence_item_id)
409
431
  RETURNING *`,
410
432
  [
411
433
  tenantId,
@@ -418,11 +440,55 @@ function createMemoryRecords({ pool, schema, defaultTenantId, inTransaction = fa
418
440
  toJson(input.metadata, {}),
419
441
  input.createdByFinalizationId || input.created_by_finalization_id || null,
420
442
  input.createdByCompactionRunId || input.created_by_compaction_run_id || null,
443
+ evidenceItemId,
421
444
  ]
422
445
  );
423
446
  return result.rows[0] || null;
424
447
  }
425
448
 
449
+ async function upsertEvidenceItem(input = {}) {
450
+ requireField(input, 'sourceKind');
451
+ requireField(input, 'sourceRef');
452
+ requireField(input, 'excerptText');
453
+ const tenantId = input.tenantId || defaultTenantId;
454
+ const excerptText = String(input.excerptText || input.excerpt_text || '').trim();
455
+ const excerptHash = input.excerptHash || input.excerpt_hash || crypto
456
+ .createHash('sha256')
457
+ .update(excerptText)
458
+ .digest('hex');
459
+ const result = await pool.query(
460
+ `INSERT INTO ${evidenceItems} (
461
+ tenant_id, source_kind, source_ref, session_row_id, turn_embedding_id,
462
+ summary_row_id, created_by_finalization_id, excerpt_text, excerpt_hash,
463
+ embedding, metadata
464
+ )
465
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::vector,COALESCE($11::jsonb,'{}'::jsonb))
466
+ ON CONFLICT (tenant_id, source_kind, source_ref, excerpt_hash)
467
+ DO UPDATE SET
468
+ session_row_id = COALESCE(${evidenceItems}.session_row_id, EXCLUDED.session_row_id),
469
+ turn_embedding_id = COALESCE(${evidenceItems}.turn_embedding_id, EXCLUDED.turn_embedding_id),
470
+ summary_row_id = COALESCE(${evidenceItems}.summary_row_id, EXCLUDED.summary_row_id),
471
+ created_by_finalization_id = COALESCE(${evidenceItems}.created_by_finalization_id, EXCLUDED.created_by_finalization_id),
472
+ embedding = COALESCE(${evidenceItems}.embedding, EXCLUDED.embedding),
473
+ metadata = COALESCE(NULLIF(EXCLUDED.metadata, '{}'::jsonb), ${evidenceItems}.metadata)
474
+ RETURNING *`,
475
+ [
476
+ tenantId,
477
+ input.sourceKind || input.source_kind,
478
+ input.sourceRef || input.source_ref,
479
+ input.sessionRowId || input.session_row_id || null,
480
+ input.turnEmbeddingId || input.turn_embedding_id || null,
481
+ input.summaryRowId || input.summary_row_id || null,
482
+ input.createdByFinalizationId || input.created_by_finalization_id || null,
483
+ excerptText,
484
+ excerptHash,
485
+ vecToStr(input.embedding),
486
+ toJson(input.metadata, {}),
487
+ ],
488
+ );
489
+ return result.rows[0] || null;
490
+ }
491
+
426
492
  async function recordFeedback(input = {}) {
427
493
  requireField(input, 'targetKind');
428
494
  requireField(input, 'targetId');
@@ -636,6 +702,9 @@ function createMemoryRecords({ pool, schema, defaultTenantId, inTransaction = fa
636
702
  params.push(input.visibleInRecall === true);
637
703
  where.push(`m.visible_in_recall = $${params.length}`);
638
704
  }
705
+ if (input.withoutEmbedding === true) {
706
+ where.push(`m.embedding IS NULL`);
707
+ }
639
708
  params.push(Math.max(1, Math.min(200, input.limit || 50)));
640
709
  const orderBy = input.visibleInBootstrap === true
641
710
  ? BOOTSTRAP_ORDER_SQL
@@ -652,6 +721,54 @@ function createMemoryRecords({ pool, schema, defaultTenantId, inTransaction = fa
652
721
  return result.rows;
653
722
  }
654
723
 
724
+ async function updateMemoryEmbedding(input = {}) {
725
+ requireField(input, 'memoryId');
726
+ const tenantId = input.tenantId || defaultTenantId;
727
+ const embedding = vecToStr(input.embedding);
728
+ if (!embedding) throw new Error('embedding is required');
729
+ const result = await pool.query(
730
+ `UPDATE ${memories}
731
+ SET embedding = $3::vector,
732
+ updated_at = now()
733
+ WHERE tenant_id = $1 AND id = $2
734
+ AND embedding IS NULL
735
+ RETURNING *`,
736
+ [
737
+ tenantId,
738
+ input.memoryId,
739
+ embedding,
740
+ ]
741
+ );
742
+ if (result.rows[0]) {
743
+ return {
744
+ status: 'updated',
745
+ updated: true,
746
+ skipped: false,
747
+ memory: result.rows[0],
748
+ };
749
+ }
750
+ const existing = await pool.query(
751
+ `SELECT * FROM ${memories}
752
+ WHERE tenant_id = $1 AND id = $2
753
+ LIMIT 1`,
754
+ [tenantId, input.memoryId]
755
+ );
756
+ if (existing.rows[0]) {
757
+ return {
758
+ status: 'skipped_existing_embedding',
759
+ updated: false,
760
+ skipped: true,
761
+ memory: existing.rows[0],
762
+ };
763
+ }
764
+ return {
765
+ status: 'missing',
766
+ updated: false,
767
+ skipped: true,
768
+ memory: null,
769
+ };
770
+ }
771
+
655
772
  async function currentProjection(input = {}) {
656
773
  const tenantId = input.tenantId || defaultTenantId;
657
774
  let activeScopePath = normalizeScopePath(input.activeScopePath, input.activeScopeKey);
@@ -778,6 +895,7 @@ function createMemoryRecords({ pool, schema, defaultTenantId, inTransaction = fa
778
895
  createVersion,
779
896
  upsertMemory,
780
897
  upsertFactAssertion,
898
+ upsertEvidenceItem,
781
899
  linkEvidence,
782
900
  recordFeedback,
783
901
  findActiveByCanonicalKey,
@@ -785,9 +903,11 @@ function createMemoryRecords({ pool, schema, defaultTenantId, inTransaction = fa
785
903
  lockCanonicalKey,
786
904
  updateMemoryStatus,
787
905
  updateMemoryStatusIfCurrent,
906
+ updateMemoryEmbedding,
788
907
  updateFactAssertionStatus,
789
908
  listActive,
790
909
  currentProjection,
910
+ normalizeCurrentMemoryRow,
791
911
  withTransaction,
792
912
  };
793
913