@shadowforge0/aquifer-memory 1.5.12 → 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 (60) hide show
  1. package/.env.example +23 -0
  2. package/README.md +78 -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 +353 -52
  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 +96 -5
  14. package/consumers/openclaw-ext/index.js +0 -1
  15. package/consumers/openclaw-plugin.js +1 -1
  16. package/consumers/shared/config.js +8 -0
  17. package/consumers/shared/factory.js +1 -0
  18. package/consumers/shared/ingest.js +1 -1
  19. package/consumers/shared/normalize.js +14 -3
  20. package/consumers/shared/recall-format.js +27 -0
  21. package/consumers/shared/summary-parser.js +151 -0
  22. package/core/aquifer.js +372 -18
  23. package/core/finalization-review.js +319 -0
  24. package/core/mcp-manifest.js +52 -2
  25. package/core/memory-bootstrap.js +188 -0
  26. package/core/memory-consolidation.js +1236 -0
  27. package/core/memory-promotion.js +544 -0
  28. package/core/memory-recall.js +247 -0
  29. package/core/memory-records.js +581 -0
  30. package/core/memory-safety-gate.js +224 -0
  31. package/core/session-finalization.js +350 -0
  32. package/core/storage.js +385 -2
  33. package/docs/getting-started.md +99 -0
  34. package/docs/postprocess-contract.md +2 -2
  35. package/docs/setup.md +51 -2
  36. package/package.json +25 -11
  37. package/pipeline/normalize/adapters/codex.js +106 -0
  38. package/pipeline/normalize/detect.js +3 -2
  39. package/schema/001-base.sql +3 -0
  40. package/schema/007-v1-foundation.sql +273 -0
  41. package/schema/008-session-finalizations.sql +50 -0
  42. package/schema/009-v1-assertion-plane.sql +193 -0
  43. package/schema/010-v1-finalization-review.sql +160 -0
  44. package/schema/011-v1-compaction-claim.sql +46 -0
  45. package/schema/012-v1-compaction-lease.sql +39 -0
  46. package/schema/013-v1-compaction-lineage.sql +193 -0
  47. package/scripts/codex-recovery.js +532 -0
  48. package/consumers/miranda/context-inject.js +0 -120
  49. package/consumers/miranda/daily-entries.js +0 -224
  50. package/consumers/miranda/index.js +0 -364
  51. package/consumers/miranda/instance.js +0 -55
  52. package/consumers/miranda/llm.js +0 -99
  53. package/consumers/miranda/profile.json +0 -145
  54. package/consumers/miranda/prompts/summary.js +0 -303
  55. package/consumers/miranda/recall-format.js +0 -76
  56. package/consumers/miranda/render-daily-md.js +0 -186
  57. package/consumers/miranda/workspace-files.js +0 -91
  58. package/scripts/drop-entity-state-history.sql +0 -17
  59. package/scripts/drop-insights.sql +0 -12
  60. package/scripts/install-openclaw.sh +0 -59
@@ -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,8 +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, feedback_stats,
11
- * session_bootstrap, memory_stats, memory_pending
10
+ * Tools: session_recall, evidence_recall, session_feedback, memory_feedback,
11
+ * feedback_stats, session_bootstrap, memory_stats, memory_pending
12
12
  *
13
13
  * Usage:
14
14
  * npx aquifer mcp
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  const { createAquiferFromConfig } = require('./shared/factory');
22
+ const { version: packageVersion } = require('../package.json');
22
23
 
23
24
  let _aquifer = null;
24
25
 
