@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 +76 -18
- package/core/aquifer.js +133 -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
|
@@ -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.
|
|
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
|
-
]);
|
|
480
|
+
// 2. Entity intersection pre-filter (when entityMode === 'all')
|
|
481
|
+
let candidateSessionIds = null; // null = no filter
|
|
482
|
+
let entityScoreBySession = new Map();
|
|
485
483
|
|
|
486
|
-
|
|
484
|
+
if (explicitEntities && explicitEntities.length > 0) {
|
|
485
|
+
if (!entitiesEnabled) throw new Error('Entities are not enabled');
|
|
487
486
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
487
|
+
const resolved = await entity.resolveEntities(pool, {
|
|
488
|
+
schema, tenantId, names: explicitEntities, agentId,
|
|
489
|
+
});
|
|
491
490
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
618
|
+
// 6. Hybrid rank
|
|
544
619
|
const mergedWeights = { ...rankWeights, ...overrideWeights };
|
|
545
620
|
const ranked = hybridRank(
|
|
546
|
-
|
|
547
|
-
[...
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
621
|
+
filteredFts,
|
|
622
|
+
[...filteredEmb, ...filterFn(externalRows)],
|
|
623
|
+
filteredTurn,
|
|
624
|
+
{
|
|
625
|
+
limit,
|
|
626
|
+
weights: mergedWeights,
|
|
627
|
+
entityScoreBySession,
|
|
628
|
+
openLoopSet,
|
|
629
|
+
},
|
|
552
630
|
);
|
|
553
631
|
|
|
554
|
-
//
|
|
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
|
-
//
|
|
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
|
};
|
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.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);
|