@shadowforge0/aquifer-memory 0.2.0 → 0.3.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 CHANGED
@@ -4,9 +4,9 @@
4
4
 
5
5
  **PG-native long-term memory for AI agents**
6
6
 
7
- *Turn-level embedding, hybrid RRF ranking, optional knowledge graph — all on PostgreSQL + pgvector.*
7
+ *Turn-level embedding, hybrid RRF ranking, trust scoring, entity intersection, knowledge graph — all on PostgreSQL + pgvector.*
8
8
 
9
- [![npm version](https://img.shields.io/npm/v/aquifer-memory)](https://www.npmjs.com/package/aquifer-memory)
9
+ [![npm version](https://img.shields.io/npm/v/@shadowforge0/aquifer-memory)](https://www.npmjs.com/package/@shadowforge0/aquifer-memory)
10
10
  [![PostgreSQL 15+](https://img.shields.io/badge/PostgreSQL-15%2B-336791)](https://www.postgresql.org/)
11
11
  [![pgvector](https://img.shields.io/badge/pgvector-0.7%2B-blue)](https://github.com/pgvector/pgvector)
12
12
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
@@ -59,13 +59,13 @@ Sessions, summaries, turn-level embeddings, entity graph — all live in one dat
59
59
  ### Install
60
60
 
61
61
  ```bash
62
- npm install aquifer-memory
62
+ npm install @shadowforge0/aquifer-memory
63
63
  ```
64
64
 
65
65
  ### Initialize
66
66
 
67
67
  ```javascript
68
- const { createAquifer } = require('aquifer-memory');
68
+ const { createAquifer } = require('@shadowforge0/aquifer-memory');
69
69
 
70
70
  const aquifer = createAquifer({
71
71
  schema: 'memory', // PG schema name (default: 'aquifer')
@@ -132,12 +132,13 @@ const results = await aquifer.recall('auth middleware decision', {
132
132
  │ + pgvector │ │ API │
133
133
  └────────────────┘ └──────┘
134
134
 
135
- ┌─────────────────────────────┐
136
- │ schema/
137
- │ 001-base.sql (sessions,
138
- │ summaries, turns, FTS)
139
- │ 002-entities.sql (KG)
140
- └─────────────────────────────┘
135
+ ┌──────────────────────────────────┐
136
+ │ schema/
137
+ │ 001-base.sql (sessions,
138
+ │ summaries, turns, FTS)
139
+ │ 002-entities.sql (KG)
140
+ │ 003-trust-feedback.sql (trust) │
141
+ └──────────────────────────────────┘
141
142
  ```
142
143
 
143
144
  ### File Reference
@@ -148,12 +149,13 @@ const results = await aquifer.recall('auth middleware decision', {
148
149
  | `core/aquifer.js` | Main facade: `migrate()`, `ingest()`, `recall()`, `enrich()` |
149
150
  | `core/storage.js` | Session/summary/turn CRUD, FTS search, embedding search |
150
151
  | `core/entity.js` | Entity upsert, mention tracking, relation graph, normalization |
151
- | `core/hybrid-rank.js` | 3-way RRF fusion, time decay, entity boost scoring |
152
+ | `core/hybrid-rank.js` | 3-way RRF fusion, time decay, trust multiplier, entity boost, open-loop boost |
152
153
  | `pipeline/summarize.js` | LLM-powered session summarization with structured output |
153
154
  | `pipeline/embed.js` | Embedding client (any OpenAI-compatible API) |
154
155
  | `pipeline/extract-entities.js` | LLM-powered entity extraction (12 types) |
155
156
  | `schema/001-base.sql` | DDL: sessions, summaries, turn_embeddings, FTS indexes |
156
157
  | `schema/002-entities.sql` | DDL: entities, mentions, relations, entity_sessions |
158
+ | `schema/003-trust-feedback.sql` | DDL: trust_score column, session_feedback audit trail |
157
159
 
158
160
  ---
159
161
 
@@ -173,6 +175,38 @@ Query ──┬── FTS (BM25) ──┐
173
175
  - **Reciprocal Rank Fusion** — merges all three ranked lists (K=60)
174
176
  - **Time decay** — sigmoid decay with configurable midpoint and steepness
175
177
  - **Entity boost** — sessions mentioning query-relevant entities get a score boost
178
+ - **Trust scoring** — multiplicative trust multiplier from explicit feedback (helpful/unhelpful)
179
+ - **Open-loop boost** — sessions with unresolved items get a mild recency boost
180
+
181
+ ### Entity Intersection
182
+
183
+ When you know which entities you're looking for, pass them explicitly:
184
+
185
+ ```javascript
186
+ const results = await aquifer.recall('auth decision', {
187
+ entities: ['auth-middleware', 'legal-compliance'],
188
+ entityMode: 'all', // only sessions containing BOTH entities
189
+ });
190
+ ```
191
+
192
+ - `entityMode: 'any'` (default) — boost sessions matching any queried entity
193
+ - `entityMode: 'all'` — hard filter: only return sessions containing every specified entity
194
+
195
+ ### Trust Scoring & Feedback
196
+
197
+ Sessions accumulate trust through explicit feedback. Low-trust memories are suppressed in rankings regardless of relevance.
198
+
199
+ ```javascript
200
+ // After a recall result was useful
201
+ await aquifer.feedback('session-id', { verdict: 'helpful' });
202
+
203
+ // After a recall result was irrelevant
204
+ await aquifer.feedback('session-id', { verdict: 'unhelpful' });
205
+ ```
206
+
207
+ - Asymmetric: helpful +0.05, unhelpful −0.10 (bad memories sink faster)
208
+ - Multiplicative in ranking: trust=0.5 is neutral, trust=0 halves the score, trust=1.0 gives 50% boost
209
+ - Full audit trail in `session_feedback` table
176
210
 
177
211
  ### Turn-Level Embeddings
178
212
 
@@ -253,15 +287,31 @@ Hybrid search across sessions.
253
287
  ```javascript
254
288
  const results = await aquifer.recall('search query', {
255
289
  agentId: 'main',
256
- tenantId: 'default',
257
290
  limit: 10, // max results
258
- ftsLimit: 20, // FTS candidate pool
259
- embLimit: 20, // embedding candidate pool
260
- turnLimit: 20, // turn embedding candidate pool
261
- midpointDays: 45, // time decay midpoint
262
- entityBoostWeight: 0.18, // entity boost factor
291
+ entities: ['postgres', 'migration'], // optional: explicit entity names
292
+ entityMode: 'all', // 'any' (default) or 'all'
293
+ weights: { // optional: override ranking weights
294
+ rrf: 0.65,
295
+ timeDecay: 0.25,
296
+ access: 0.10,
297
+ entityBoost: 0.18,
298
+ openLoop: 0.08,
299
+ },
263
300
  });
264
- // Returns: [{ session_id, score, title, overview, started_at, ... }]
301
+ // Returns: [{ sessionId, score, trustScore, summaryText, matchedTurnText, _debug, ... }]
302
+ ```
303
+
304
+ #### `aquifer.feedback(sessionId, options)`
305
+
306
+ Records explicit trust feedback on a session.
307
+
308
+ ```javascript
309
+ await aquifer.feedback('session-id', {
310
+ verdict: 'helpful', // or 'unhelpful'
311
+ agentId: 'main', // optional
312
+ note: 'reason', // optional
313
+ });
314
+ // Returns: { trustBefore, trustAfter, verdict }
265
315
  ```
266
316
 
267
317
  #### `aquifer.enrich(sessionId, options)`
@@ -335,6 +385,14 @@ Key indexes: GIN on messages, GiST on `tsvector`, ivfflat on embeddings, B-tree
335
385
 
336
386
  Key indexes: trigram on entity names, GiST on embeddings, composite on tenant/agent.
337
387
 
388
+ ### 003-trust-feedback.sql
389
+
390
+ | Table | Purpose |
391
+ |-------|---------|
392
+ | `session_feedback` | Explicit feedback audit trail (helpful/unhelpful verdicts, trust deltas) |
393
+
394
+ Also adds `trust_score` column to `session_summaries` (default 0.5, range 0–1).
395
+
338
396
  ---
339
397
 
340
398
  ## Dependencies
package/core/aquifer.js CHANGED
@@ -127,6 +127,7 @@ function createAquifer(config) {
127
127
  `SELECT
128
128
  s.id, s.session_id, s.agent_id, s.source, s.started_at, s.last_message_at,
129
129
  ss.summary_text, ss.structured_summary, ss.access_count, ss.last_accessed_at,
130
+ ss.trust_score,
130
131
  (ss.embedding <=> $${vecPos}::vector) AS distance
131
132
  FROM ${qi(schema)}.session_summaries ss
132
133
  JOIN ${qi(schema)}.sessions s ON s.id = ss.session_row_id
@@ -158,6 +159,10 @@ function createAquifer(config) {
158
159
  await pool.query(entitySql);
159
160
  }
160
161
 
162
+ // 3. Trust + feedback (always, not gated by entities)
163
+ const trustSql = loadSql('003-trust-feedback.sql', schema);
164
+ await pool.query(trustSql);
165
+
161
166
  migrated = true;
162
167
  },
163
168
 
@@ -461,6 +466,8 @@ function createAquifer(config) {
461
466
  dateTo,
462
467
  limit = 5,
463
468
  weights: overrideWeights,
469
+ entities: explicitEntities,
470
+ entityMode = 'any',
464
471
  } = opts;
465
472
 
466
473
  const fetchLimit = limit * 4;
@@ -470,35 +477,63 @@ function createAquifer(config) {
470
477
  const queryVec = queryVecResult[0];
471
478
  if (!queryVec || !queryVec.length) return []; // m3: guard empty array too
472
479
 
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
- ]);
480
+ // 2. Entity intersection pre-filter (when entityMode === 'all')
481
+ let candidateSessionIds = null; // null = no filter
482
+ let entityScoreBySession = new Map();
485
483
 
486
- const turnRows = turnResult.rows || [];
484
+ if (explicitEntities && explicitEntities.length > 0) {
485
+ if (!entitiesEnabled) throw new Error('Entities are not enabled');
487
486
 
488
- if (ftsRows.length === 0 && embRows.length === 0 && turnRows.length === 0) {
489
- return [];
490
- }
487
+ const resolved = await entity.resolveEntities(pool, {
488
+ schema, tenantId, names: explicitEntities, agentId,
489
+ });
491
490
 
492
- // 3. Entity boost (if enabled)
493
- let entityScoreBySession = new Map();
494
- if (entitiesEnabled) {
491
+ if (resolved.length === 0) return [];
492
+
493
+ // Guard: if 'all' mode but fewer entities resolved than requested,
494
+ // return [] — partial resolution would silently weaken the AND constraint
495
+ if (entityMode === 'all' && resolved.length < new Set(explicitEntities.map(n => entity.normalizeEntityName(n))).size) {
496
+ return [];
497
+ }
498
+
499
+ const entityIds = resolved.map(r => r.entityId);
500
+
501
+ if (entityMode === 'all') {
502
+ // Hard filter: only sessions with ALL entities
503
+ const intersectionRows = await entity.getSessionsByEntityIntersection(pool, {
504
+ schema, entityIds, tenantId, agentId, source, dateFrom, dateTo, limit: fetchLimit,
505
+ });
506
+
507
+ if (intersectionRows.length === 0) return [];
508
+
509
+ candidateSessionIds = new Set(intersectionRows.map(r => r.session_id));
510
+ for (const row of intersectionRows) {
511
+ entityScoreBySession.set(row.session_id, 1.0);
512
+ }
513
+ } else {
514
+ // 'any' mode with explicit entities: use resolved IDs for boost
515
+ const esResult = await pool.query(
516
+ `SELECT es.session_row_id, s.session_id, COUNT(*) AS entity_count
517
+ FROM ${qi(schema)}.entity_sessions es
518
+ JOIN ${qi(schema)}.sessions s ON s.id = es.session_row_id
519
+ WHERE es.entity_id = ANY($1)
520
+ GROUP BY es.session_row_id, s.session_id`,
521
+ [entityIds]
522
+ );
523
+
524
+ const maxCount = Math.max(1, ...esResult.rows.map(r => parseInt(r.entity_count)));
525
+ for (const row of esResult.rows) {
526
+ entityScoreBySession.set(row.session_id, parseInt(row.entity_count) / maxCount);
527
+ }
528
+ }
529
+ } else if (entitiesEnabled) {
530
+ // No explicit entities: existing query-text-based entity boost
495
531
  try {
496
532
  const matchedEntities = await entity.searchEntities(pool, {
497
533
  schema, tenantId, query, agentId, limit: 10,
498
534
  });
499
535
 
500
536
  if (matchedEntities.length > 0) {
501
- // M1 fix: single JOIN instead of N+1
502
537
  const entityIds = matchedEntities.map(e => e.id);
503
538
  const esResult = await pool.query(
504
539
  `SELECT es.session_row_id, s.session_id, COUNT(*) AS entity_count
@@ -517,7 +552,47 @@ function createAquifer(config) {
517
552
  } catch (_) { /* entity search failure non-fatal */ }
518
553
  }
519
554
 
520
- // 4. Run external source searches (parallel + timeout)
555
+ // 3. Run 3 search paths in parallel
556
+ const [ftsRows, embRows, turnResult] = await Promise.all([
557
+ storage.searchSessions(pool, query, {
558
+ schema, tenantId, agentId, source, dateFrom, dateTo, limit: fetchLimit,
559
+ }).catch(() => []),
560
+ embeddingSearchSummaries(queryVec, {
561
+ agentId, source, dateFrom, dateTo, limit: fetchLimit,
562
+ }).catch(() => []),
563
+ storage.searchTurnEmbeddings(pool, {
564
+ schema, tenantId, queryVec, dateFrom, dateTo, agentId, source, limit: fetchLimit,
565
+ }).catch(() => ({ rows: [] })),
566
+ ]);
567
+
568
+ const turnRows = turnResult.rows || [];
569
+
570
+ // 3b. Apply candidate filter (entityMode 'all')
571
+ const filterFn = candidateSessionIds
572
+ ? (rows) => rows.filter(r => candidateSessionIds.has(r.session_id || String(r.id)))
573
+ : (rows) => rows;
574
+
575
+ const filteredFts = filterFn(ftsRows);
576
+ const filteredEmb = filterFn(embRows);
577
+ const filteredTurn = filterFn(turnRows);
578
+
579
+ if (filteredFts.length === 0 && filteredEmb.length === 0 && filteredTurn.length === 0) {
580
+ return [];
581
+ }
582
+
583
+ // 4. Open-loop set extraction
584
+ const openLoopSet = new Set();
585
+ for (const r of [...filteredFts, ...filteredEmb, ...filteredTurn]) {
586
+ const sid = r.session_id || String(r.id);
587
+ const ss = typeof r.structured_summary === 'string'
588
+ ? (() => { try { return JSON.parse(r.structured_summary); } catch (_) { return null; } })()
589
+ : r.structured_summary;
590
+ if (ss && Array.isArray(ss.open_loops) && ss.open_loops.length > 0) {
591
+ openLoopSet.add(sid);
592
+ }
593
+ }
594
+
595
+ // 5. Run external source searches (parallel + timeout)
521
596
  const EXTERNAL_TIMEOUT = 10000;
522
597
  const externalRows = [];
523
598
  const externalPromises = [];
@@ -540,18 +615,21 @@ function createAquifer(config) {
540
615
  }
541
616
  if (externalPromises.length > 0) await Promise.all(externalPromises);
542
617
 
543
- // 5. Hybrid rank — external results as separate embedding-like signal
618
+ // 6. Hybrid rank
544
619
  const mergedWeights = { ...rankWeights, ...overrideWeights };
545
620
  const ranked = hybridRank(
546
- ftsRows,
547
- [...embRows, ...externalRows],
548
- limit,
549
- mergedWeights,
550
- turnRows,
551
- entityScoreBySession,
621
+ filteredFts,
622
+ [...filteredEmb, ...filterFn(externalRows)],
623
+ filteredTurn,
624
+ {
625
+ limit,
626
+ weights: mergedWeights,
627
+ entityScoreBySession,
628
+ openLoopSet,
629
+ },
552
630
  );
553
631
 
554
- // 6. Record access
632
+ // 7. Record access
555
633
  const sessionRowIds = ranked
556
634
  .map(r => r.id || r.session_row_id)
557
635
  .filter(Boolean);
@@ -562,7 +640,7 @@ function createAquifer(config) {
562
640
  } catch (_) { /* access recording non-fatal */ }
563
641
  }
564
642
 
565
- // 7. Format results
643
+ // 8. Format results
566
644
  return ranked.map(r => ({
567
645
  sessionId: r.session_id,
568
646
  agentId: r.agent_id,
@@ -574,15 +652,40 @@ function createAquifer(config) {
574
652
  matchedTurnText: r.matched_turn_text || null,
575
653
  matchedTurnIndex: r.matched_turn_index || null,
576
654
  score: r._score,
655
+ trustScore: r._trustScore ?? 0.5,
577
656
  _debug: {
578
657
  rrf: r._rrf,
579
658
  timeDecay: r._timeDecay,
580
659
  access: r._access,
581
660
  entityScore: r._entityScore,
661
+ trustScore: r._trustScore,
662
+ trustMultiplier: r._trustMultiplier,
663
+ openLoopBoost: r._openLoopBoost,
582
664
  },
583
665
  }));
584
666
  },
585
667
 
668
+ // --- feedback ---
669
+
670
+ async feedback(sessionId, opts = {}) {
671
+ const agentId = opts.agentId || 'agent';
672
+ const verdict = opts.verdict;
673
+ if (!verdict) throw new Error('opts.verdict is required ("helpful" or "unhelpful")');
674
+
675
+ const session = await storage.getSession(pool, sessionId, agentId, {}, { schema, tenantId });
676
+ if (!session) throw new Error(`Session not found: ${sessionId} (agentId=${agentId})`);
677
+
678
+ return storage.recordFeedback(pool, {
679
+ schema,
680
+ tenantId,
681
+ sessionRowId: session.id,
682
+ sessionId,
683
+ agentId,
684
+ verdict,
685
+ note: opts.note || null,
686
+ });
687
+ },
688
+
586
689
  // --- admin ---
587
690
 
588
691
  async getSession(sessionId, opts = {}) {
package/core/entity.js CHANGED
@@ -344,6 +344,120 @@ async function getEntityRelations(pool, {
344
344
  return result.rows;
345
345
  }
346
346
 
347
+ // ---------------------------------------------------------------------------
348
+ // resolveEntities — map raw names to entity IDs with dedup
349
+ // ---------------------------------------------------------------------------
350
+
351
+ async function resolveEntities(pool, {
352
+ schema,
353
+ tenantId,
354
+ names,
355
+ agentId = null,
356
+ threshold = 0.1,
357
+ }) {
358
+ if (!names || names.length === 0) return [];
359
+
360
+ const seen = new Map();
361
+ const results = [];
362
+
363
+ for (const rawName of names) {
364
+ const normQ = normalizeEntityName(rawName);
365
+ if (!normQ || seen.has(normQ)) continue;
366
+ seen.set(normQ, true);
367
+
368
+ const escaped = _escapeIlike(normQ);
369
+ const result = await pool.query(
370
+ `SELECT id, name, normalized_name
371
+ FROM ${qi(schema)}.entities
372
+ WHERE status = 'active'
373
+ AND tenant_id = $1
374
+ AND (
375
+ similarity(normalized_name, $2) >= $3
376
+ OR normalized_name = $2
377
+ OR $2 = ANY(aliases)
378
+ )
379
+ AND ($4::text IS NULL OR agent_id = $4)
380
+ ORDER BY similarity(normalized_name, $2) DESC, frequency DESC
381
+ LIMIT 1`,
382
+ [tenantId, normQ, threshold, agentId || null]
383
+ );
384
+
385
+ if (result.rows[0]) {
386
+ const row = result.rows[0];
387
+ if (!results.some(r => r.entityId === row.id)) {
388
+ results.push({
389
+ entityId: row.id,
390
+ name: row.name,
391
+ normalizedName: row.normalized_name,
392
+ inputName: rawName,
393
+ });
394
+ }
395
+ }
396
+ }
397
+
398
+ return results;
399
+ }
400
+
401
+ // ---------------------------------------------------------------------------
402
+ // getSessionsByEntityIntersection — sessions containing ALL specified entities
403
+ // ---------------------------------------------------------------------------
404
+
405
+ async function getSessionsByEntityIntersection(pool, {
406
+ schema,
407
+ entityIds,
408
+ tenantId,
409
+ agentId = null,
410
+ source = null,
411
+ dateFrom = null,
412
+ dateTo = null,
413
+ limit = 100,
414
+ }) {
415
+ if (!entityIds || entityIds.length === 0) return [];
416
+
417
+ const where = ['s.tenant_id = $2'];
418
+ const params = [entityIds, tenantId];
419
+
420
+ if (agentId) {
421
+ params.push(agentId);
422
+ where.push(`s.agent_id = $${params.length}`);
423
+ }
424
+ if (source) {
425
+ params.push(source);
426
+ where.push(`s.source = $${params.length}`);
427
+ }
428
+ if (dateFrom) {
429
+ params.push(dateFrom);
430
+ where.push(`s.started_at::date >= $${params.length}::date`);
431
+ }
432
+ if (dateTo) {
433
+ params.push(dateTo);
434
+ where.push(`s.started_at::date <= $${params.length}::date`);
435
+ }
436
+
437
+ params.push(entityIds.length);
438
+ const havingPos = params.length;
439
+
440
+ params.push(Math.max(1, Math.min(500, limit)));
441
+ const limitPos = params.length;
442
+
443
+ const result = await pool.query(
444
+ `SELECT es.session_row_id, s.session_id,
445
+ COUNT(DISTINCT es.entity_id) AS matched_count,
446
+ SUM(es.mention_count) AS mention_weight
447
+ FROM ${qi(schema)}.entity_sessions es
448
+ JOIN ${qi(schema)}.sessions s ON s.id = es.session_row_id
449
+ WHERE es.entity_id = ANY($1)
450
+ AND ${where.join(' AND ')}
451
+ GROUP BY es.session_row_id, s.session_id
452
+ HAVING COUNT(DISTINCT es.entity_id) >= $${havingPos}
453
+ ORDER BY matched_count DESC, mention_weight DESC
454
+ LIMIT $${limitPos}`,
455
+ params
456
+ );
457
+
458
+ return result.rows;
459
+ }
460
+
347
461
  // ---------------------------------------------------------------------------
348
462
  // Exports
349
463
  // ---------------------------------------------------------------------------
@@ -357,4 +471,6 @@ module.exports = {
357
471
  upsertEntitySession,
358
472
  searchEntities,
359
473
  getEntityRelations,
474
+ resolveEntities,
475
+ getSessionsByEntityIntersection,
360
476
  };
@@ -62,6 +62,14 @@ function accessScore(accessCount, lastAccessedAt) {
62
62
 
63
63
  // ---------------------------------------------------------------------------
64
64
  // hybridRank — combine all signals into final ranked list
65
+ //
66
+ // Scoring order:
67
+ // 1. rawBase = rrf * normRrf + timeDecay * td + access * as
68
+ // 2. base = min(1, rawBase)
69
+ // 3. trustMultiplier = 0.5 + (trust_score ?? 0.5) [0.5–1.5]
70
+ // 4. trustedBase = base * trustMultiplier
71
+ // 5. withOpenLoop = min(1, trustedBase + openLoop boost)
72
+ // 6. finalScore = min(1, withOpenLoop + entityBoost * entitySc * (1 - withOpenLoop))
65
73
  // ---------------------------------------------------------------------------
66
74
 
67
75
  const DEFAULT_WEIGHTS = {
@@ -69,16 +77,17 @@ const DEFAULT_WEIGHTS = {
69
77
  timeDecay: 0.25,
70
78
  access: 0.10,
71
79
  entityBoost: 0.18,
80
+ openLoop: 0.08,
72
81
  };
73
82
 
74
- function hybridRank(
75
- ftsResults,
76
- embResults,
77
- limit = 5,
78
- weights = {},
79
- turnResults = [],
80
- entityScoreBySession = new Map(),
81
- ) {
83
+ function hybridRank(ftsResults, embResults, turnResults, opts = {}) {
84
+ const {
85
+ limit = 5,
86
+ weights = {},
87
+ entityScoreBySession = new Map(),
88
+ openLoopSet = new Set(),
89
+ } = opts;
90
+
82
91
  const w = { ...DEFAULT_WEIGHTS, ...weights };
83
92
 
84
93
  // Build allResults map: session_id → result object
@@ -139,11 +148,22 @@ function hybridRank(
139
148
  );
140
149
  const as = 1 - Math.exp(-accessEff / 5);
141
150
 
151
+ // Step 1–2: base score
142
152
  const rawBase = w.rrf * normRrf + w.timeDecay * td + w.access * as;
143
- const base = Math.min(1, rawBase); // m7: clamp to prevent negative entity boost
153
+ const base = Math.min(1, rawBase);
154
+
155
+ // Step 3–4: trust multiplier (read from result row)
156
+ const trustSc = result.trust_score ?? 0.5;
157
+ const trustMultiplier = 0.5 + trustSc;
158
+ const trustedBase = Math.min(1, base * trustMultiplier);
159
+
160
+ // Step 5: open-loop boost
161
+ const olBoost = openLoopSet.has(sessionId) ? w.openLoop : 0;
162
+ const withOpenLoop = Math.min(1, trustedBase + olBoost);
144
163
 
164
+ // Step 6: entity boost
145
165
  const entitySc = entityScoreBySession.get(sessionId) || 0;
146
- const finalScore = Math.min(1, base + w.entityBoost * entitySc * (1 - base));
166
+ const finalScore = Math.min(1, withOpenLoop + w.entityBoost * entitySc * (1 - withOpenLoop));
147
167
 
148
168
  scored.push({
149
169
  ...result,
@@ -152,6 +172,9 @@ function hybridRank(
152
172
  _timeDecay: td,
153
173
  _access: as,
154
174
  _entityScore: entitySc,
175
+ _trustScore: trustSc,
176
+ _trustMultiplier: trustMultiplier,
177
+ _openLoopBoost: olBoost,
155
178
  });
156
179
  }
157
180
 
package/core/storage.js CHANGED
@@ -350,6 +350,7 @@ async function searchSessions(pool, query, {
350
350
  ss.structured_summary,
351
351
  ss.access_count,
352
352
  ss.last_accessed_at,
353
+ ss.trust_score,
353
354
  ts_headline('simple', COALESCE(ss.summary_text, ''), plainto_tsquery('simple', $1)) AS summary_snippet,
354
355
  ts_rank(ss.search_tsv, plainto_tsquery('simple', $1)) AS fts_rank
355
356
  FROM ${qi(schema)}.sessions s
@@ -513,6 +514,7 @@ async function searchTurnEmbeddings(pool, {
513
514
  SELECT DISTINCT ON (t.session_row_id)
514
515
  s.session_id, s.id AS session_row_id, s.agent_id, s.source, s.started_at,
515
516
  ss.summary_text, ss.structured_summary, ss.access_count, ss.last_accessed_at,
517
+ COALESCE(ss.trust_score, 0.5) AS trust_score,
516
518
  t.content_text AS matched_turn_text, t.turn_index AS matched_turn_index,
517
519
  (t.embedding <=> $${vecPos}::vector) AS turn_distance
518
520
  FROM ${qi(schema)}.turn_embeddings t
@@ -529,6 +531,68 @@ async function searchTurnEmbeddings(pool, {
529
531
  return { rows: result.rows.slice(0, limit) };
530
532
  }
531
533
 
534
+ // ---------------------------------------------------------------------------
535
+ // recordFeedback — explicit trust feedback with audit trail
536
+ // ---------------------------------------------------------------------------
537
+
538
+ const TRUST_UP = 0.05;
539
+ const TRUST_DOWN = 0.10;
540
+
541
+ async function recordFeedback(pool, {
542
+ schema,
543
+ tenantId,
544
+ sessionRowId,
545
+ sessionId,
546
+ agentId,
547
+ verdict,
548
+ note,
549
+ }) {
550
+ if (verdict !== 'helpful' && verdict !== 'unhelpful') {
551
+ throw new Error(`Invalid verdict: "${verdict}". Must be "helpful" or "unhelpful".`);
552
+ }
553
+
554
+ const client = await pool.connect();
555
+ try {
556
+ await client.query('BEGIN');
557
+
558
+ const current = await client.query(
559
+ `SELECT trust_score FROM ${qi(schema)}.session_summaries
560
+ WHERE session_row_id = $1 FOR UPDATE`,
561
+ [sessionRowId]
562
+ );
563
+ if (!current.rows[0]) {
564
+ throw new Error(`Session not enriched: no summary for session_row_id=${sessionRowId}`);
565
+ }
566
+
567
+ const trustBefore = parseFloat(current.rows[0].trust_score);
568
+ const trustAfter = verdict === 'helpful'
569
+ ? Math.min(1.0, trustBefore + TRUST_UP)
570
+ : Math.max(0.0, trustBefore - TRUST_DOWN);
571
+
572
+ await client.query(
573
+ `UPDATE ${qi(schema)}.session_summaries
574
+ SET trust_score = $1, updated_at = now()
575
+ WHERE session_row_id = $2`,
576
+ [trustAfter, sessionRowId]
577
+ );
578
+
579
+ await client.query(
580
+ `INSERT INTO ${qi(schema)}.session_feedback
581
+ (session_row_id, tenant_id, agent_id, session_id, verdict, note, trust_before, trust_after)
582
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
583
+ [sessionRowId, tenantId, agentId, sessionId, verdict, note || null, trustBefore, trustAfter]
584
+ );
585
+
586
+ await client.query('COMMIT');
587
+ return { trustBefore, trustAfter, verdict };
588
+ } catch (err) {
589
+ await client.query('ROLLBACK').catch(() => {});
590
+ throw err;
591
+ } finally {
592
+ client.release();
593
+ }
594
+ }
595
+
532
596
  // ---------------------------------------------------------------------------
533
597
  // Exports
534
598
  // ---------------------------------------------------------------------------
@@ -547,4 +611,5 @@ module.exports = {
547
611
  extractUserTurns,
548
612
  upsertTurnEmbeddings,
549
613
  searchTurnEmbeddings,
614
+ recordFeedback,
550
615
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shadowforge0/aquifer-memory",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "PG-native long-term memory for AI agents. Turn-level embedding, hybrid RRF ranking, optional knowledge graph. Includes CLI, MCP server, and OpenClaw plugin.",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -15,6 +15,8 @@
15
15
  },
16
16
  "exports": {
17
17
  ".": "./index.js",
18
+ "./core/*": "./core/*.js",
19
+ "./pipeline/*": "./pipeline/*.js",
18
20
  "./consumers/mcp": "./consumers/mcp.js",
19
21
  "./consumers/openclaw-plugin": "./consumers/openclaw-plugin.js",
20
22
  "./consumers/shared/config": "./consumers/shared/config.js",
@@ -0,0 +1,29 @@
1
+ -- Aquifer trust feedback extension
2
+ -- Requires: 001-base.sql applied first
3
+ -- Usage: replace ${schema} with actual schema name
4
+
5
+ -- =========================================================================
6
+ -- Trust score: per-session summary quality metric
7
+ -- =========================================================================
8
+ ALTER TABLE ${schema}.session_summaries
9
+ ADD COLUMN IF NOT EXISTS trust_score REAL NOT NULL DEFAULT 0.5
10
+ CHECK (trust_score >= 0 AND trust_score <= 1);
11
+
12
+ -- =========================================================================
13
+ -- Session feedback: audit trail for user feedback on session summaries
14
+ -- =========================================================================
15
+ CREATE TABLE IF NOT EXISTS ${schema}.session_feedback (
16
+ id BIGSERIAL PRIMARY KEY,
17
+ session_row_id BIGINT NOT NULL REFERENCES ${schema}.sessions(id) ON DELETE CASCADE,
18
+ tenant_id TEXT NOT NULL DEFAULT 'default',
19
+ agent_id TEXT NOT NULL DEFAULT 'agent',
20
+ session_id TEXT NOT NULL,
21
+ verdict TEXT NOT NULL CHECK (verdict IN ('helpful', 'unhelpful')),
22
+ note TEXT,
23
+ trust_before REAL NOT NULL,
24
+ trust_after REAL NOT NULL,
25
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
26
+ );
27
+
28
+ CREATE INDEX IF NOT EXISTS idx_session_feedback_session
29
+ ON ${schema}.session_feedback (session_row_id, created_at DESC);