@@ -59,12 +60,12 @@ async function main() {
59
60
 
60
61
  const server = new McpServer({
61
62
  name: 'aquifer-memory',
62
- version: '0.9.0',
63
+ version: packageVersion,
63
64
  });
64
65
 
65
66
  server.tool(
66
67
  'session_recall',
67
- '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.',
68
69
  {
69
70
  query: z.string().min(1).describe('Search query (keyword or natural language)'),
70
71
  limit: z.number().int().min(1).max(20).optional().describe('Max results (default 5)'),
@@ -76,6 +77,8 @@ async function main() {
76
77
  entityMode: z.enum(['any', 'all']).optional().describe('"any" (default, boost) or "all" (only sessions with every entity)'),
77
78
  mode: z.enum(['fts', 'hybrid', 'vector']).optional().describe('Recall mode: "fts" (keyword only, no embed needed), "hybrid" (default, FTS + vector), "vector" (vector only)'),
78
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'),
79
82
  },
80
83
  async (params) => {
81
84
  try {
@@ -87,6 +90,8 @@ async function main() {
87
90
  source: params.source || undefined,
88
91
  dateFrom: params.dateFrom || undefined,
89
92
  dateTo: params.dateTo || undefined,
93
+ activeScopeKey: params.activeScopeKey || undefined,
94
+ activeScopePath: params.activeScopePath || undefined,
90
95
  };
91
96
  if (params.entities && params.entities.length > 0) {
92
97
  recallOpts.entities = params.entities;
@@ -106,9 +111,55 @@ async function main() {
106
111
  }
107
112
  );
108
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
+
109
160
  server.tool(
110
161
  'session_feedback',
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.',
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.',
112
163
  {
113
164
  sessionId: z.string().min(1).describe('Session ID to give feedback on'),
114
165
  verdict: z.enum(['helpful', 'unhelpful']).describe('Was the recalled session useful?'),
@@ -135,6 +186,42 @@ async function main() {
135
186
  }
136
187
  );
137
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
+
138
225
  server.tool(
139
226
  'feedback_stats',
140
227
  'Return trust feedback statistics: total feedback count, helpful/unhelpful breakdown, trust score distribution, and coverage.',
@@ -229,6 +316,8 @@ async function main() {
229
316
  limit: z.number().int().min(1).max(20).optional().describe('Max sessions (default 5)'),
230
317
  lookbackDays: z.number().int().min(1).max(90).optional().describe('How far back in days (default 14)'),
231
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'),
232
321
  },
233
322
  async (params) => {
234
323
  try {
@@ -238,6 +327,8 @@ async function main() {
238
327
  limit: params.limit,
239
328
  lookbackDays: params.lookbackDays,
240
329
  maxChars: params.maxChars,
330
+ activeScopeKey: params.activeScopeKey || undefined,
331
+ activeScopePath: params.activeScopePath || undefined,
241
332
  format: 'text',
242
333
  });
243
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: {
@@ -40,6 +40,11 @@ const DEFAULTS = {
40
40
  },
41
41
  },
42
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
+ },
43
48
  rerank: {
44
49
  enabled: false,
45
50
  provider: null, // 'tei' | 'jina' | 'openrouter' | 'custom'
@@ -87,6 +92,9 @@ const ENV_MAP = [
87
92
  ['AQUIFER_INSIGHTS_DEDUP_MODE', 'insights.dedup.mode'],
88
93
  ['AQUIFER_INSIGHTS_DEDUP_COSINE', 'insights.dedup.cosineThreshold', Number],
89
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'],
90
98
  ['AQUIFER_RERANK_ENABLED', 'rerank.enabled', Boolean],
91
99
  ['AQUIFER_RERANK_PROVIDER', 'rerank.provider'],
92
100
  ['AQUIFER_RERANK_BASE_URL', 'rerank.baseUrl'],
@@ -92,6 +92,7 @@ function createAquiferFromConfig(overrides) {
92
92
  rerank: rerankOpts,
93
93
  migrations: config.migrations,
94
94
  insights: config.insights,
95
+ memory: config.memory,
95
96
  });
96
97
 
97
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;
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+
3
+ const DEFAULT_SUMMARY_MARKERS = [
4
+ '===SESSION_ENTRIES===',
5
+ '===EMOTIONAL_STATE===',
6
+ '===RECAP===',
7
+ '===ENTITIES===',
8
+ '===WORKING_FACTS===',
9
+ '===HANDOFF===',
10
+ ];
11
+ const SUMMARY_MARKERS = DEFAULT_SUMMARY_MARKERS;
12
+
13
+ function parseSummaryOutput(output, markers = DEFAULT_SUMMARY_MARKERS) {
14
+ const sections = {};
15
+ for (let i = 0; i < markers.length; i++) {
16
+ const start = output.indexOf(markers[i]);
17
+ if (start === -1) continue;
18
+ const contentStart = start + markers[i].length;
19
+ let end = output.length;
20
+ for (let j = i + 1; j < markers.length; j++) {
21
+ const candidate = output.indexOf(markers[j], contentStart);
22
+ if (candidate !== -1) { end = candidate; break; }
23
+ }
24
+ const key = markers[i].replace(/===/g, '').toLowerCase();
25
+ sections[key] = (end > contentStart ? output.slice(contentStart, end) : output.slice(contentStart)).trim();
26
+ }
27
+ return sections;
28
+ }
29
+
30
+ function normalizeOpenOwner(raw) {
31
+ const owner = (raw || 'unknown').trim().toLowerCase();
32
+ if (['mk', 'agent', 'unknown'].includes(owner)) return owner;
33
+ if (/^[a-z][a-z0-9_-]{0,63}$/.test(owner)) return owner;
34
+ return 'unknown';
35
+ }
36
+
37
+ function parseRecapLines(text) {
38
+ const recap = {
39
+ title: '', overview: '', topics: [], decisions: [], actions_completed: [],
40
+ open_loops: [], files_mentioned: [], important_facts: [], reusable_patterns: [],
41
+ focus_decision: 'keep', focus: '', todo_new: [], todo_done: [],
42
+ };
43
+
44
+ for (const line of (text || '').split('\n')) {
45
+ const trimmed = line.trim();
46
+ if (!trimmed) continue;
47
+ const match = trimmed.match(/^([A-Z_]+):\s*(.*)/);
48
+ if (!match) continue;
49
+ const [, tag, value] = match;
50
+
51
+ switch (tag) {
52
+ case 'TITLE': recap.title = value; break;
53
+ case 'OVERVIEW': recap.overview = value; break;
54
+ case 'TOPIC': {
55
+ const p = value.split('|').map(s => s.trim());
56
+ if (p[0]) recap.topics.push({ name: p[0], summary: p[1] || '' });
57
+ break;
58
+ }
59
+ case 'DECISION': {
60
+ const p = value.split('|').map(s => s.trim());
61
+ if (p[0]) recap.decisions.push({ decision: p[0], reason: p[1] || '' });
62
+ break;
63
+ }
64
+ case 'ACTION': {
65
+ const p = value.split('|').map(s => s.trim());
66
+ if (p[0]) recap.actions_completed.push({
67
+ action: p[0],
68
+ status: (p[1] || 'done').toLowerCase() === 'partial' ? 'partial' : 'done',
69
+ });
70
+ break;
71
+ }
72
+ case 'OPEN': {
73
+ const p = value.split('|').map(s => s.trim());
74
+ if (p[0]) recap.open_loops.push({
75
+ item: p[0],
76
+ owner: normalizeOpenOwner(p[1]),
77
+ });
78
+ break;
79
+ }
80
+ case 'FACT': if (value) recap.important_facts.push(value); break;
81
+ case 'PATTERN': {
82
+ const p = value.split('|').map(s => s.trim());
83
+ if (p[0] && p[1]) recap.reusable_patterns.push({
84
+ pattern: p[0], trigger: p[1], action: p[2] || '',
85
+ durability: (p[3] || 'derived').toLowerCase() === 'invariant' ? 'invariant' : 'derived',
86
+ });
87
+ break;
88
+ }
89
+ case 'FOCUS_DECISION':
90
+ recap.focus_decision = value.toLowerCase().trim() === 'update' ? 'update' : 'keep';
91
+ break;
92
+ case 'FOCUS': recap.focus = value; break;
93
+ case 'TODO_NEW': if (value) recap.todo_new.push(value); break;
94
+ case 'TODO_DONE': if (value) recap.todo_done.push(value); break;
95
+ }
96
+ }
97
+ return recap;
98
+ }
99
+
100
+ function parseWorkingFacts(text) {
101
+ if (!text || typeof text !== 'string') return [];
102
+ const facts = [];
103
+ for (const line of text.split('\n')) {
104
+ const m = line.trim().match(/^WFACT:\s*(.+?)\s*\|\s*(.+)/);
105
+ if (!m) continue;
106
+ const subject = m[1].trim().slice(0, 100);
107
+ const statement = m[2].trim().slice(0, 500);
108
+ if (!subject || !statement) continue;
109
+ facts.push({ subject, statement });
110
+ if (facts.length >= 5) break;
111
+ }
112
+ return facts;
113
+ }
114
+
115
+ const VALID_HANDOFF_STATUS = new Set(['in_progress', 'interrupted', 'completed', 'blocked']);
116
+ const VALID_STOP_REASON = new Set(['natural', 'interrupted', 'blocked', 'context_full']);
117
+
118
+ function normalizeEnum(raw, validSet) {
119
+ const v = raw.trim().toLowerCase().replace(/-/g, '_').replace(/\s+/g, '_');
120
+ return validSet.has(v) ? v : null;
121
+ }
122
+
123
+ function parseHandoffSection(text) {
124
+ if (!text || typeof text !== 'string') return null;
125
+ const handoff = { status: 'completed', lastStep: '', next: '', stopReason: 'natural', decided: '', blocker: '' };
126
+ for (const line of text.split('\n')) {
127
+ const m = line.trim().match(/^([A-Z_]+):\s*(.*)/);
128
+ if (!m) continue;
129
+ const [, tag, value] = m;
130
+ switch (tag) {
131
+ case 'STATUS': handoff.status = normalizeEnum(value, VALID_HANDOFF_STATUS) || 'completed'; break;
132
+ case 'LAST_STEP': handoff.lastStep = value.trim().slice(0, 200); break;
133
+ case 'NEXT': handoff.next = value.trim().slice(0, 200); break;
134
+ case 'STOP_REASON': handoff.stopReason = normalizeEnum(value, VALID_STOP_REASON) || 'natural'; break;
135
+ case 'DECIDED': handoff.decided = value.trim().slice(0, 200); break;
136
+ case 'BLOCKER': handoff.blocker = value.trim().slice(0, 200); break;
137
+ }
138
+ }
139
+ if (!handoff.lastStep || !handoff.next) return null;
140
+ return handoff;
141
+ }
142
+
143
+ module.exports = {
144
+ DEFAULT_SUMMARY_MARKERS,
145
+ SUMMARY_MARKERS,
146
+ parseSummaryOutput,
147
+ parseRecapLines,
148
+ parseWorkingFacts,
149
+ parseHandoffSection,
150
+ normalizeOpenOwner,
151
+ };