@shadowforge0/aquifer-memory 1.5.9 → 1.6.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.
Files changed (65) hide show
  1. package/.env.example +23 -0
  2. package/README.md +96 -73
  3. package/README_CN.md +659 -0
  4. package/README_TW.md +680 -0
  5. package/aquifer.config.example.json +34 -0
  6. package/consumers/claude-code.js +11 -11
  7. package/consumers/cli.js +374 -39
  8. package/consumers/codex-handoff.js +152 -0
  9. package/consumers/codex.js +1549 -0
  10. package/consumers/default/daily-entries.js +23 -4
  11. package/consumers/default/index.js +2 -2
  12. package/consumers/default/prompts/summary.js +6 -6
  13. package/consumers/mcp.js +131 -7
  14. package/consumers/openclaw-ext/index.js +0 -1
  15. package/consumers/openclaw-plugin.js +44 -4
  16. package/consumers/shared/config.js +28 -0
  17. package/consumers/shared/factory.js +2 -0
  18. package/consumers/shared/ingest.js +1 -1
  19. package/consumers/shared/normalize.js +14 -3
  20. package/consumers/shared/recall-format.js +53 -0
  21. package/consumers/shared/summary-parser.js +151 -0
  22. package/core/aquifer.js +384 -18
  23. package/core/finalization-review.js +319 -0
  24. package/core/insights.js +210 -58
  25. package/core/mcp-manifest.js +69 -2
  26. package/core/memory-bootstrap.js +188 -0
  27. package/core/memory-consolidation.js +1236 -0
  28. package/core/memory-promotion.js +544 -0
  29. package/core/memory-recall.js +247 -0
  30. package/core/memory-records.js +581 -0
  31. package/core/memory-safety-gate.js +224 -0
  32. package/core/session-finalization.js +350 -0
  33. package/core/storage.js +456 -2
  34. package/docs/getting-started.md +99 -0
  35. package/docs/postprocess-contract.md +2 -2
  36. package/docs/setup.md +51 -2
  37. package/package.json +31 -9
  38. package/pipeline/normalize/adapters/codex.js +106 -0
  39. package/pipeline/normalize/detect.js +3 -2
  40. package/schema/001-base.sql +3 -0
  41. package/schema/007-v1-foundation.sql +273 -0
  42. package/schema/008-session-finalizations.sql +50 -0
  43. package/schema/009-v1-assertion-plane.sql +193 -0
  44. package/schema/010-v1-finalization-review.sql +160 -0
  45. package/schema/011-v1-compaction-claim.sql +46 -0
  46. package/schema/012-v1-compaction-lease.sql +39 -0
  47. package/schema/013-v1-compaction-lineage.sql +193 -0
  48. package/scripts/backfill-canonical-key.js +250 -0
  49. package/scripts/codex-recovery.js +532 -0
  50. package/consumers/miranda/context-inject.js +0 -119
  51. package/consumers/miranda/daily-entries.js +0 -224
  52. package/consumers/miranda/index.js +0 -364
  53. package/consumers/miranda/instance.js +0 -55
  54. package/consumers/miranda/llm.js +0 -99
  55. package/consumers/miranda/profile.json +0 -145
  56. package/consumers/miranda/prompts/summary.js +0 -303
  57. package/consumers/miranda/recall-format.js +0 -76
  58. package/consumers/miranda/render-daily-md.js +0 -186
  59. package/consumers/miranda/workspace-files.js +0 -91
  60. package/scripts/drop-entity-state-history.sql +0 -17
  61. package/scripts/drop-insights.sql +0 -12
  62. package/scripts/install-openclaw.sh +0 -59
  63. package/scripts/queries.json +0 -45
  64. package/scripts/retro-recall-bench.js +0 -409
  65. package/scripts/sample-bench-queries.sql +0 -75
@@ -9,7 +9,7 @@
9
9
  // CREATE TABLE jenny.daily_entries (LIKE miranda.daily_entries INCLUDING ALL);
10
10
 
11
11
  const crypto = require('crypto');
12
- const { parseHandoffSection } = require('../miranda/prompts/summary');
12
+ const { parseHandoffSection } = require('../shared/summary-parser');
13
13
 
