@shadowforge0/aquifer-memory 1.0.3 → 1.3.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.
- package/README.md +37 -29
- package/consumers/claude-code.js +117 -0
- package/consumers/cli.js +28 -1
- package/consumers/default/daily-entries.js +196 -0
- package/consumers/default/index.js +282 -0
- package/consumers/default/prompts/summary.js +153 -0
- package/consumers/mcp.js +3 -23
- package/consumers/miranda/context-inject.js +119 -0
- package/consumers/miranda/daily-entries.js +224 -0
- package/consumers/miranda/index.js +353 -0
- package/consumers/miranda/instance.js +55 -0
- package/consumers/miranda/llm.js +99 -0
- package/consumers/miranda/profile.json +145 -0
- package/consumers/miranda/prompts/summary.js +303 -0
- package/consumers/miranda/recall-format.js +74 -0
- package/consumers/miranda/render-daily-md.js +186 -0
- package/consumers/miranda/workspace-files.js +91 -0
- package/consumers/openclaw-ext/index.js +38 -0
- package/consumers/openclaw-ext/openclaw.plugin.json +9 -0
- package/consumers/openclaw-ext/package.json +10 -0
- package/consumers/openclaw-plugin.js +66 -74
- package/consumers/opencode.js +21 -24
- package/consumers/shared/autodetect.js +64 -0
- package/consumers/shared/entity-parser.js +119 -0
- package/consumers/shared/ingest.js +148 -0
- package/consumers/shared/llm-autodetect.js +137 -0
- package/consumers/shared/normalize.js +129 -0
- package/consumers/shared/recall-format.js +110 -0
- package/core/aquifer.js +209 -71
- package/core/artifacts.js +174 -0
- package/core/bundles.js +400 -0
- package/core/consolidation.js +340 -0
- package/core/decisions.js +164 -0
- package/core/entity.js +1 -3
- package/core/errors.js +97 -0
- package/core/handoff.js +153 -0
- package/core/mcp-manifest.js +131 -0
- package/core/narratives.js +212 -0
- package/core/profiles.js +171 -0
- package/core/state.js +163 -0
- package/core/storage.js +86 -28
- package/core/timeline.js +152 -0
- package/docs/postprocess-contract.md +132 -0
- package/index.js +23 -1
- package/package.json +23 -2
- package/pipeline/_http.js +1 -1
- package/pipeline/consolidation/apply.js +176 -0
- package/pipeline/consolidation/index.js +21 -0
- package/pipeline/extract-entities.js +2 -2
- package/pipeline/rerank.js +1 -1
- package/pipeline/summarize.js +4 -1
- package/schema/001-base.sql +61 -24
- package/schema/002-entities.sql +17 -3
- package/schema/004-completion.sql +375 -0
- package/schema/004-facts.sql +67 -0
- package/scripts/diagnose-fts-zh.js +168 -134
- package/scripts/diagnose-vector.js +188 -0
- package/scripts/install-openclaw.sh +59 -0
- 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 };
|