@shadowforge0/aquifer-memory 1.5.8 → 1.5.12

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
@@ -132,14 +132,34 @@ Need LLM summarization, the knowledge graph, OpenAI embeddings, or the reranker?
132
132
  | `AQUIFER_RERANK_PROVIDER` | No | Reranker provider: `tei`, `jina`, `openrouter` | `tei` |
133
133
  | `AQUIFER_RERANK_BASE_URL` | No | Reranker endpoint | `http://localhost:8080` |
134
134
  | `AQUIFER_AGENT_ID` | No | Default agent ID | `main` |
135
+ | `AQUIFER_MIGRATIONS_MODE` | No | Startup handshake mode: `apply` (default), `check`, `off` | `apply` |
136
+ | `AQUIFER_MIGRATION_LOCK_TIMEOUT_MS` | No | Advisory-lock wait before `AQ_MIGRATION_LOCK_TIMEOUT` (default 30000) | `30000` |
137
+ | `AQUIFER_INSIGHTS_DEDUP_MODE` | No | Insights semantic dedup mode: `off` (default), `shadow`, `enforce` — env wins over code for this field only, so operators can kill-switch without redeploy | `shadow` |
138
+ | `AQUIFER_INSIGHTS_DEDUP_COSINE` | No | Cosine threshold for semantic merge (default `0.88`; warn outside `[0.75, 0.95]`) | `0.90` |
139
+ | `AQUIFER_INSIGHTS_DEDUP_CLOSE_BAND_FROM` | No | Lower bound for close-band logging (`dedupNear`); must be below threshold (default `0.85`) | `0.82` |
135
140
 
136
141
  Full env-to-config mapping is in [consumers/shared/config.js](consumers/shared/config.js).
137
142
 
143
+ ### Insights semantic dedup (1.5.10)
144
+
145
+ When a cron extractor (`scripts/extract-insights-from-recent-sessions.js`) or any other caller writes insights via `commitInsight`, the canonical-key layer (1.5.3+) dedupes rows whose `canonicalClaim + entities` hash to the same value. But LLMs don't always produce the same `canonicalClaim` across runs, so 1.5.10 adds a second tier: `title + body` are embedded, matched against `(tenant, agent, type)`-scoped active rows, and a top cosine above `AQUIFER_INSIGHTS_DEDUP_COSINE` triggers supersede (enforce) or metadata-only would-merge logging (shadow). Close-band hits (`closeBandFrom ≤ cos < threshold`) write `metadata.dedupNear` without supersede so operators can tune thresholds without committing.
146
+
147
+ Recommended rollout: `shadow` for one weekly cycle, inspect `SELECT metadata->>'shadowMatch' FROM insights WHERE metadata ? 'shadowMatch'`, then flip to `enforce`. Kill-switch: `AQUIFER_INSIGHTS_DEDUP_MODE=off` and restart.
148
+
149
+ Pre-1.5.3 rows with `canonical_key_v2 IS NULL` are caught by the semantic tier but skip the canonical path; a startup warn points at the one-shot backfill:
150
+
151
+ ```bash
152
+ DATABASE_URL=... \
153
+ node scripts/backfill-canonical-key.js --schema <schema> --agent <id>
154
+ ```
155
+
156
+ The script is idempotent (`WHERE canonical_key_v2 IS NULL` guard) and race-safe with live writers.
157
+
138
158
  ---
139
159
 
140
160
  ## Host Integration
141
161
 
142
- MCP is the primary integration surface. Agent hosts connect to the Aquifer MCP server, which exposes five tools: `session_recall`, `session_feedback`, `session_bootstrap`, `memory_stats`, `memory_pending`.
162
+ MCP is the primary integration surface. Agent hosts connect to the Aquifer MCP server, which exposes six tools: `session_recall`, `session_feedback`, `feedback_stats`, `session_bootstrap`, `memory_stats`, `memory_pending`.
143
163
 
144
164
  | Integration | Route | Status | When to use |
145
165
  |-------------|-------|--------|-------------|
@@ -194,7 +214,7 @@ Add to `openclaw.json` under `mcp.servers`:
194
214
  }
