@shadowforge0/aquifer-memory 1.0.2 → 1.2.1

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 (45) hide show
  1. package/README.md +29 -20
  2. package/consumers/claude-code.js +117 -0
  3. package/consumers/cli.js +17 -0
  4. package/consumers/default/daily-entries.js +196 -0
  5. package/consumers/default/index.js +282 -0
  6. package/consumers/default/prompts/summary.js +153 -0
  7. package/consumers/mcp.js +3 -23
  8. package/consumers/miranda/context-inject.js +119 -0
  9. package/consumers/miranda/daily-entries.js +224 -0
  10. package/consumers/miranda/index.js +353 -0
  11. package/consumers/miranda/instance.js +55 -0
  12. package/consumers/miranda/llm.js +99 -0
  13. package/consumers/miranda/prompts/summary.js +303 -0
  14. package/consumers/miranda/recall-format.js +74 -0
  15. package/consumers/miranda/workspace-files.js +91 -0
  16. package/consumers/openclaw-ext/index.js +38 -0
  17. package/consumers/openclaw-ext/openclaw.plugin.json +9 -0
  18. package/consumers/openclaw-ext/package.json +10 -0
  19. package/consumers/openclaw-plugin.js +66 -74
  20. package/consumers/opencode.js +21 -24
  21. package/consumers/shared/autodetect.js +64 -0
  22. package/consumers/shared/entity-parser.js +119 -0
  23. package/consumers/shared/ingest.js +148 -0
  24. package/consumers/shared/llm-autodetect.js +137 -0
  25. package/consumers/shared/normalize.js +129 -0
  26. package/consumers/shared/recall-format.js +110 -0
  27. package/core/aquifer.js +200 -82
  28. package/core/entity.js +29 -17
  29. package/core/storage.js +116 -45
  30. package/docs/postprocess-contract.md +132 -0
  31. package/index.js +9 -1
  32. package/package.json +23 -2
  33. package/pipeline/_http.js +1 -1
  34. package/pipeline/consolidation/apply.js +176 -0
  35. package/pipeline/consolidation/index.js +21 -0
  36. package/pipeline/extract-entities.js +2 -2
  37. package/pipeline/rerank.js +1 -1
  38. package/pipeline/summarize.js +4 -1
  39. package/schema/001-base.sql +61 -24
  40. package/schema/002-entities.sql +17 -3
  41. package/schema/004-facts.sql +67 -0
  42. package/scripts/diagnose-fts-zh.js +168 -134
  43. package/scripts/diagnose-vector.js +188 -0
  44. package/scripts/install-openclaw.sh +59 -0
  45. package/scripts/smoke.mjs +2 -2
