@shadowforge0/aquifer-memory 1.5.9 → 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
@@ -134,14 +134,32 @@ Need LLM summarization, the knowledge graph, OpenAI embeddings, or the reranker?
134
134
  | `AQUIFER_AGENT_ID` | No | Default agent ID | `main` |
135
135
  | `AQUIFER_MIGRATIONS_MODE` | No | Startup handshake mode: `apply` (default), `check`, `off` | `apply` |
136
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` |
137
140
 
138
141
  Full env-to-config mapping is in [consumers/shared/config.js](consumers/shared/config.js).
139
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
+
140
158
  ---
141
159
 
142
160
  ## Host Integration
143
161
 
144
- 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`.
145
163
 
146
164
  | Integration | Route | Status | When to use |
147
165
  |-------------|-------|--------|-------------|
@@ -196,7 +214,7 @@ Add to `openclaw.json` under `mcp.servers`:
196
214
  }
197
215
  ```
198
216
 
199
- 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).
200
218
 
201
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.
202
220
 
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;