195
215
  ```
196
216
 
197
- Tools materialize as `aquifer__session_recall`, `aquifer__session_feedback`, `aquifer__session_bootstrap`, `aquifer__memory_stats`, `aquifer__memory_pending` (server name prefix added by the host).
217
+ Tools materialize as `aquifer__session_recall`, `aquifer__session_feedback`, `aquifer__feedback_stats`, `aquifer__session_bootstrap`, `aquifer__memory_stats`, `aquifer__memory_pending` (server name prefix added by the host).
198
218
 
199
219
  The OpenClaw plugin (`consumers/openclaw-plugin.js`) is retained for session capture via `before_reset` but is **not** the recommended tool delivery path. Use MCP.
200
220
 
@@ -377,9 +397,36 @@ Returns an Aquifer instance. Config:
377
397
  }
378
398
  ```
379
399
 
400
+ #### `aquifer.init()`
401
+
402
+ Startup handshake — resolves pending migrations and returns a StartupEnvelope. Hosts should `await` this before accepting traffic. In `apply` mode a `ready=false` envelope is the signal to abort startup.
403
+
404
+ ```javascript
405
+ const envelope = await aquifer.init();
406
+ // {
407
+ // ready: true,
408
+ // memoryMode: 'rw', // 'rw' | 'ro' | 'off'
409
+ // migrationMode: 'apply', // 'apply' | 'check' | 'off'
410
+ // pendingMigrations: [], // migration ids still outstanding
411
+ // appliedMigrations: ['001-base', '003-trust-feedback', '004-completion', '006-insights'],
412
+ // error: null, // { code, message } on failure
413
+ // durationMs: 1035,
414
+ // }
415
+ ```
416
+
417
+ The MCP consumer (`consumers/mcp.js`) already wires `aquifer.init()` before `server.connect()` and exits non-zero if `ready=false` under `apply` mode.
418
+
419
+ #### `aquifer.listPendingMigrations()` / `aquifer.getMigrationStatus()`
420
+
421
+ Returns `{ required, applied, pending, lastRunAt }` via a `pg_tables` signature probe. No DDL runs. Use it from a health check or from a consumer that wants to surface drift before calling `init()`.
422
+
380
423
  #### `aquifer.migrate()`
381
424
 
382
- Runs SQL migrations (idempotent). Creates tables, indexes, triggers, and extensions.
425
+ Runs SQL migrations (idempotent). Creates tables, indexes, triggers, and extensions. Uses `pg_try_advisory_lock` with a 250 ms poll and a `lockTimeoutMs` deadline (30 s default); on exhaustion throws with `code: 'AQ_MIGRATION_LOCK_TIMEOUT'`. On success returns `{ ok: true, durationMs, notices, ddlExecuted }`; on failure throws an error whose `err.notices` / `err.failedAt` describe the stage that blew up. Most callers should go through `aquifer.init()` instead.
426
+
427
+ #### `aquifer.ensureMigrated()`
428
+
429
+ Lazy idempotent wrapper — fires `migrate()` once on first call, no-ops afterwards. Honors `migrations.mode`: `check` only probes, `off` marks the instance migrated without touching the DB.
383
430
 
384
431
  #### `aquifer.commit(sessionId, messages, opts)`
385
432
 