14
14
  const UPSERT_TAGS = new Set(['[FOCUS]', '[TODO]', '[STATS]', '[HIGHLIGHT]', '[SYSTEM]', '[HANDOFF]']);
15
15
 
@@ -27,10 +27,27 @@ function textHash6(text) {
27
27
  return crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 6);
28
28
  }
29
29
 
30
+ function quoteIdentifier(identifier) {
31
+ if (!/^[A-Za-z_][A-Za-z0-9_]{0,62}$/.test(identifier)) {
32
+ throw new Error(`Invalid dailyTable identifier: ${identifier}`);
33
+ }
34
+ return `"${identifier}"`;
35
+ }
36
+
37
+ function quoteTableName(tableName) {
38
+ if (typeof tableName !== 'string') throw new Error('dailyTable must be a string');
39
+ const parts = tableName.split('.');
40
+ if (parts.length < 1 || parts.length > 2 || parts.some(part => !part)) {
41
+ throw new Error(`Invalid dailyTable name: ${tableName}`);
42
+ }
43
+ return parts.map(quoteIdentifier).join('.');
44
+ }
45
+
30
46
  async function insertDailyEntry(pool, tableName, { eventAt, source, tag, text, agentId, sessionId, metadata, dedupeKey }) {
47
+ const tableSql = quoteTableName(tableName);
31
48
  const shouldUpsert = dedupeKey && UPSERT_TAGS.has(tag);
32
49
  const sql = shouldUpsert
33
- ? `INSERT INTO ${tableName}
50
+ ? `INSERT INTO ${tableSql}
34
51
  (event_at, source, tag, text, agent_id, session_id, metadata, dedupe_key)
35
52
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
36
53
  ON CONFLICT (dedupe_key) DO UPDATE SET
@@ -38,7 +55,7 @@ async function insertDailyEntry(pool, tableName, { eventAt, source, tag, text, a
38
55
  event_at = EXCLUDED.event_at,
39
56
  metadata = EXCLUDED.metadata
40
57
  RETURNING id, event_at, source, tag, text`
41
- : `INSERT INTO ${tableName}
58
+ : `INSERT INTO ${tableSql}
42
59
  (event_at, source, tag, text, agent_id, session_id, metadata, dedupe_key)
43
60
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
44
61
  ON CONFLICT (dedupe_key) DO NOTHING
@@ -53,8 +70,9 @@ async function insertDailyEntry(pool, tableName, { eventAt, source, tag, text, a
53
70
  }
54
71
 
55
72
  async function getDailyEntries(pool, tableName, date, agentId) {
73
+ const tableSql = quoteTableName(tableName);
56
74
  const result = await pool.query(
57
- `SELECT * FROM ${tableName}
75
+ `SELECT * FROM ${tableSql}
58
76
  WHERE (event_at AT TIME ZONE 'Asia/Taipei')::date = $1
59
77
  AND ($2::text IS NULL OR agent_id = $2)
60
78
  ORDER BY event_at ASC`,
@@ -191,6 +209,7 @@ async function writeDailyEntries({
191
209
 
192
210
  module.exports = {
193
211
  taipeiDateString, textHash6,
212
+ quoteIdentifier, quoteTableName,
194
213
  insertDailyEntry, getDailyEntries, fetchDailyContext, writeDailyEntries,
195
214
  UPSERT_TAGS,
196
215
  };
@@ -15,7 +15,7 @@
15
15
  // briefingIntro: '你是 Dobby。以下是現況...', // optional context-inject preamble
16
16
  // });
17
17
  //
18
- // Returns a persona module with the same shape as consumers/miranda — host
18
+ // Returns a persona module with the standard persona adapter shape — host
19
19
  // can do `AQUIFER_PERSONA=<host-path>` where <host-path>/index.js does:
20
20
  // module.exports = require('@shadowforge0/aquifer-memory/consumers/default')
21
21
  // .createPersona({ ... });
@@ -222,7 +222,7 @@ function createPersona(personaOpts = {}) {
222
222
  if ((ctx?.sessionKey || '').includes('subagent')) return null;
223
223
  return {
224
224
  name: 'session_recall',
225
- description: 'Search stored sessions by keyword or natural language. Use entities when the user names specific people, projects, files, tools, or concepts; use entity_mode="all" when every named entity must co-occur (default "any" boosts). Use mode to force fts/vector/hybrid (default hybrid).',
225
+ description: 'Search Aquifer memory by keyword or natural language. In curated serving mode this searches active curated memory; legacy/evidence lookup is exposed by the MCP stdio evidence_recall tool. Use entities when the user names specific people, projects, files, tools, or concepts; use entity_mode="all" when every named entity must co-occur (default "any" boosts). Use mode to force fts/vector/hybrid (default hybrid).',
226
226
  parameters: {
227
227
  type: 'object',
228
228
  properties: {
@@ -139,15 +139,15 @@ TODO_DONE: 已完成待辦(需匹配既有)
139
139
  }
140
140
 
141
141
  // ---------------------------------------------------------------------------
142
- // parseSummaryOutput / parseRecapLines — delegate to miranda's parsers.
143
- // The output format is intentionally the same, so parsers work for both.
142
+ // parseSummaryOutput / parseRecapLines — shared parser used by all personas.
143
+ // The output format is intentionally stable so downstream daily entries work.
144
144
  // ---------------------------------------------------------------------------
145
145
 
146
- const mirandaSummary = require('../../miranda/prompts/summary');
146
+ const summaryParser = require('../../shared/summary-parser');
147
147
 
148
148
  module.exports = {
149
149
  buildSummaryPrompt,
150
- parseSummaryOutput: mirandaSummary.parseSummaryOutput,
151
- parseRecapLines: mirandaSummary.parseRecapLines,
152
- parseWorkingFacts: mirandaSummary.parseWorkingFacts,
150
+ parseSummaryOutput: summaryParser.parseSummaryOutput,
151
+ parseRecapLines: summaryParser.parseRecapLines,
152
+ parseWorkingFacts: summaryParser.parseWorkingFacts,
153
153
  };
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, evidence_recall, session_feedback, memory_feedback,
11
+ * feedback_stats, session_bootstrap, memory_stats, memory_pending
11
12
  *
12
13
  * Usage:
13
14
  * npx aquifer mcp
@@ -18,6 +19,7 @@
18
19
  */
19
20
 
20
21
  const { createAquiferFromConfig } = require('./shared/factory');
22
+ const { version: packageVersion } = require('../package.json');
21
23
 
22
24
  let _aquifer = null;
23
25
 
@@ -32,8 +34,8 @@ function getAquifer() {
32
34
 
33
35
  const { formatRecallResults } = require('./shared/recall-format');
34
36
 
35
- function formatResults(results, query) {
36
- return formatRecallResults(results, { query, showScore: true });
37
+ function formatResults(results, query, explain) {
38
+ return formatRecallResults(results, { query, showScore: true, showExplain: !!explain });
37
39
  }
38
40
 
39
41
  // ---------------------------------------------------------------------------
@@ -58,12 +60,12 @@ async function main() {
58
60
 
59
61
  const server = new McpServer({
60
62
  name: 'aquifer-memory',
61
- version: '0.9.0',
63
+ version: packageVersion,
62
64
  });
63
65
 
64
66
  server.tool(
65
67
  'session_recall',
66
- 'Search stored sessions by keyword. Supports entity intersection for precise multi-entity queries.',
68
+ 'Search Aquifer memory. In curated serving mode this searches active curated memory only; use evidence_recall for legacy session/evidence lookup.',
67
69
  {
68
70
  query: z.string().min(1).describe('Search query (keyword or natural language)'),
69
71
  limit: z.number().int().min(1).max(20).optional().describe('Max results (default 5)'),
@@ -74,6 +76,9 @@ async function main() {
74
76
  entities: z.array(z.string()).optional().describe('Entity names to match'),
75
77
  entityMode: z.enum(['any', 'all']).optional().describe('"any" (default, boost) or "all" (only sessions with every entity)'),
76
78
  mode: z.enum(['fts', 'hybrid', 'vector']).optional().describe('Recall mode: "fts" (keyword only, no embed needed), "hybrid" (default, FTS + vector), "vector" (vector only)'),
79
+ explain: z.boolean().optional().describe('Include per-result score breakdown (diagnostic)'),
80
+ activeScopeKey: z.string().optional().describe('Active curated memory scope key'),
81
+ activeScopePath: z.array(z.string()).optional().describe('Ordered curated scope path'),
77
82
  },
78
83
  async (params) => {
79
84
  try {
@@ -85,6 +90,8 @@ async function main() {
85
90
  source: params.source || undefined,
86
91
  dateFrom: params.dateFrom || undefined,
87
92
  dateTo: params.dateTo || undefined,
93
+ activeScopeKey: params.activeScopeKey || undefined,
94
+ activeScopePath: params.activeScopePath || undefined,
88
95
  };
89
96
  if (params.entities && params.entities.length > 0) {
90
97
  recallOpts.entities = params.entities;
@@ -93,7 +100,7 @@ async function main() {
93
100
  if (params.mode) recallOpts.mode = params.mode;
94
101
 
95
102
  const results = await aquifer.recall(params.query, recallOpts);
96
- const text = formatResults(results, params.query);
103
+ const text = formatResults(results, params.query, params.explain);
97
104
  return { content: [{ type: 'text', text }] };
98
105
  } catch (err) {
99
106
  return {
@@ -104,9 +111,55 @@ async function main() {
104
111
  }
105
112
  );
106
113
 
114
+ server.tool(
115
+ 'evidence_recall',
116
+ 'Explicit legacy/evidence search over stored sessions and summaries. Use for audit/debug/distillation; it does not feed bootstrap implicitly.',
117
+ {
118
+ query: z.string().min(1).describe('Evidence search query (keyword or natural language)'),
119
+ limit: z.number().int().min(1).max(20).optional().describe('Max results (default 5)'),
120
+ agentId: z.string().optional().describe('Filter by agent ID'),
121
+ source: z.string().optional().describe('Filter by source (e.g., gateway, cc)'),
122
+ dateFrom: z.string().optional().describe('Start date YYYY-MM-DD'),
123
+ dateTo: z.string().optional().describe('End date YYYY-MM-DD'),
124
+ entities: z.array(z.string()).optional().describe('Entity names to match'),
125
+ entityMode: z.enum(['any', 'all']).optional().describe('"any" (default, boost) or "all" (only sessions with every entity)'),
126
+ mode: z.enum(['fts', 'hybrid', 'vector']).optional().describe('Legacy evidence recall mode'),
127
+ explain: z.boolean().optional().describe('Include per-result score breakdown (diagnostic)'),
128
+ allowUnsafeDebug: z.boolean().optional().describe('Allow broad evidence/debug search without an audit boundary'),
129
+ },
130
+ async (params) => {
131
+ try {
132
+ const aquifer = getAquifer();
133
+ const limit = params.limit || 5;
134
+ const recallOpts = {
135
+ limit,
136
+ agentId: params.agentId || undefined,
137
+ source: params.source || undefined,
138
+ dateFrom: params.dateFrom || undefined,
139
+ dateTo: params.dateTo || undefined,
140
+ };
141
+ if (params.entities && params.entities.length > 0) {
142
+ recallOpts.entities = params.entities;
143
+ recallOpts.entityMode = params.entityMode || 'any';
144
+ }
145
+ if (params.mode) recallOpts.mode = params.mode;
146
+ if (params.allowUnsafeDebug) recallOpts.allowUnsafeDebug = true;
147
+
148
+ const results = await aquifer.evidenceRecall(params.query, recallOpts);
149
+ const text = formatResults(results, params.query, params.explain);
150
+ return { content: [{ type: 'text', text }] };
151
+ } catch (err) {
152
+ return {
153
+ content: [{ type: 'text', text: `evidence_recall error: ${err.message}` }],
154
+ isError: true,
155
+ };
156
+ }
157
+ }
158
+ );
159
+
107
160
  server.tool(
108
161
  'session_feedback',
109
- 'Record trust feedback on a recalled session. Helpful sessions rank higher in future recalls.',
162
+ 'After using legacy session_recall or bootstrap, mark the recalled session helpful or unhelpful. This only targets legacy session trust, not curated memory rows.',
110
163
  {
111
164
  sessionId: z.string().min(1).describe('Session ID to give feedback on'),
112
165
  verdict: z.enum(['helpful', 'unhelpful']).describe('Was the recalled session useful?'),
@@ -133,6 +186,73 @@ async function main() {
133
186
  }
134
187
  );
135
188
 
189
+ server.tool(
190
+ 'memory_feedback',
191
+ 'Record append-only feedback on a curated memory row. This affects curated ranking/review priority only and does not mutate memory truth.',
192
+ {
193
+ memoryId: z.string().min(1).optional().describe('Curated memory record ID to give feedback on'),
194
+ canonicalKey: z.string().min(1).optional().describe('Canonical key of the active curated memory record'),
195
+ feedbackType: z.enum(['helpful', 'confirm', 'irrelevant', 'scope_mismatch', 'stale', 'incorrect']).describe('Curated memory feedback event type'),
196
+ note: z.string().optional().describe('Optional reason'),
197
+ agentId: z.string().optional().describe('Optional actor/agent label for audit metadata'),
198
+ },
199
+ async (params) => {
200
+ try {
201
+ if (!params.memoryId && !params.canonicalKey) {
202
+ throw new Error('memory_feedback requires memoryId or canonicalKey');
203
+ }
204
+ const aquifer = getAquifer();
205
+ const result = await aquifer.memoryFeedback({
206
+ memoryId: params.memoryId || undefined,
207
+ canonicalKey: params.canonicalKey || undefined,
208
+ }, {
209
+ feedbackType: params.feedbackType,
210
+ note: params.note || undefined,
211
+ agentId: params.agentId || undefined,
212
+ });
213
+ return {
214
+ content: [{ type: 'text', text: `Memory feedback: ${result.feedbackType}` }],
215
+ };
216
+ } catch (err) {
217
+ return {
218
+ content: [{ type: 'text', text: `memory_feedback error: ${err.message}` }],
219
+ isError: true,
220
+ };
221
+ }
222
+ }
223
+ );
224
+
225
+ server.tool(
226
+ 'feedback_stats',
227
+ 'Return trust feedback statistics: total feedback count, helpful/unhelpful breakdown, trust score distribution, and coverage.',
228
+ {
229
+ agentId: z.string().optional().describe('Filter by agent ID'),
230
+ dateFrom: z.string().optional().describe('Start date YYYY-MM-DD'),
231
+ dateTo: z.string().optional().describe('End date YYYY-MM-DD'),
232
+ },
233
+ async (params) => {
234
+ try {
235
+ const aquifer = getAquifer();
236
+ const stats = await aquifer.feedbackStats({
237
+ agentId: params.agentId || undefined,
238
+ dateFrom: params.dateFrom || undefined,
239
+ dateTo: params.dateTo || undefined,
240
+ });
241
+ const lines = [
242
+ `Feedback: ${stats.totalFeedback} total (${stats.helpfulCount} helpful, ${stats.unhelpfulCount} unhelpful)`,
243
+ `Coverage: ${stats.feedbackSessions}/${stats.totalSessions} sessions rated`,
244
+ `Trust score: avg=${stats.trustScoreAvg} min=${stats.trustScoreMin} max=${stats.trustScoreMax}`,
245
+ ];
246
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
247
+ } catch (err) {
248
+ return {
249
+ content: [{ type: 'text', text: `feedback_stats error: ${err.message}` }],
250
+ isError: true,
251
+ };
252
+ }
253
+ }
254
+ );
255
+
136
256
  server.tool(
137
257
  'memory_stats',
138
258
  'Return storage statistics for the Aquifer memory store (session counts by status, summaries, turn embeddings, entities, date range).',
@@ -196,6 +316,8 @@ async function main() {
196
316
  limit: z.number().int().min(1).max(20).optional().describe('Max sessions (default 5)'),
197
317
  lookbackDays: z.number().int().min(1).max(90).optional().describe('How far back in days (default 14)'),
198
318
  maxChars: z.number().int().min(500).max(12000).optional().describe('Max output characters (default 4000)'),
319
+ activeScopeKey: z.string().optional().describe('Active curated memory scope key'),
320
+ activeScopePath: z.array(z.string()).optional().describe('Ordered curated scope path'),
199
321
  },
200
322
  async (params) => {
201
323
  try {
@@ -205,6 +327,8 @@ async function main() {
205
327
  limit: params.limit,
206
328
  lookbackDays: params.lookbackDays,
207
329
  maxChars: params.maxChars,
330
+ activeScopeKey: params.activeScopeKey || undefined,
331
+ activeScopePath: params.activeScopePath || undefined,
208
332
  format: 'text',
209
333
  });
210
334
  return { content: [{ type: 'text', text: result.text }] };
@@ -4,7 +4,6 @@
4
4
  //
5
5
  // Host layout:
6
6
  // $OPENCLAW_HOME/extensions/aquifer-memory/ ← symlink to this directory
7
- // (or run `bash scripts/install-openclaw.sh $OPENCLAW_HOME` from the tarball)
8
7
  //
9
8
  // Behavior:
10
9
  // - Loads $OPENCLAW_HOME/.env so DATABASE_URL / EMBED_PROVIDER /
@@ -201,7 +201,7 @@ function register(api) {
201
201
 
202
202
  return {
203
203
  name: 'session_recall',
204
- description: 'Search stored sessions by keyword. Supports entity intersection for precise multi-entity queries.',
204
+ description: 'Search Aquifer memory by keyword. In curated serving mode this searches active curated memory; legacy/evidence lookup is exposed by the MCP stdio evidence_recall tool. Supports entity intersection for precise multi-entity queries.',
205
205
  parameters: {
206
206
  type: 'object',
207
207
  properties: {
@@ -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,7 +30,21 @@ 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 },
43
+ memory: {
44
+ servingMode: 'legacy', // 'legacy' | 'curated'
45
+ activeScopeKey: null,
46
+ activeScopePath: null,
47
+ },
34
48
  rerank: {
35
49
  enabled: false,
36
50
  provider: null, // 'tei' | 'jina' | 'openrouter' | 'custom'
@@ -75,6 +89,12 @@ const ENV_MAP = [
75
89
  ['AQUIFER_LLM_TEMPERATURE', 'llm.temperature', Number],
76
90
  ['AQUIFER_ENTITIES_ENABLED', 'entities.enabled', Boolean],
77
91
  ['AQUIFER_ENTITY_SCOPE', 'entities.scope'],
92
+ ['AQUIFER_INSIGHTS_DEDUP_MODE', 'insights.dedup.mode'],
93
+ ['AQUIFER_INSIGHTS_DEDUP_COSINE', 'insights.dedup.cosineThreshold', Number],
94
+ ['AQUIFER_INSIGHTS_DEDUP_CLOSE_BAND_FROM', 'insights.dedup.closeBandFrom', Number],
95
+ ['AQUIFER_MEMORY_SERVING_MODE', 'memory.servingMode'],
96
+ ['AQUIFER_MEMORY_ACTIVE_SCOPE_KEY', 'memory.activeScopeKey'],
97
+ ['AQUIFER_MEMORY_ACTIVE_SCOPE_PATH', 'memory.activeScopePath'],
78
98
  ['AQUIFER_RERANK_ENABLED', 'rerank.enabled', Boolean],
79
99
  ['AQUIFER_RERANK_PROVIDER', 'rerank.provider'],
80
100
  ['AQUIFER_RERANK_BASE_URL', 'rerank.baseUrl'],
@@ -165,6 +185,14 @@ function loadConfig(opts = {}) {
165
185
  config = deepMerge(config, opts.overrides);
166
186
  }
167
187
 
188
+ // insights.dedup shorthand: true → enforce, false → off
189
+ if (config.insights && typeof config.insights.dedup === 'boolean') {
190
+ config.insights.dedup = {
191
+ ...DEFAULTS.insights.dedup,
192
+ mode: config.insights.dedup ? 'enforce' : 'off',
193
+ };
194
+ }
195
+
168
196
  return config;
169
197
  }
170
198
 
@@ -91,6 +91,8 @@ function createAquiferFromConfig(overrides) {
91
91
  rank: config.rank,
92
92
  rerank: rerankOpts,
93
93
  migrations: config.migrations,
94
+ insights: config.insights,
95
+ memory: config.memory,
94
96
  });
95
97
 
96
98
  return aquifer;
@@ -37,7 +37,7 @@ function evictStale(dedupMap, now = Date.now()) {
37
37
  * @param {string} [opts.source] — caller-provided source tag (e.g. 'openclaw', 'cc', 'opencode')
38
38
  * @param {string} [opts.sessionKey] — passed through to commit()
39
39
  * @param {any[]} opts.rawEntries — host-native session entries
40
- * @param {'gateway'|'cc'|'claude-code'|'preNormalized'} [opts.adapter]
40
+ * @param {'gateway'|'cc'|'claude-code'|'codex'|'preNormalized'} [opts.adapter]
41
41
  * 'preNormalized' means rawEntries already matches normalizeMessages output
42
42
  * (used by OpenCode which reads SQLite directly).
43
43
  * @param {object} [opts.preNormalized] — { messages, userCount, ... } ready to commit,
@@ -5,7 +5,7 @@
5
5
  // session-level metadata. Wraps pipeline/normalize so consumers don't each
6
6
  // reinvent their own role/content extraction.
7
7
  //
8
- // Supported adapters: 'gateway' | 'cc' (alias of 'claude-code'). The OpenCode
8
+ // Supported adapters: 'gateway' | 'cc' (alias of 'claude-code') | 'codex'. The OpenCode
9
9
  // consumer reads from SQLite and constructs the output shape directly; it is
10
10
  // not expected to route through here.
11
11
  //
@@ -21,13 +21,14 @@ const ADAPTER_ALIASES = {
21
21
  'cc': 'claude-code',
22
22
  'claude-code': 'claude-code',
23
23
  'gateway': 'gateway',
24
+ 'codex': 'codex',
24
25
  };
25
26
 
26
27
  function resolveAdapter(adapter) {
27
28
  if (!adapter) return null; // auto-detect
28
29
  const name = ADAPTER_ALIASES[adapter];
29
30
  if (!name) {
30
- throw new Error(`Unknown adapter: "${adapter}". Supported: gateway, cc (alias claude-code).`);
31
+ throw new Error(`Unknown adapter: "${adapter}". Supported: gateway, cc (alias claude-code), codex.`);
31
32
  }
32
33
  return name;
33
34
  }
@@ -39,6 +40,16 @@ function extractRawMeta(rawEntries) {
39
40
 
40
41
  for (const entry of rawEntries || []) {
41
42
  if (!entry || typeof entry !== 'object') continue;
43
+
44
+ if (entry.type === 'turn_context' && entry.payload?.model && !model) {
45
+ model = entry.payload.model;
46
+ }
47
+ if (entry.type === 'event_msg' && entry.payload?.type === 'token_count') {
48
+ const usage = entry.payload?.info?.last_token_usage || {};
49
+ tokensIn += usage.input_tokens || usage.input || 0;
50
+ tokensOut += usage.output_tokens || usage.output || 0;
51
+ }
52
+
42
53
  const msg = entry.message || entry;
43
54
  if (msg && typeof msg === 'object') {
44
55
  if (msg.model && !model) model = msg.model;
@@ -57,7 +68,7 @@ function extractRawMeta(rawEntries) {
57
68
  *
58
69
  * @param {any[]} rawEntries
59
70
  * @param {object} [opts]
60
- * @param {'gateway'|'cc'|'claude-code'} [opts.adapter] — host adapter; auto-detected if omitted
71
+ * @param {'gateway'|'cc'|'claude-code'|'codex'} [opts.adapter] — host adapter; auto-detected if omitted
61
72
  * @returns {{
62
73
  * messages: {role:string,content:string,timestamp:string|null}[],
63
74
  * userCount: number, assistantCount: number,
@@ -18,6 +18,24 @@ function formatDateIso(value) {
18
18
  return Number.isNaN(d.getTime()) ? 'unknown' : d.toISOString().slice(0, 10);
19
19
  }
20
20
 
21
+ function isCuratedMemoryResult(result) {
22
+ return Boolean(result && (
23
+ result.memoryType || result.memory_type ||
24
+ result.canonicalKey || result.canonical_key ||
25
+ result.scopeKey || result.scope_key
26
+ ));
27
+ }
28
+
29
+ function curatedTitle(result) {
30
+ const type = result.memoryType || result.memory_type || 'memory';
31
+ const title = result.title || result.summary || result.canonicalKey || result.canonical_key || type;
32
+ return truncate(title, 80);
33
+ }
34
+
35
+ function resultDate(result) {
36
+ return result.startedAt || result.acceptedAt || result.accepted_at || result.observedAt || result.observed_at || null;
37
+ }
38
+
21
39
  // Humanize a past timestamp into zh-TW relative form (e.g. "3 天前", "昨天").
22
40
  // Bucketed on raw ms-diff — good enough for model intuition, not calendar-precise.
23
41
  // Returns null for invalid / future timestamps so callers can fall back.
@@ -48,6 +66,11 @@ const defaultRenderers = {
48
66
  return query ? `No results found for "${query}".` : 'No matching sessions found.';
49
67
  },
50
68
  title(result, index) {
69
+ if (isCuratedMemoryResult(result)) {
70
+ const date = formatDateIso(resultDate(result));
71
+ const scope = result.scopeKey || result.scope_key || 'scope:unknown';
72
+ return `### ${index + 1}. ${curatedTitle(result)} (${date}, ${scope})`;
73
+ }
51
74
  const ss = result.structuredSummary || {};
52
75
  const title = ss.title || truncate(result.summaryText, 60) || '(untitled)';
53
76
  const date = formatDateIso(result.startedAt);
@@ -55,6 +78,10 @@ const defaultRenderers = {
55
78
  return `### ${index + 1}. ${title} (${date}, ${agent})`;
56
79
  },
57
80
  body(result) {
81
+ if (isCuratedMemoryResult(result)) {
82
+ const text = result.summary || result.title || result.canonicalKey || result.canonical_key || '';
83
+ return text ? truncate(text, 300) : null;
84
+ }
58
85
  const ss = result.structuredSummary || {};
59
86
  const text = ss.overview || result.summaryText || '';
60
87
  return text ? truncate(text, 300) : null;
@@ -66,6 +93,30 @@ const defaultRenderers = {
66
93
  if (!showScore) return null;
67
94
  return `Score: ${typeof result.score === 'number' ? result.score.toFixed(3) : '?'}`;
68
95
  },
96
+ explain(result, { showExplain }) {
97
+ if (!showExplain) return null;
98
+ const d = result._debug;
99
+ if (!d) return null;
100
+ const f = (v) => typeof v === 'number' ? v.toFixed(3) : '?';
101
+ const parts = [
102
+ `rrf=${f(d.rrf)}`,
103
+ `td=${f(d.timeDecay)}`,
104
+ `access=${f(d.access)}`,
105
+ `entity=${f(d.entityScore)}`,
106
+ `trust=${f(d.trustScore)}(\u00d7${f(d.trustMultiplier)})`,
107
+ `ol=${f(d.openLoopBoost)}`,
108
+ `\u2192 hybrid=${f(d.hybridScore)}`,
109
+ ];
110
+ if (d.rerankApplied) {
111
+ parts.push(`rerank=${f(d.rerankScore)}(${d.rerankReason || '?'})`);
112
+ } else {
113
+ parts.push(`[rerank: off (${d.rerankReason || '?'})]`);
114
+ }
115
+ if (Array.isArray(d.searchErrors) && d.searchErrors.length > 0) {
116
+ parts.push(`errors: ${d.searchErrors.map(e => (e && e.path) || '?').join(',')}`);
117
+ }
118
+ return ` ${parts.join(' ')}`;
119
+ },
69
120
  separator() {
70
121
  return '';
71
122
  },
@@ -102,6 +153,8 @@ function createRecallFormatter(overrides = {}) {
102
153
  if (matched) lines.push(matched);
103
154
  const score = r.score(res, { showScore: !!opts.showScore, ...ctx });
104
155
  if (score) lines.push(score);
156
+ const explain = r.explain(res, { showExplain: !!opts.showExplain, ...ctx });
157
+ if (explain) lines.push(explain);
105
158
  const sep = r.separator(i, ctx);
106
159
  if (sep !== null && sep !== undefined) lines.push(sep);
107
160
  }