@shadowforge0/aquifer-memory 0.2.0 → 0.3.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.
- package/README.md +76 -18
- package/core/aquifer.js +150 -30
- package/core/entity.js +116 -0
- package/core/hybrid-rank.js +33 -10
- package/core/storage.js +65 -0
- package/package.json +3 -1
- package/schema/003-trust-feedback.sql +29 -0
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,
|
|
7
|
+
*Turn-level embedding, hybrid RRF ranking, trust scoring, entity intersection, knowledge graph — all on PostgreSQL + pgvector.*
|
|
8
8
|
|
|
9
|
-
[](https://www.npmjs.com/package/aquifer-memory)
|
|
9
|
+
[](https://www.npmjs.com/package/@shadowforge0/aquifer-memory)
|
|
10
10
|
[](https://www.postgresql.org/)
|
|
11
11
|
[](https://github.com/pgvector/pgvector)
|
|
12
12
|
[](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
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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: [{
|
|
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
|
@@ -94,6 +94,14 @@ function createAquifer(config) {
|
|
|
94
94
|
|
|
95
95
|
// Track if migrate was called
|
|
96
96
|
let migrated = false;
|
|
97
|
+
let migratePromise = null;
|
|
98
|
+
|
|
99
|
+
async function ensureMigrated() {
|
|
100
|
+
if (migrated) return;
|
|
101
|
+
if (migratePromise) return migratePromise;
|
|
102
|
+
migratePromise = aquifer.migrate().finally(() => { migratePromise = null; });
|
|
103
|
+
return migratePromise;
|
|
104
|
+
}
|
|
97
105
|
|
|
98
106
|
// --- Helper: embed search on summaries ---
|
|
99
107
|
async function embeddingSearchSummaries(queryVec, opts) {
|
|
@@ -127,6 +135,7 @@ function createAquifer(config) {
|
|
|
127
135
|
`SELECT
|
|
128
136
|
s.id, s.session_id, s.agent_id, s.source, s.started_at, s.last_message_at,
|
|
129
137
|
ss.summary_text, ss.structured_summary, ss.access_count, ss.last_accessed_at,
|
|
138
|
+
ss.trust_score,
|
|
130
139
|
(ss.embedding <=> $${vecPos}::vector) AS distance
|
|
131
140
|
FROM ${qi(schema)}.session_summaries ss
|
|
132
141
|
JOIN ${qi(schema)}.sessions s ON s.id = ss.session_row_id
|
|
@@ -158,6 +167,10 @@ function createAquifer(config) {
|
|
|
158
167
|
await pool.query(entitySql);
|
|
159
168
|
}
|
|
160
169
|
|
|
170
|
+
// 3. Trust + feedback (always, not gated by entities)
|
|
171
|
+
const trustSql = loadSql('003-trust-feedback.sql', schema);
|
|
172
|
+
await pool.query(trustSql);
|
|
173
|
+
|
|
161
174
|
migrated = true;
|
|
162
175
|
},
|
|
163
176
|
|
|
@@ -191,6 +204,7 @@ function createAquifer(config) {
|
|
|
191
204
|
async commit(sessionId, messages, opts = {}) {
|
|
192
205
|
if (!sessionId) throw new Error('sessionId is required');
|
|
193
206
|
if (!messages || !Array.isArray(messages)) throw new Error('messages must be an array');
|
|
207
|
+
await ensureMigrated();
|
|
194
208
|
|
|
195
209
|
const agentId = opts.agentId || 'agent';
|
|
196
210
|
const source = opts.source || 'api';
|
|
@@ -235,6 +249,7 @@ function createAquifer(config) {
|
|
|
235
249
|
// --- enrichment ---
|
|
236
250
|
|
|
237
251
|
async enrich(sessionId, opts = {}) {
|
|
252
|
+
await ensureMigrated();
|
|
238
253
|
const agentId = opts.agentId || 'agent';
|
|
239
254
|
const skipSummary = opts.skipSummary || false;
|
|
240
255
|
const skipTurnEmbed = opts.skipTurnEmbed || false;
|
|
@@ -461,8 +476,17 @@ function createAquifer(config) {
|
|
|
461
476
|
dateTo,
|
|
462
477
|
limit = 5,
|
|
463
478
|
weights: overrideWeights,
|
|
479
|
+
entities: explicitEntities,
|
|
480
|
+
entityMode = 'any',
|
|
464
481
|
} = opts;
|
|
465
482
|
|
|
483
|
+
// Validate before touching DB
|
|
484
|
+
if (explicitEntities && explicitEntities.length > 0 && !entitiesEnabled) {
|
|
485
|
+
throw new Error('Entities are not enabled');
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
await ensureMigrated();
|
|
489
|
+
|
|
466
490
|
const fetchLimit = limit * 4;
|
|
467
491
|
|
|
468
492
|
// 1. Embed query
|
|
@@ -470,35 +494,62 @@ function createAquifer(config) {
|
|
|
470
494
|
const queryVec = queryVecResult[0];
|
|
471
495
|
if (!queryVec || !queryVec.length) return []; // m3: guard empty array too
|
|
472
496
|
|
|
473
|
-
// 2.
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
]);
|
|
497
|
+
// 2. Entity intersection pre-filter (when entityMode === 'all')
|
|
498
|
+
let candidateSessionIds = null; // null = no filter
|
|
499
|
+
let entityScoreBySession = new Map();
|
|
485
500
|
|
|
486
|
-
|
|
501
|
+
if (explicitEntities && explicitEntities.length > 0) {
|
|
487
502
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
503
|
+
const resolved = await entity.resolveEntities(pool, {
|
|
504
|
+
schema, tenantId, names: explicitEntities, agentId,
|
|
505
|
+
});
|
|
491
506
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
507
|
+
if (resolved.length === 0) return [];
|
|
508
|
+
|
|
509
|
+
// Guard: if 'all' mode but fewer entities resolved than requested,
|
|
510
|
+
// return [] — partial resolution would silently weaken the AND constraint
|
|
511
|
+
if (entityMode === 'all' && resolved.length < new Set(explicitEntities.map(n => entity.normalizeEntityName(n))).size) {
|
|
512
|
+
return [];
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const entityIds = resolved.map(r => r.entityId);
|
|
516
|
+
|
|
517
|
+
if (entityMode === 'all') {
|
|
518
|
+
// Hard filter: only sessions with ALL entities
|
|
519
|
+
const intersectionRows = await entity.getSessionsByEntityIntersection(pool, {
|
|
520
|
+
schema, entityIds, tenantId, agentId, source, dateFrom, dateTo, limit: fetchLimit,
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
if (intersectionRows.length === 0) return [];
|
|
524
|
+
|
|
525
|
+
candidateSessionIds = new Set(intersectionRows.map(r => r.session_id));
|
|
526
|
+
for (const row of intersectionRows) {
|
|
527
|
+
entityScoreBySession.set(row.session_id, 1.0);
|
|
528
|
+
}
|
|
529
|
+
} else {
|
|
530
|
+
// 'any' mode with explicit entities: use resolved IDs for boost
|
|
531
|
+
const esResult = await pool.query(
|
|
532
|
+
`SELECT es.session_row_id, s.session_id, COUNT(*) AS entity_count
|
|
533
|
+
FROM ${qi(schema)}.entity_sessions es
|
|
534
|
+
JOIN ${qi(schema)}.sessions s ON s.id = es.session_row_id
|
|
535
|
+
WHERE es.entity_id = ANY($1)
|
|
536
|
+
GROUP BY es.session_row_id, s.session_id`,
|
|
537
|
+
[entityIds]
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
const maxCount = Math.max(1, ...esResult.rows.map(r => parseInt(r.entity_count)));
|
|
541
|
+
for (const row of esResult.rows) {
|
|
542
|
+
entityScoreBySession.set(row.session_id, parseInt(row.entity_count) / maxCount);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
} else if (entitiesEnabled) {
|
|
546
|
+
// No explicit entities: existing query-text-based entity boost
|
|
495
547
|
try {
|
|
496
548
|
const matchedEntities = await entity.searchEntities(pool, {
|
|
497
549
|
schema, tenantId, query, agentId, limit: 10,
|
|
498
550
|
});
|
|
499
551
|
|
|
500
552
|
if (matchedEntities.length > 0) {
|
|
501
|
-
// M1 fix: single JOIN instead of N+1
|
|
502
553
|
const entityIds = matchedEntities.map(e => e.id);
|
|
503
554
|
const esResult = await pool.query(
|
|
504
555
|
`SELECT es.session_row_id, s.session_id, COUNT(*) AS entity_count
|
|
@@ -517,7 +568,47 @@ function createAquifer(config) {
|
|
|
517
568
|
} catch (_) { /* entity search failure non-fatal */ }
|
|
518
569
|
}
|
|
519
570
|
|
|
520
|
-
//
|
|
571
|
+
// 3. Run 3 search paths in parallel
|
|
572
|
+
const [ftsRows, embRows, turnResult] = await Promise.all([
|
|
573
|
+
storage.searchSessions(pool, query, {
|
|
574
|
+
schema, tenantId, agentId, source, dateFrom, dateTo, limit: fetchLimit,
|
|
575
|
+
}).catch(() => []),
|
|
576
|
+
embeddingSearchSummaries(queryVec, {
|
|
577
|
+
agentId, source, dateFrom, dateTo, limit: fetchLimit,
|
|
578
|
+
}).catch(() => []),
|
|
579
|
+
storage.searchTurnEmbeddings(pool, {
|
|
580
|
+
schema, tenantId, queryVec, dateFrom, dateTo, agentId, source, limit: fetchLimit,
|
|
581
|
+
}).catch(() => ({ rows: [] })),
|
|
582
|
+
]);
|
|
583
|
+
|
|
584
|
+
const turnRows = turnResult.rows || [];
|
|
585
|
+
|
|
586
|
+
// 3b. Apply candidate filter (entityMode 'all')
|
|
587
|
+
const filterFn = candidateSessionIds
|
|
588
|
+
? (rows) => rows.filter(r => candidateSessionIds.has(r.session_id || String(r.id)))
|
|
589
|
+
: (rows) => rows;
|
|
590
|
+
|
|
591
|
+
const filteredFts = filterFn(ftsRows);
|
|
592
|
+
const filteredEmb = filterFn(embRows);
|
|
593
|
+
const filteredTurn = filterFn(turnRows);
|
|
594
|
+
|
|
595
|
+
if (filteredFts.length === 0 && filteredEmb.length === 0 && filteredTurn.length === 0) {
|
|
596
|
+
return [];
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// 4. Open-loop set extraction
|
|
600
|
+
const openLoopSet = new Set();
|
|
601
|
+
for (const r of [...filteredFts, ...filteredEmb, ...filteredTurn]) {
|
|
602
|
+
const sid = r.session_id || String(r.id);
|
|
603
|
+
const ss = typeof r.structured_summary === 'string'
|
|
604
|
+
? (() => { try { return JSON.parse(r.structured_summary); } catch (_) { return null; } })()
|
|
605
|
+
: r.structured_summary;
|
|
606
|
+
if (ss && Array.isArray(ss.open_loops) && ss.open_loops.length > 0) {
|
|
607
|
+
openLoopSet.add(sid);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// 5. Run external source searches (parallel + timeout)
|
|
521
612
|
const EXTERNAL_TIMEOUT = 10000;
|
|
522
613
|
const externalRows = [];
|
|
523
614
|
const externalPromises = [];
|
|
@@ -540,18 +631,21 @@ function createAquifer(config) {
|
|
|
540
631
|
}
|
|
541
632
|
if (externalPromises.length > 0) await Promise.all(externalPromises);
|
|
542
633
|
|
|
543
|
-
//
|
|
634
|
+
// 6. Hybrid rank
|
|
544
635
|
const mergedWeights = { ...rankWeights, ...overrideWeights };
|
|
545
636
|
const ranked = hybridRank(
|
|
546
|
-
|
|
547
|
-
[...
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
637
|
+
filteredFts,
|
|
638
|
+
[...filteredEmb, ...filterFn(externalRows)],
|
|
639
|
+
filteredTurn,
|
|
640
|
+
{
|
|
641
|
+
limit,
|
|
642
|
+
weights: mergedWeights,
|
|
643
|
+
entityScoreBySession,
|
|
644
|
+
openLoopSet,
|
|
645
|
+
},
|
|
552
646
|
);
|
|
553
647
|
|
|
554
|
-
//
|
|
648
|
+
// 7. Record access
|
|
555
649
|
const sessionRowIds = ranked
|
|
556
650
|
.map(r => r.id || r.session_row_id)
|
|
557
651
|
.filter(Boolean);
|
|
@@ -562,7 +656,7 @@ function createAquifer(config) {
|
|
|
562
656
|
} catch (_) { /* access recording non-fatal */ }
|
|
563
657
|
}
|
|
564
658
|
|
|
565
|
-
//
|
|
659
|
+
// 8. Format results
|
|
566
660
|
return ranked.map(r => ({
|
|
567
661
|
sessionId: r.session_id,
|
|
568
662
|
agentId: r.agent_id,
|
|
@@ -574,15 +668,41 @@ function createAquifer(config) {
|
|
|
574
668
|
matchedTurnText: r.matched_turn_text || null,
|
|
575
669
|
matchedTurnIndex: r.matched_turn_index || null,
|
|
576
670
|
score: r._score,
|
|
671
|
+
trustScore: r._trustScore ?? 0.5,
|
|
577
672
|
_debug: {
|
|
578
673
|
rrf: r._rrf,
|
|
579
674
|
timeDecay: r._timeDecay,
|
|
580
675
|
access: r._access,
|
|
581
676
|
entityScore: r._entityScore,
|
|
677
|
+
trustScore: r._trustScore,
|
|
678
|
+
trustMultiplier: r._trustMultiplier,
|
|
679
|
+
openLoopBoost: r._openLoopBoost,
|
|
582
680
|
},
|
|
583
681
|
}));
|
|
584
682
|
},
|
|
585
683
|
|
|
684
|
+
// --- feedback ---
|
|
685
|
+
|
|
686
|
+
async feedback(sessionId, opts = {}) {
|
|
687
|
+
const agentId = opts.agentId || 'agent';
|
|
688
|
+
const verdict = opts.verdict;
|
|
689
|
+
if (!verdict) throw new Error('opts.verdict is required ("helpful" or "unhelpful")');
|
|
690
|
+
await ensureMigrated();
|
|
691
|
+
|
|
692
|
+
const session = await storage.getSession(pool, sessionId, agentId, {}, { schema, tenantId });
|
|
693
|
+
if (!session) throw new Error(`Session not found: ${sessionId} (agentId=${agentId})`);
|
|
694
|
+
|
|
695
|
+
return storage.recordFeedback(pool, {
|
|
696
|
+
schema,
|
|
697
|
+
tenantId,
|
|
698
|
+
sessionRowId: session.id,
|
|
699
|
+
sessionId,
|
|
700
|
+
agentId,
|
|
701
|
+
verdict,
|
|
702
|
+
note: opts.note || null,
|
|
703
|
+
});
|
|
704
|
+
},
|
|
705
|
+
|
|
586
706
|
// --- admin ---
|
|
587
707
|
|
|
588
708
|
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
|
};
|
package/core/hybrid-rank.js
CHANGED
|
@@ -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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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);
|
|
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,
|
|
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.
|
|
3
|
+
"version": "0.3.1",
|
|
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);
|