@@ -463,6 +510,26 @@ const result = await aquifer.bootstrap({
463
510
 
464
511
  Cross-session dedup on open loops and decisions, sentinel filtering (removes 無/none/n/a), and maxChars truncation.
465
512
 
513
+ #### `aquifer.insights.commitInsight(opts)` / `recallInsights(query, opts)` / `markStale(id)` / `supersede(oldId, newId)`
514
+
515
+ Higher-order reflections distilled from session windows (preferences, patterns, frustrations, workflows). Split into two identities: a **canonical key** that describes what the insight is *about* (stable across rewordings), and an **idempotency key** that describes which revision of that claim was written.
516
+
517
+ ```javascript
518
+ await aquifer.insights.commitInsight({
519
+ agentId: 'main',
520
+ type: 'preference',
521
+ canonicalClaim: 'mk prefers checking context before coding', // required — short declarative claim
522
+ title: 'Context-first discipline', // best-effort display
523
+ body: '…',
524
+ entities: ['mk', 'claude code'],
525
+ sourceSessionIds: ['sess-a', 'sess-b'],
526
+ evidenceWindow: { from: isoString, to: isoString },
527
+ importance: 0.9,
528
+ });
529
+ ```
530
+
531
+ Write rules: **duplicate** (same idempotency key → return existing), **revision** (same canonical key + newer evidence → INSERT + inline supersede of prior active), **back-fill revision** (same canonical key + older evidence → INSERT without supersede), **stale replay** (same canonical + same body → return existing). Old pre-1.5.6 rows are not retrofitted; their `canonical_key_v2` stays `NULL` and they age out naturally.
532
+
466
533
  #### `aquifer.close()`
467
534
 
468
535
  Closes the PostgreSQL connection pool (only if Aquifer created it).
@@ -498,9 +565,19 @@ createAquifer({
498
565
  access: 0.10, // access frequency weight
499
566
  entityBoost: 0.18, // entity match boost
500
567
  },
568
+ migrations: {
569
+ mode: 'apply', // 'apply' | 'check' | 'off'
570
+ lockTimeoutMs: 30000, // abort init() if advisory lock held this long
571
+ startupTimeoutMs: 60000, // overall init() deadline (plan probe + DDL combined)
572
+ onEvent: null, // (e) => void — lifecycle hook, see below
573
+ },
501
574
  });
502
575
  ```
503
576
 
577
+ ### Startup observability
578
+
579
+ Set `migrations.onEvent` to observe the lifecycle without parsing logs. Event names: `init_started`, `check_completed`, `apply_started`, `apply_succeeded`, `apply_failed`. Each payload carries `schema`, `mode`, the plan, `ddlExecuted`, `durationMs`, and on failure the `error` / `failedAt` / `notices`. No listener → zero cost.
580
+
504
581
  ### Entity Scope
505
582
 
506
583
  `entities.scope` defines the namespace for entity identity. The unique constraint is `(tenant_id, normalized_name, entity_scope)` — the same entity name in different scopes creates separate entities. This decouples entity identity from `agentId`, allowing multiple agents to share an entity namespace.
@@ -542,6 +619,22 @@ Key indexes: trigram on entity names, GiST on embeddings, unique on `(tenant_id,
542
619
 
543
620
  Also adds `trust_score` column to `session_summaries` (default 0.5, range 0–1).
544
621
 
622
+ ### 005-entity-state-history.sql *(entities enabled)*
623
+
624
+ | Table | Purpose |
625
+ |-------|---------|
626
+ | `entity_state_history` | Temporal state-change log with partial `UNIQUE (tenant, agent, entity, attribute) WHERE valid_to IS NULL` to enforce at-most-one-current. Out-of-order backfill is supported via predecessor/successor overlap checks |
627
+
628
+ Opt-in pipeline (`createAquifer({stateChanges: {enabled, whitelist, confidenceThreshold, timeoutMs, ...}})`) extracts temporal state transitions from session text during `enrich()`; off by default to control LLM cost.
629
+
630
+ ### 006-insights.sql
631
+
632
+ | Table | Purpose |
633
+ |-------|---------|
634
+ | `insights` | Higher-order reflections with TSTZRANGE evidence window, importance, GIN on source_session_ids, HNSW on 1024-dim embedding, and a non-unique partial index on `canonical_key_v2` for the canonical/revision dedup contract |
635
+
636
+ Key indexes: `idx_insights_canonical_v2_active` (partial on active rows with canonical key set), `idx_insights_idempotency_key` (unique on revision key).
637
+
545
638
  ---
546
639
 
547
640
  ## Troubleshooting
@@ -556,6 +649,10 @@ Also adds `trust_score` column to `session_summaries` (default 0.5, range 0–1)
556
649
 
557
650
  **Embedding provider connection refused** — Verify your `AQUIFER_EMBED_BASE_URL` is reachable. For local Ollama, make sure the server is running and the model is pulled (`ollama pull bge-m3`).
558
651
 
652
+ **`AQ_MIGRATION_LOCK_TIMEOUT` on startup** — another process holds the migration advisory lock for `aquifer:<schema>`. Either it is a concurrent `aquifer.init()` racing yours (expected; one will win, the other re-runs and finds `pending=[]`) or a crashed worker left the lock held. Raise `migrations.lockTimeoutMs`, or drop the stale backend via `SELECT pg_terminate_backend(pid) FROM pg_locks WHERE locktype='advisory'` after you have confirmed which pid is dead.
653
+
654
+ **MCP process exits non-zero at startup** — expected when `migrations.mode=apply` and `aquifer.init()` returns `ready=false`. Read the `[aquifer-mcp] startup aborted` line on stderr for the `error.code` / `failedAt`. If you need the old lazy-migrate-on-first-tool-call behaviour instead, set `AQUIFER_MIGRATIONS_MODE=check` (and run `migrate()` out of band) or `=off`.
655
+
559
656
  ---
560
657
 
561
658
  ## Dependencies
package/consumers/cli.js CHANGED
@@ -99,6 +99,7 @@ async function cmdRecall(aquifer, args) {
99
99
  return;
100
100
  }
101
101
 
102
+ const showExplain = !!args.flags.explain;
102
103
  for (let i = 0; i < results.length; i++) {
103
104
  const r = results[i];
104
105
  const ss = r.structuredSummary || {};
@@ -107,6 +108,18 @@ async function cmdRecall(aquifer, args) {
107
108
  console.log(`${i + 1}. [${r.score?.toFixed(3)}] ${title} (${date}, ${r.agentId})`);
108
109
  if (ss.overview) console.log(` ${ss.overview.slice(0, 200)}`);
109
110
  if (r.matchedTurnText) console.log(` > ${r.matchedTurnText.slice(0, 150)}`);
111
+ if (showExplain && r._debug) {
112
+ const d = r._debug;
113
+ const f = (v) => typeof v === 'number' ? v.toFixed(3) : '?';
114
+ const parts = [
115
+ `rrf=${f(d.rrf)}`, `td=${f(d.timeDecay)}`, `access=${f(d.access)}`,
116
+ `entity=${f(d.entityScore)}`, `trust=${f(d.trustScore)}(\u00d7${f(d.trustMultiplier)})`,
117
+ `ol=${f(d.openLoopBoost)}`, `\u2192 hybrid=${f(d.hybridScore)}`,
118
+ ];
119
+ if (d.rerankApplied) parts.push(`rerank=${f(d.rerankScore)}(${d.rerankReason || '?'})`);
120
+ else parts.push(`[rerank: off (${d.rerankReason || '?'})]`);
121
+ console.log(` ${parts.join(' ')}`);
122
+ }
110
123
  console.log();
111
124
  }
112
125
  }
@@ -133,6 +146,22 @@ async function cmdFeedback(aquifer, args) {
133
146
  }
134
147
  }
135
148
 
149
+ async function cmdFeedbackStats(aquifer, args) {
150
+ const stats = await aquifer.feedbackStats({
151
+ agentId: args.flags['agent-id'] || undefined,
152
+ dateFrom: args.flags['date-from'] || undefined,
153
+ dateTo: args.flags['date-to'] || undefined,
154
+ });
155
+
156
+ if (args.flags.json) {
157
+ console.log(JSON.stringify(stats, null, 2));
158
+ } else {
159
+ console.log(`Feedback: ${stats.totalFeedback} total (${stats.helpfulCount} helpful, ${stats.unhelpfulCount} unhelpful)`);
160
+ console.log(`Coverage: ${stats.feedbackSessions}/${stats.totalSessions} sessions rated`);
161
+ console.log(`Trust score: avg=${stats.trustScoreAvg} min=${stats.trustScoreMin} max=${stats.trustScoreMax}`);
162
+ }
163
+ }
164
+
136
165
  async function cmdBackfill(aquifer, args) {
137
166
  const limit = parsePositiveInt(args.flags.limit, 100);
138
167
  const dryRun = !!args.flags['dry-run'];
@@ -318,6 +347,7 @@ Commands:
318
347
  migrate Run database migrations
319
348
  recall <query> Search sessions (requires embed config)
320
349
  feedback Record trust feedback on a session
350
+ feedback-stats Show trust feedback statistics and coverage
321
351
  backfill Enrich pending sessions
322
352
  stats Show database statistics
323
353
  export Export sessions as JSONL
@@ -336,6 +366,7 @@ Options:
336
366
  --session-id ID Session ID (feedback)
337
367
  --verdict helpful|unhelpful Feedback verdict (feedback)
338
368
  --note TEXT Feedback note (feedback)
369
+ --explain Show score breakdown per result (recall)
339
370
  --json JSON output
340
371
  --dry-run Preview only (backfill)
341
372
  --output PATH Output file (export)
@@ -410,6 +441,9 @@ Options:
410
441
  case 'feedback':
411
442
  await cmdFeedback(aquifer, args);
412
443
  break;
444
+ case 'feedback-stats':
445
+ await cmdFeedbackStats(aquifer, args);
446
+ break;
413
447
  case 'backfill':
414
448
  await cmdBackfill(aquifer, args);
415
449
  break;
package/consumers/mcp.js CHANGED
@@ -7,7 +7,8 @@
7
7
  * This is the primary integration surface for Aquifer. Agent hosts (Claude Code,
8
8
  * Codex, OpenCode, etc.) should integrate through this MCP server.
9
9
  *
10
- * Tools: session_recall, session_feedback, memory_stats, memory_pending
10
+ * Tools: session_recall, session_feedback, feedback_stats,
11
+ * session_bootstrap, memory_stats, memory_pending
11
12
  *
12
13
  * Usage:
13
14
  * npx aquifer mcp
@@ -32,8 +33,8 @@ function getAquifer() {
32
33
 
33
34
  const { formatRecallResults } = require('./shared/recall-format');
34
35
 
35
- function formatResults(results, query) {
36
- return formatRecallResults(results, { query, showScore: true });
36
+ function formatResults(results, query, explain) {
37
+ return formatRecallResults(results, { query, showScore: true, showExplain: !!explain });
37
38
  }
38
39
 
39
40
  // ---------------------------------------------------------------------------
@@ -74,6 +75,7 @@ async function main() {
74
75
  entities: z.array(z.string()).optional().describe('Entity names to match'),
75
76
  entityMode: z.enum(['any', 'all']).optional().describe('"any" (default, boost) or "all" (only sessions with every entity)'),
76
77
  mode: z.enum(['fts', 'hybrid', 'vector']).optional().describe('Recall mode: "fts" (keyword only, no embed needed), "hybrid" (default, FTS + vector), "vector" (vector only)'),
78
+ explain: z.boolean().optional().describe('Include per-result score breakdown (diagnostic)'),
77
79
  },
78
80
  async (params) => {
79
81
  try {
@@ -93,7 +95,7 @@ async function main() {
93
95
  if (params.mode) recallOpts.mode = params.mode;
94
96
 
95
97
  const results = await aquifer.recall(params.query, recallOpts);
96
- const text = formatResults(results, params.query);
98
+ const text = formatResults(results, params.query, params.explain);
97
99
  return { content: [{ type: 'text', text }] };
98
100
  } catch (err) {
99
101
  return {
@@ -106,7 +108,7 @@ async function main() {
106
108
 
107
109
  server.tool(
108
110
  'session_feedback',
109
- 'Record trust feedback on a recalled session. Helpful sessions rank higher in future recalls.',
111
+ 'After using session_recall, mark the result helpful if it directly informed your answer, or unhelpful if it was irrelevant/outdated. Include a short note. Sessions with more helpful feedback rank higher in future recalls.',
110
112
  {
111
113
  sessionId: z.string().min(1).describe('Session ID to give feedback on'),
112
114
  verdict: z.enum(['helpful', 'unhelpful']).describe('Was the recalled session useful?'),
@@ -133,6 +135,37 @@ async function main() {
133
135
  }
134
136
  );
135
137
 
138
+ server.tool(
139
+ 'feedback_stats',
140
+ 'Return trust feedback statistics: total feedback count, helpful/unhelpful breakdown, trust score distribution, and coverage.',
141
+ {
142
+ agentId: z.string().optional().describe('Filter by agent ID'),
143
+ dateFrom: z.string().optional().describe('Start date YYYY-MM-DD'),
144
+ dateTo: z.string().optional().describe('End date YYYY-MM-DD'),
145
+ },
146
+ async (params) => {
147
+ try {
148
+ const aquifer = getAquifer();
149
+ const stats = await aquifer.feedbackStats({
150
+ agentId: params.agentId || undefined,
151
+ dateFrom: params.dateFrom || undefined,
152
+ dateTo: params.dateTo || undefined,
153
+ });
154
+ const lines = [
155
+ `Feedback: ${stats.totalFeedback} total (${stats.helpfulCount} helpful, ${stats.unhelpfulCount} unhelpful)`,
156
+ `Coverage: ${stats.feedbackSessions}/${stats.totalSessions} sessions rated`,
157
+ `Trust score: avg=${stats.trustScoreAvg} min=${stats.trustScoreMin} max=${stats.trustScoreMax}`,
158
+ ];
159
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
160
+ } catch (err) {
161
+ return {
162
+ content: [{ type: 'text', text: `feedback_stats error: ${err.message}` }],
163
+ isError: true,
164
+ };
165
+ }
166
+ }
167
+ );
168
+
136
169
  server.tool(
137
170
  'memory_stats',
138
171
  'Return storage statistics for the Aquifer memory store (session counts by status, summaries, turn embeddings, entities, date range).',
@@ -14,6 +14,7 @@ function buildSessionContext({ today, agentId, focusText, todoItems, moodLine, h
14
14
  const parts = [];
15
15
  parts.push('你是 Miranda。以下是你已經知道的現況,直接用來回應,不需要讀檔或搜尋。像做 briefing——帶現況也帶判斷和建議。用散文段落,最後一句必須是結論或建議,不能是問句。若草稿有 bullet、標題、表格或問句收尾,改寫再送出。');
16
16
  parts.push('回答任何關於過去做過什麼、討論過什麼、決策過什麼的問題時,第一步用 session_recall MCP tool 查,不要用 grep、讀 log、翻檔案。工具在手上就用。');
17
+ parts.push('用完 session_recall 後,如果某筆結果實際幫助了你的回答,呼叫 session_feedback(sessionId, verdict="helpful");如果結果明顯過時或錯誤,呼叫 session_feedback(sessionId, verdict="unhelpful"),帶簡短 note 說明原因。只對實際影響回答的結果回饋,不要每次 recall 都打分。');
17
18
 
18
19
  if (focusText) parts.push(`現在的焦點是${focusText}。`);
19
20
  if (handoffText) parts.push(`上一段的交接:${handoffText}`);
@@ -214,6 +214,7 @@ function register(api) {
214
214
  entities: { type: 'array', items: { type: 'string' }, description: 'Entity names to match' },
215
215
  entityMode: { type: 'string', enum: ['any', 'all'], description: '"any" (default, boost) or "all" (only sessions with every entity)' },
216
216
  mode: { type: 'string', enum: ['fts', 'hybrid', 'vector'], description: 'Recall mode: "fts" (keyword only), "hybrid" (default), "vector" (vector only)' },
217
+ explain: { type: 'boolean', description: 'Include per-result score breakdown (diagnostic)' },
217
218
  },
218
219
  required: ['query'],
219
220
  },
@@ -234,7 +235,7 @@ function register(api) {
234
235
  if (params.mode) recallOpts.mode = params.mode;
235
236
 
236
237
  const results = await aquifer.recall(params.query, recallOpts);
237
- const text = formatRecallResults(results);
238
+ const text = formatRecallResults(results, { showScore: true, showExplain: !!params.explain });
238
239
  return { content: [{ type: 'text', text }] };
239
240
  } catch (err) {
240
241
  return {
@@ -253,7 +254,7 @@ function register(api) {
253
254
 
254
255
  return {
255
256
  name: 'session_feedback',
256
- description: 'Record trust feedback on a recalled session. Helpful sessions rank higher in future recalls.',
257
+ description: 'After using session_recall, mark the result helpful if it directly informed your answer, or unhelpful if it was irrelevant/outdated. Include a short note. Sessions with more helpful feedback rank higher in future recalls.',
257
258
  parameters: {
258
259
  type: 'object',
259
260
  properties: {
@@ -285,5 +286,44 @@ function register(api) {
285
286
  };
286
287
  }, { name: 'session_feedback' });
287
288
 
288
- api.logger.info('[aquifer-memory] registered (before_reset + session_recall + session_feedback)');
289
+ // --- feedback_stats tool ---
290
+
291
+ api.registerTool((ctx) => {
292
+ if ((ctx?.sessionKey || '').includes('subagent')) return null;
293
+
294
+ return {
295
+ name: 'feedback_stats',
296
+ description: 'Return trust feedback statistics: total feedback count, helpful/unhelpful breakdown, trust score distribution, and coverage.',
297
+ parameters: {
298
+ type: 'object',
299
+ properties: {
300
+ agentId: { type: 'string', description: 'Filter by agent ID' },
301
+ dateFrom: { type: 'string', description: 'Start date YYYY-MM-DD' },
302
+ dateTo: { type: 'string', description: 'End date YYYY-MM-DD' },
303
+ },
304
+ },
305
+ async execute(_toolCallId, params) {
306
+ try {
307
+ const stats = await aquifer.feedbackStats({
308
+ agentId: params.agentId || undefined,
309
+ dateFrom: params.dateFrom || undefined,
310
+ dateTo: params.dateTo || undefined,
311
+ });
312
+ const lines = [
313
+ `Feedback: ${stats.totalFeedback} total (${stats.helpfulCount} helpful, ${stats.unhelpfulCount} unhelpful)`,
314
+ `Coverage: ${stats.feedbackSessions}/${stats.totalSessions} sessions rated`,
315
+ `Trust score: avg=${stats.trustScoreAvg} min=${stats.trustScoreMin} max=${stats.trustScoreMax}`,
316
+ ];
317
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
318
+ } catch (err) {
319
+ return {
320
+ content: [{ type: 'text', text: `feedback_stats error: ${err.message}` }],
321
+ isError: true,
322
+ };
323
+ }
324
+ },
325
+ };
326
+ }, { name: 'feedback_stats' });
327
+
328
+ api.logger.info('[aquifer-memory] registered (before_reset + session_recall + session_feedback + feedback_stats)');
289
329
  }
@@ -30,6 +30,15 @@ const DEFAULTS = {
30
30
  temperature: 0,
31
31
  },
32
32
  entities: { enabled: false, mergeCall: true, scope: 'default' },
33
+ insights: {
34
+ recallWeights: null,
35
+ recencyWindowDays: null,
36
+ dedup: {
37
+ mode: 'off',
38
+ cosineThreshold: 0.88,
39
+ closeBandFrom: 0.85,
40
+ },
41
+ },
33
42
  rank: { rrf: 0.65, timeDecay: 0.25, access: 0.10, entityBoost: 0.18 },
34
43
  rerank: {
35
44
  enabled: false,
@@ -75,6 +84,9 @@ const ENV_MAP = [
75
84
  ['AQUIFER_LLM_TEMPERATURE', 'llm.temperature', Number],
76
85
  ['AQUIFER_ENTITIES_ENABLED', 'entities.enabled', Boolean],
77
86
  ['AQUIFER_ENTITY_SCOPE', 'entities.scope'],
87
+ ['AQUIFER_INSIGHTS_DEDUP_MODE', 'insights.dedup.mode'],
88
+ ['AQUIFER_INSIGHTS_DEDUP_COSINE', 'insights.dedup.cosineThreshold', Number],
89
+ ['AQUIFER_INSIGHTS_DEDUP_CLOSE_BAND_FROM', 'insights.dedup.closeBandFrom', Number],
78
90
  ['AQUIFER_RERANK_ENABLED', 'rerank.enabled', Boolean],
79
91
  ['AQUIFER_RERANK_PROVIDER', 'rerank.provider'],
80
92
  ['AQUIFER_RERANK_BASE_URL', 'rerank.baseUrl'],
@@ -165,6 +177,14 @@ function loadConfig(opts = {}) {
165
177
  config = deepMerge(config, opts.overrides);
166
178
  }
167
179
 
180
+ // insights.dedup shorthand: true → enforce, false → off
181
+ if (config.insights && typeof config.insights.dedup === 'boolean') {
182
+ config.insights.dedup = {
183
+ ...DEFAULTS.insights.dedup,
184
+ mode: config.insights.dedup ? 'enforce' : 'off',
185
+ };
186
+ }
187
+
168
188
  return config;
169
189
  }
170
190
 
@@ -91,6 +91,7 @@ function createAquiferFromConfig(overrides) {
91
91
  rank: config.rank,
92
92
  rerank: rerankOpts,
93
93
  migrations: config.migrations,
94
+ insights: config.insights,
94
95
  });
95
96
 
96
97
  return aquifer;
@@ -66,6 +66,30 @@ const defaultRenderers = {
66
66
  if (!showScore) return null;
67
67
  return `Score: ${typeof result.score === 'number' ? result.score.toFixed(3) : '?'}`;
68
68
  },
69
+ explain(result, { showExplain }) {
70
+ if (!showExplain) return null;
71
+ const d = result._debug;
72
+ if (!d) return null;
73
+ const f = (v) => typeof v === 'number' ? v.toFixed(3) : '?';
74
+ const parts = [
75
+ `rrf=${f(d.rrf)}`,
76
+ `td=${f(d.timeDecay)}`,
77
+ `access=${f(d.access)}`,
78
+ `entity=${f(d.entityScore)}`,
79
+ `trust=${f(d.trustScore)}(\u00d7${f(d.trustMultiplier)})`,
80
+ `ol=${f(d.openLoopBoost)}`,
81
+ `\u2192 hybrid=${f(d.hybridScore)}`,
82
+ ];
83
+ if (d.rerankApplied) {
84
+ parts.push(`rerank=${f(d.rerankScore)}(${d.rerankReason || '?'})`);
85
+ } else {
86
+ parts.push(`[rerank: off (${d.rerankReason || '?'})]`);
87
+ }
88
+ if (Array.isArray(d.searchErrors) && d.searchErrors.length > 0) {
89
+ parts.push(`errors: ${d.searchErrors.map(e => (e && e.path) || '?').join(',')}`);
90
+ }
91
+ return ` ${parts.join(' ')}`;
92
+ },
69
93
  separator() {
70
94
  return '';
71
95
  },
@@ -102,6 +126,8 @@ function createRecallFormatter(overrides = {}) {
102
126
  if (matched) lines.push(matched);
103
127
  const score = r.score(res, { showScore: !!opts.showScore, ...ctx });
104
128
  if (score) lines.push(score);
129
+ const explain = r.explain(res, { showExplain: !!opts.showExplain, ...ctx });
130
+ if (explain) lines.push(explain);
105
131
  const sep = r.separator(i, ctx);
106
132
  if (sep !== null && sep !== undefined) lines.push(sep);
107
133
  }
package/core/aquifer.js CHANGED
@@ -1558,6 +1558,17 @@ function createAquifer(config = {}) {
1558
1558
  });
1559
1559
  },
1560
1560
 
1561
+ async feedbackStats(opts = {}) {
1562
+ await ensureMigrated();
1563
+ return storage.getFeedbackStats(pool, {
1564
+ schema,
1565
+ tenantId,
1566
+ agentId: opts.agentId || undefined,
1567
+ dateFrom: opts.dateFrom || undefined,
1568
+ dateTo: opts.dateTo || undefined,
1569
+ });
1570
+ },
1571
+
1561
1572
  // --- admin ---
1562
1573
 
1563
1574
  async getSession(sessionId, opts = {}) {
@@ -1837,6 +1848,7 @@ function createAquifer(config = {}) {
1837
1848
  recallWeights: (config.insights && config.insights.recallWeights) || null,
1838
1849
  recencyWindowDays: config.insights && Number.isFinite(config.insights.recencyWindowDays)
1839
1850
  ? config.insights.recencyWindowDays : undefined,
1851
+ dedup: config.insights && config.insights.dedup ? config.insights.dedup : undefined,
1840
1852
  });
1841
1853
 
1842
1854
  return aquifer;