@@ -0,0 +1,224 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const { parseHandoffSection } = require('./prompts/summary');
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Miranda daily log — writes to host-owned `miranda.daily_entries` table.
8
+ //
9
+ // Self-contained DAL: the SQL lives here instead of in session-dal so the
10
+ // plugin doesn't pull in OpenClaw host code. The host injects a pg.Pool.
11
+ //
12
+ // Table layout (matches live miranda.daily_entries):
13
+ // id, event_at, source, tag, text, agent_id, session_id, metadata, dedupe_key
14
+ // ---------------------------------------------------------------------------
15
+
16
+ const TABLE = 'miranda.daily_entries';
17
+ const UPSERT_TAGS = new Set(['[FOCUS]', '[TODO]', '[STATS]', '[HIGHLIGHT]', '[SYSTEM]', '[HANDOFF]']);
18
+
19
+ function taipeiDateString(now) {
20
+ if (!now) now = new Date();
21
+ return new Intl.DateTimeFormat('sv-SE', {
22
+ timeZone: 'Asia/Taipei',
23
+ year: 'numeric', month: '2-digit', day: '2-digit',
24
+ }).format(now);
25
+ }
26
+
27
+ function textHash6(text) {
28
+ const normalized = (text || '').normalize('NFKC').replace(/\s+/g, ' ').trim().toLowerCase();
29
+ if (!normalized) return 'empty';
30
+ return crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 6);
31
+ }
32
+
33
+ async function insertDailyEntry(pool, { eventAt, source, tag, text, agentId, sessionId, metadata, dedupeKey }) {
34
+ const shouldUpsert = dedupeKey && UPSERT_TAGS.has(tag);
35
+ const sql = shouldUpsert
36
+ ? `INSERT INTO ${TABLE}
37
+ (event_at, source, tag, text, agent_id, session_id, metadata, dedupe_key)
38
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
39
+ ON CONFLICT (dedupe_key) DO UPDATE SET
40
+ text = EXCLUDED.text,
41
+ event_at = EXCLUDED.event_at,
42
+ metadata = EXCLUDED.metadata
43
+ RETURNING id, event_at, source, tag, text`
44
+ : `INSERT INTO ${TABLE}
45
+ (event_at, source, tag, text, agent_id, session_id, metadata, dedupe_key)
46
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
47
+ ON CONFLICT (dedupe_key) DO NOTHING
48
+ RETURNING id, event_at, source, tag, text`;
49
+ const result = await pool.query(sql, [
50
+ eventAt,
51
+ source,
52
+ tag || null,
53
+ text,
54
+ agentId || 'main',
55
+ sessionId || null,
56
+ metadata ? JSON.stringify(metadata) : '{}',
57
+ dedupeKey || null,
58
+ ]);
59
+ return result.rows[0] || null;
60
+ }
61
+
62
+ async function getDailyEntries(pool, date, agentId) {
63
+ const result = await pool.query(
64
+ `SELECT * FROM ${TABLE}
65
+ WHERE (event_at AT TIME ZONE 'Asia/Taipei')::date = $1
66
+ AND ($2::text IS NULL OR agent_id = $2)
67
+ ORDER BY event_at ASC`,
68
+ [date, agentId || null],
69
+ );
70
+ return result.rows;
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+
75
+ async function fetchDailyContext(pool, date, agentId) {
76
+ const rows = await getDailyEntries(pool, date, agentId);
77
+ if (!rows || rows.length === 0) return '';
78
+
79
+ let currentFocus = '';
80
+ let currentTodo = '';
81
+ const entries = [];
82
+
83
+ for (const row of rows) {
84
+ if (row.tag === '[FOCUS]') currentFocus = row.text;
85
+ else if (row.tag === '[TODO]') currentTodo = row.text;
86
+ else if (!row.tag || row.tag === '[CLI]') {
87
+ const time = new Date(row.event_at).toLocaleTimeString('sv-SE', {
88
+ timeZone: 'Asia/Taipei', hour: '2-digit', minute: '2-digit',
89
+ });
90
+ entries.push(`- (${time}) ${row.text}`);
91
+ }
92
+ }
93
+
94
+ const recentEntries = entries.slice(-20);
95
+ const parts = [];
96
+ if (currentFocus) parts.push(`當前焦點: ${currentFocus}`);
97
+ if (currentTodo) parts.push(`當前待辦:\n${currentTodo}`);
98
+ if (recentEntries.length > 0) parts.push(`今日紀錄:\n${recentEntries.join('\n')}`);
99
+
100
+ let text = parts.join('\n\n');
101
+ if (text.length > 3000) text = text.slice(0, 3000) + '\n...(truncated)';
102
+ return text;
103
+ }
104
+
105
+ async function writeDailyEntries({
106
+ sections, recap, pool, sessionId, agentId, logger = console,
107
+ source = 'afterburn', tag = null, now, renderDailyLog,
108
+ }) {
109
+ if (!now) now = new Date();
110
+ // Daily entries are work logs — always attribute to main
111
+ if (agentId === 'cc') agentId = 'main';
112
+ const date = taipeiDateString(now);
113
+ let inserted = 0;
114
+ let focusUpdated = false;
115
+ let todoUpdated = false;
116
+
117
+ // Session bullets
118
+ if (sections?.session_entries) {
119
+ const entryLines = sections.session_entries.split('\n');
120
+ const bullets = entryLines.filter(l => l.trim().startsWith('- ')).map(l => l.trim().slice(2));
121
+ for (const bullet of bullets) {
122
+ const timeMatch = bullet.match(/^\((\d{2}:\d{2})\)\s*(.*)/);
123
+ const text = timeMatch ? timeMatch[2] : bullet;
124
+ const eventAt = now.toISOString();
125
+ const row = await insertDailyEntry(pool, {
126
+ eventAt, source, tag, text,
127
+ agentId, sessionId, metadata: {},
128
+ dedupeKey: `daily:${date}:${textHash6(text)}`,
129
+ });
130
+ if (row) inserted++;
131
+ }
132
+ if (logger.info) logger.info(`[miranda] wrote ${inserted} daily entries`);
133
+ }
134
+
135
+ // Focus
136
+ if (recap?.focus_decision === 'update' && recap.focus) {
137
+ await insertDailyEntry(pool, {
138
+ eventAt: now.toISOString(), source, tag: '[FOCUS]',
139
+ text: recap.focus, agentId, sessionId,
140
+ metadata: { proposed_by: source },
141
+ dedupeKey: `daily:${date}:focus:${source}`,
142
+ });
143
+ focusUpdated = true;
144
+ if (logger.info) logger.info(`[miranda] focus updated: ${recap.focus.slice(0, 60)}`);
145
+ }
146
+
147
+ // TODO
148
+ if (recap?.todo_new?.length > 0 || recap?.todo_done?.length > 0) {
149
+ const todayEntries = await getDailyEntries(pool, date, agentId);
150
+ let currentItems = [];
151
+ for (const row of todayEntries) {
152
+ if (row.tag === '[TODO]') {
153
+ currentItems = row.text.split('\n').map(s => s.replace(/^[-•]\s*/, '').trim()).filter(Boolean);
154
+ }
155
+ }
156
+ if (recap.todo_done?.length > 0) {
157
+ for (const done of recap.todo_done) {
158
+ const dl = done.toLowerCase();
159
+ currentItems = currentItems.filter(item => {
160
+ const il = item.toLowerCase();
161
+ return il !== dl && !il.includes(dl) && !dl.includes(il);
162
+ });
163
+ }
164
+ }
165
+ if (recap.todo_new?.length > 0) {
166
+ for (const n of recap.todo_new) {
167
+ if (!currentItems.some(i => i.toLowerCase() === n.toLowerCase())) currentItems.push(n);
168
+ }
169
+ }
170
+ await insertDailyEntry(pool, {
171
+ eventAt: now.toISOString(), source, tag: '[TODO]',
172
+ text: currentItems.map(i => `- ${i}`).join('\n') || '(全部完成)',
173
+ agentId, sessionId,
174
+ metadata: { proposed_by: source, todo_new: recap.todo_new, todo_done: recap.todo_done },
175
+ dedupeKey: `daily:${date}:todo:${source}`,
176
+ });
177
+ todoUpdated = true;
178
+ if (logger.info) logger.info(`[miranda] todo updated: ${currentItems.length} items`);
179
+ }
180
+
181
+ // Handoff
182
+ if (sections?.handoff) {
183
+ const handoff = parseHandoffSection(sections.handoff);
184
+ if (handoff) {
185
+ let handoffText;
186
+ switch (handoff.status) {
187
+ case 'completed': handoffText = `上一段已完成 ${handoff.lastStep}`; break;
188
+ case 'blocked': handoffText = `上一段卡在 ${handoff.lastStep}`; break;
189
+ default: handoffText = `上一段停在 ${handoff.lastStep}`;
190
+ }
191
+ if (handoff.next && handoff.next !== '無') handoffText += `,下一步建議 ${handoff.next}`;
192
+ if (handoff.decided) handoffText += `,已決定 ${handoff.decided}`;
193
+ if (handoff.blocker && handoff.status !== 'blocked') handoffText += `,卡在 ${handoff.blocker}`;
194
+ handoffText += '。';
195
+
196
+ await insertDailyEntry(pool, {
197
+ eventAt: now.toISOString(), source, tag: '[HANDOFF]',
198
+ text: handoffText, agentId, sessionId,
199
+ metadata: { ...handoff, proposed_by: source },
200
+ dedupeKey: `daily:${date}:handoff:${source}`,
201
+ });
202
+ if (logger.info) logger.info(`[miranda] handoff written: ${handoffText.slice(0, 80)}`);
203
+ }
204
+ }
205
+
206
+ // Optional custom renderer (persona can plug in a markdown writer)
207
+ if (renderDailyLog) {
208
+ try { await renderDailyLog(date, agentId); }
209
+ catch (err) { if (logger.info) logger.info(`[miranda] renderDailyLog failed: ${err.message}`); }
210
+ }
211
+
212
+ return { inserted, focusUpdated, todoUpdated };
213
+ }
214
+
215
+ module.exports = {
216
+ taipeiDateString,
217
+ textHash6,
218
+ insertDailyEntry,
219
+ getDailyEntries,
220
+ fetchDailyContext,
221
+ writeDailyEntries,
222
+ TABLE,
223
+ UPSERT_TAGS,
224
+ };
@@ -0,0 +1,353 @@
1
+ 'use strict';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Miranda persona layer.
5
+ //
6
+ // This is a PERSONA, not a host adapter. It wraps a host (OpenClaw gateway,
7
+ // Claude Code afterburn) with Miranda's six-section prompt, zh-TW daily log,
8
+ // workspace file artifacts, and consolidation lifecycle — but leaves the
9
+ // actual hook plumbing (before_reset, before_prompt_build, MCP tool registry)
10
+ // to the host.
11
+ //
12
+ // Entry points:
13
+ // mountOnOpenClaw(api, opts) — gateway plugin wiring
14
+ // mountOnClaudeCode(cc, opts) — CC afterburn wiring (see consumers/claude-code.js)
15
+ // buildPostProcess(opts) — low-level: returns the enrich postProcess
16
+ // fn; host calls it directly if neither
17
+ // mount helper fits.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const { runIngest } = require('../shared/ingest');
21
+ const { parseEntitySection } = require('../shared/entity-parser');
22
+
23
+ const instance = require('./instance');
24
+ const { callLlm, resolveModel, loadConfig } = require('./llm');
25
+ const summary = require('./prompts/summary');
26
+ const dailyEntries = require('./daily-entries');
27
+ const workspaceFiles = require('./workspace-files');
28
+ const contextInject = require('./context-inject');
29
+ const mirandaRecallFormat = require('./recall-format');
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // summaryFn / entityParseFn factories — shared by gateway + CC
33
+ // ---------------------------------------------------------------------------
34
+
35
+ function buildSummaryFn({ agentId, now, dailyContext, runtime = 'gateway', logger = console }) {
36
+ return async function summaryFn(_normalized) {
37
+ // _normalized is the cleaned messages from Aquifer; Miranda wants the
38
+ // reconstructed conversation text for the six-section prompt.
39
+ const conversationText = extractConversationText(_normalized);
40
+ if (!conversationText) throw new Error('empty conversation text');
41
+
42
+ const prompt = summary.buildSummaryPrompt({ conversationText, agentId, now, dailyContext });
43
+ if (logger.info) logger.info(`[miranda] calling LLM (${runtime})`);
44
+ const output = await callLlm(prompt, { runtime });
45
+ if (!output) throw new Error('LLM returned empty');
46
+
47
+ const sections = summary.parseSummaryOutput(output);
48
+ const recap = summary.parseRecapLines(sections.recap || '');
49
+ const workingFacts = summary.parseWorkingFacts(sections.working_facts || '');
50
+ if (!recap.title) throw new Error('LLM recap missing title');
51
+
52
+ return {
53
+ summaryText: recap.overview || '',
54
+ structuredSummary: { ...recap, raw_sections: sections },
55
+ entityRaw: sections.entities || null,
56
+ extra: { sections, recap, workingFacts },
57
+ };
58
+ };
59
+ }
60
+
61
+ function buildEntityParseFn() {
62
+ return function entityParseFn(text) {
63
+ const parsed = parseEntitySection(text);
64
+ return parsed.entities; // already has { name, normalizedName, aliases, type }
65
+ };
66
+ }
67
+
68
+ function extractConversationText(normalized) {
69
+ if (!Array.isArray(normalized)) return '';
70
+ return normalized
71
+ .filter(m => m.role === 'user' || m.role === 'assistant')
72
+ .map(m => `[${m.role}] ${typeof m.content === 'string' ? m.content : ''}`)
73
+ .join('\n');
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // buildPostProcess — produce the enrich postProcess hook for Miranda
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /**
81
+ * @param {object} opts
82
+ * @param {object} opts.aquifer — Aquifer instance
83
+ * @param {object} opts.pool — pg.Pool (used for daily-entries DAL)
84
+ * @param {string} opts.agentId
85
+ * @param {string} [opts.workspaceDir] — if set, writes emotional-state.md and recap JSON files
86
+ * @param {string} [opts.source='afterburn']
87
+ * @param {string|null} [opts.tag=null] — daily-entry tag (e.g. '[CLI]' for CC runs)
88
+ * @param {Date} [opts.now]
89
+ * @param {object} [opts.logger]
90
+ * @param {boolean} [opts.consolidate=true]
91
+ */
92
+ function buildPostProcess({
93
+ aquifer, pool, agentId, workspaceDir = null,
94
+ source = 'afterburn', tag = null, now = null,
95
+ logger = console, consolidate = true,
96
+ } = {}) {
97
+ if (!aquifer) throw new Error('buildPostProcess: aquifer is required');
98
+ if (!pool) throw new Error('buildPostProcess: pool is required');
99
+ if (!agentId) throw new Error('buildPostProcess: agentId is required');
100
+
101
+ return async function postProcess(ctx) {
102
+ const _now = now || new Date();
103
+ const recap = ctx.extra?.recap || null;
104
+ const sections = ctx.extra?.sections || null;
105
+ const workingFacts = ctx.extra?.workingFacts || [];
106
+ const sessionId = ctx.session.sessionId;
107
+
108
+ // 1. Workspace files (optional — only if persona has a workspace dir)
109
+ if (workspaceDir && (sections || recap)) {
110
+ try {
111
+ await workspaceFiles.writeWorkspaceFiles(sections || {}, recap, workspaceDir, {
112
+ sessionId,
113
+ agentId,
114
+ conversationText: extractConversationText(ctx.normalized || []),
115
+ }, logger);
116
+ } catch (err) {
117
+ if (logger.warn) logger.warn(`[miranda] workspace files failed: ${err.message}`);
118
+ }
119
+ }
120
+
121
+ // 2. Daily entries
122
+ if (sections || recap) {
123
+ try {
124
+ await dailyEntries.writeDailyEntries({
125
+ sections: sections || {}, recap, pool, sessionId, agentId, logger,
126
+ source, tag, now: _now,
127
+ });
128
+ } catch (err) {
129
+ if (logger.warn) logger.warn(`[miranda] daily entries failed: ${err.message}`);
130
+ }
131
+ }
132
+
133
+ // 3. Fact candidates — write to aquifer.${schema}.facts via consolidate 'create'
134
+ // (We only CREATE here; candidate-lifecycle decisions come from the
135
+ // consolidation step below.)
136
+ if (consolidate && workingFacts.length > 0) {
137
+ try {
138
+ const { normalizeEntityName } = require('../../index');
139
+ const actions = workingFacts.map(f => ({
140
+ action: 'create',
141
+ subject: f.subject,
142
+ statement: f.statement,
143
+ importance: 6,
144
+ }));
145
+ await aquifer.consolidate(sessionId, {
146
+ agentId,
147
+ actions,
148
+ normalizeSubject: normalizeEntityName,
149
+ recapOverview: recap?.overview || '',
150
+ });
151
+ } catch (err) {
152
+ if (logger.warn) logger.warn(`[miranda] fact candidates failed: ${err.message}`);
153
+ }
154
+ }
155
+ };
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Mount helpers — afterburn / context-inject / session_recall tool.
160
+ // Each is independently mountable so different OpenClaw extensions can claim
161
+ // the hooks they care about. mountOnOpenClaw() is the all-in-one convenience.
162
+ // ---------------------------------------------------------------------------
163
+
164
+ function resolveCommon(opts) {
165
+ if (!opts.pool) throw new Error('Miranda: pool is required');
166
+ if (!opts.embedFn) throw new Error('Miranda: embedFn is required');
167
+ return {
168
+ pool: opts.pool,
169
+ embedFn: opts.embedFn,
170
+ defaultAgentId: opts.agentId || 'main',
171
+ workspaceDir: opts.workspaceDir || null,
172
+ minUserMessages: opts.minUserMessages || 3,
173
+ aquifer: instance.getAquifer({
174
+ pool: opts.pool, embedFn: opts.embedFn, llmFn: opts.llmFn, rerankKey: opts.rerankKey,
175
+ }),
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Register Miranda's afterburn hook (before_reset) on the OpenClaw api.
181
+ * Runs commit + enrich with Miranda's summaryFn and postProcess.
182
+ */
183
+ function registerAfterburn(api, opts = {}) {
184
+ const { pool, aquifer, defaultAgentId, workspaceDir, minUserMessages } = resolveCommon(opts);
185
+ const recentlyProcessed = new Map();
186
+ const inFlight = new Set();
187
+
188
+ api.on('before_reset', (event, ctx) => {
189
+ const sessionId = ctx?.sessionId || event?.sessionId;
190
+ const agentId = ctx?.agentId || defaultAgentId;
191
+ const sessionKey = ctx?.sessionKey || null;
192
+
193
+ if (!sessionId) return;
194
+ if ((sessionKey || '').includes('subagent')) return;
195
+ if ((sessionKey || '').includes(':cron:')) return;
196
+
197
+ const rawEntries = Array.isArray(event?.messages) ? event.messages : [];
198
+ if (rawEntries.length < 3) {
199
+ api.logger.info(`[miranda] skip ${sessionId}: only ${rawEntries.length} msgs`);
200
+ return;
201
+ }
202
+
203
+ (async () => {
204
+ try {
205
+ const now = new Date();
206
+ const date = dailyEntries.taipeiDateString(now);
207
+ let dailyContext = '';
208
+ try { dailyContext = await dailyEntries.fetchDailyContext(pool, date, agentId); } catch { /* best-effort */ }
209
+
210
+ await runIngest({
211
+ aquifer,
212
+ sessionId,
213
+ agentId,
214
+ source: 'openclaw',
215
+ sessionKey,
216
+ adapter: 'gateway',
217
+ rawEntries,
218
+ minUserMessages,
219
+ dedupMap: recentlyProcessed,
220
+ inFlight,
221
+ summaryFn: buildSummaryFn({ agentId, now, dailyContext, runtime: 'gateway', logger: api.logger }),
222
+ entityParseFn: buildEntityParseFn(),
223
+ postProcess: buildPostProcess({
224
+ aquifer, pool, agentId, workspaceDir,
225
+ source: 'afterburn', now, logger: api.logger,
226
+ }),
227
+ logger: api.logger,
228
+ });
229
+ } catch (err) {
230
+ api.logger.warn(`[miranda] capture failed ${sessionId}: ${err.message}`);
231
+ }
232
+ })();
233
+ });
234
+
235
+ api.logger.info('[miranda] registerAfterburn: before_reset hooked');
236
+ return { aquifer };
237
+ }
238
+
239
+ /**
240
+ * Register the before_prompt_build Miranda context injection.
241
+ * Separate from afterburn so the Driftwood ext can install it while the
242
+ * afterburn ext owns the before_reset hook.
243
+ */
244
+ function registerContextInject(api, opts = {}) {
245
+ const { pool, aquifer, defaultAgentId } = resolveCommon(opts);
246
+
247
+ api.on('before_prompt_build', async (event, ctx) => {
248
+ try {
249
+ const agentId = ctx?.agentId || defaultAgentId;
250
+ if ((ctx?.sessionKey || '').includes('subagent')) return;
251
+
252
+ const context = await contextInject.computeInjection({
253
+ aquifer, pool, agentId, includeBootstrap: true,
254
+ });
255
+ if (context && context.split('\n').length > 3) {
256
+ api.logger.info(`[miranda] injecting context: ${context.length} chars, agent=${agentId}`);
257
+ return { prependSystemContext: context };
258
+ }
259
+ } catch (err) {
260
+ api.logger.warn(`[miranda] context injection failed: ${err.message}`);
261
+ }
262
+ });
263
+
264
+ api.logger.info('[miranda] registerContextInject: before_prompt_build hooked');
265
+ return { aquifer };
266
+ }
267
+
268
+ /**
269
+ * Register the zh-TW session_recall tool.
270
+ */
271
+ function registerRecallTool(api, opts = {}) {
272
+ const { aquifer } = resolveCommon(opts);
273
+
274
+ api.registerTool((ctx) => {
275
+ if ((ctx?.sessionKey || '').includes('subagent')) return null;
276
+ return {
277
+ name: 'session_recall',
278
+ description: '搜尋歷史 session 的摘要和對話記錄。可按關鍵字、日期範圍、agent 搜尋。',
279
+ parameters: {
280
+ type: 'object',
281
+ properties: {
282
+ query: { type: 'string', description: '搜尋關鍵字(可空,空時按時間排序)' },
283
+ date_from: { type: 'string' }, date_to: { type: 'string' },
284
+ agent_id: { type: 'string' }, source: { type: 'string' },
285
+ detail: { type: 'string' },
286
+ limit: { type: 'number' },
287
+ },
288
+ },
289
+ async execute(_toolCallId, params) {
290
+ try {
291
+ const limit = Math.max(1, Math.min(20, parseInt(params?.limit ?? 5, 10) || 5));
292
+ const results = await aquifer.recall(String(params?.query || ''), {
293
+ agentId: params?.agent_id || ctx?.agentId || undefined,
294
+ source: params?.source || undefined,
295
+ dateFrom: params?.date_from || undefined,
296
+ dateTo: params?.date_to || undefined,
297
+ limit,
298
+ });
299
+ const text = mirandaRecallFormat.formatRecallResults(results.map(r => ({
300
+ sessionId: r.sessionId, agentId: r.agentId, source: r.source,
301
+ startedAt: r.startedAt, summaryText: r.summaryText,
302
+ structuredSummary: r.structuredSummary,
303
+ matchedTurnText: r.matchedTurnText,
304
+ })));
305
+ return { content: [{ type: 'text', text }] };
306
+ } catch (err) {
307
+ return { content: [{ type: 'text', text: `session_recall 錯誤:${err.message}` }], isError: true };
308
+ }
309
+ },
310
+ };
311
+ }, { name: 'session_recall' });
312
+
313
+ api.logger.info('[miranda] registerRecallTool: session_recall tool registered');
314
+ return { aquifer };
315
+ }
316
+
317
+ /**
318
+ * Convenience: register all three on the same api. Equivalent to calling
319
+ * registerAfterburn + registerContextInject + registerRecallTool.
320
+ */
321
+ function mountOnOpenClaw(api, opts = {}) {
322
+ const r1 = registerAfterburn(api, opts);
323
+ registerContextInject(api, opts);
324
+ registerRecallTool(api, opts);
325
+ return r1;
326
+ }
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // Exports
330
+ // ---------------------------------------------------------------------------
331
+
332
+ module.exports = {
333
+ // Persona entry points
334
+ mountOnOpenClaw,
335
+ registerAfterburn,
336
+ registerContextInject,
337
+ registerRecallTool,
338
+ buildPostProcess,
339
+ buildSummaryFn,
340
+ buildEntityParseFn,
341
+
342
+ // Individual modules for advanced wiring
343
+ instance,
344
+ llm: { callLlm, resolveModel, loadConfig },
345
+ summary,
346
+ dailyEntries,
347
+ workspaceFiles,
348
+ contextInject,
349
+ recallFormat: mirandaRecallFormat,
350
+
351
+ // Helpers re-exported for convenience
352
+ extractConversationText,
353
+ };
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ // Miranda Aquifer instance factory — produces a singleton bound to the
4
+ // miranda schema + entity scope + rerank config. Host supplies the pg.Pool
5
+ // and embed function (they're host-specific wiring: OpenClaw has its own
6
+ // pg + embed libs; CC uses the same).
7
+
8
+ const { createAquifer } = require('../../index');
9
+ const { callLlm } = require('./llm');
10
+
11
+ let _instance = null;
12
+
13
+ /**
14
+ * @param {object} opts
15
+ * @param {object} opts.pool — pg.Pool from the host
16
+ * @param {function} opts.embedFn — async (texts: string[]) => number[][]
17
+ * @param {function} [opts.llmFn] — defaults to Miranda's MiniMax wrapper
18
+ * @param {string} [opts.rerankKey] — OpenRouter API key; falls back to
19
+ * process.env.OPENROUTER_API_KEY / AQUIFER_RERANK_API_KEY
20
+ * @returns {object} Aquifer instance
21
+ */
22
+ function getAquifer(opts = {}) {
23
+ if (_instance) return _instance;
24
+ if (!opts.pool) throw new Error('Miranda: pool is required');
25
+ if (!opts.embedFn) throw new Error('Miranda: embedFn is required');
26
+
27
+ const rerankKey = opts.rerankKey
28
+ || process.env.OPENROUTER_API_KEY
29
+ || process.env.AQUIFER_RERANK_API_KEY;
30
+
31
+ _instance = createAquifer({
32
+ schema: 'miranda',
33
+ db: opts.pool,
34
+ tenantId: 'default',
35
+ embed: { fn: opts.embedFn },
36
+ llm: { fn: opts.llmFn || callLlm },
37
+ entities: { enabled: true, mergeCall: true, scope: 'miranda' },
38
+ facts: { enabled: true },
39
+ rerank: rerankKey ? {
40
+ provider: 'openrouter',
41
+ openrouterApiKey: rerankKey,
42
+ model: 'cohere/rerank-v3.5',
43
+ topK: 20,
44
+ maxChars: 1600,
45
+ timeout: 5000,
46
+ maxRetries: 1,
47
+ } : null,
48
+ });
49
+
50
+ return _instance;
51
+ }
52
+
53
+ function resetAquifer() { _instance = null; }
54
+
55
+ module.exports = { getAquifer, resetAquifer };