@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,186 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Miranda daily log renderer — reference implementation for the artifact
|
|
4
|
+
// capability (spec §12).
|
|
5
|
+
//
|
|
6
|
+
// Pulls the canonical state for a date from Aquifer (timeline events + latest
|
|
7
|
+
// state + active narrative + latest handoff) and renders it into a single
|
|
8
|
+
// markdown file. Pure logic — does not write to disk; returns the rendered
|
|
9
|
+
// string plus an artifact record declaration the caller can persist via
|
|
10
|
+
// aq.artifacts.record().
|
|
11
|
+
//
|
|
12
|
+
// Shape deliberately mirrors the historical Miranda daily-log format so the
|
|
13
|
+
// downstream consumers (CC, Discord pushes, weekly rollup) see no regression
|
|
14
|
+
// during the cutover from render-daily-log.js to this reference impl.
|
|
15
|
+
|
|
16
|
+
const crypto = require('crypto');
|
|
17
|
+
|
|
18
|
+
function startOfDayIso(dateStr) {
|
|
19
|
+
return `${dateStr}T00:00:00Z`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function endOfDayIso(dateStr) {
|
|
23
|
+
return `${dateStr}T23:59:59.999Z`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function ensureDate(input) {
|
|
27
|
+
if (!input) throw new Error('date (YYYY-MM-DD) is required');
|
|
28
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(input)) {
|
|
29
|
+
throw new Error(`date must match YYYY-MM-DD, got: ${input}`);
|
|
30
|
+
}
|
|
31
|
+
return input;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function renderSection(title, lines) {
|
|
35
|
+
if (!lines || lines.length === 0) return null;
|
|
36
|
+
return `## ${title}\n\n${lines.join('\n')}\n`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function formatTimelineLine(evt) {
|
|
40
|
+
const ts = new Date(evt.occurredAt).toISOString().slice(11, 16);
|
|
41
|
+
const src = evt.sessionRef ? ` (${evt.sessionRef})` : '';
|
|
42
|
+
return `- \`${ts}\`${src} ${evt.text}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function formatHandoff(payload) {
|
|
46
|
+
if (!payload) return null;
|
|
47
|
+
const lines = [];
|
|
48
|
+
if (payload.last_step) lines.push(`**Last step.** ${payload.last_step}`);
|
|
49
|
+
if (payload.status) lines.push(`**Status.** ${payload.status}`);
|
|
50
|
+
if (payload.next) lines.push(`**Next.** ${payload.next}`);
|
|
51
|
+
if (Array.isArray(payload.blockers) && payload.blockers.length > 0) {
|
|
52
|
+
lines.push(`**Blockers.**`);
|
|
53
|
+
for (const b of payload.blockers) lines.push(`- ${b}`);
|
|
54
|
+
}
|
|
55
|
+
if (Array.isArray(payload.open_loops) && payload.open_loops.length > 0) {
|
|
56
|
+
lines.push(`**Open loops.**`);
|
|
57
|
+
for (const l of payload.open_loops) lines.push(`- ${l}`);
|
|
58
|
+
}
|
|
59
|
+
return lines.length > 0 ? lines.join('\n') + '\n' : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function formatState(state) {
|
|
63
|
+
if (!state) return null;
|
|
64
|
+
const lines = [];
|
|
65
|
+
if (state.goal) lines.push(`**Goal.** ${state.goal}`);
|
|
66
|
+
if (Array.isArray(state.active_work) && state.active_work.length > 0) {
|
|
67
|
+
lines.push(`**Active work.**`);
|
|
68
|
+
for (const w of state.active_work) lines.push(`- ${w}`);
|
|
69
|
+
}
|
|
70
|
+
if (state.affect && typeof state.affect === 'object') {
|
|
71
|
+
const bits = [];
|
|
72
|
+
if (state.affect.mood) bits.push(`mood: ${state.affect.mood}`);
|
|
73
|
+
if (state.affect.energy) bits.push(`energy: ${state.affect.energy}`);
|
|
74
|
+
if (state.affect.confidence) bits.push(`confidence: ${state.affect.confidence}`);
|
|
75
|
+
if (bits.length > 0) lines.push(`**Affect.** ${bits.join(', ')}`);
|
|
76
|
+
}
|
|
77
|
+
return lines.length > 0 ? lines.join('\n') + '\n' : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// -------------------------------------------------------------------------
|
|
81
|
+
// Public entry
|
|
82
|
+
// -------------------------------------------------------------------------
|
|
83
|
+
//
|
|
84
|
+
// renderDailyMd({ aquifer, date, agentId, tenantId?, categories? }) returns:
|
|
85
|
+
// {
|
|
86
|
+
// markdown: string,
|
|
87
|
+
// artifact: { producerId, type, format, destination, payload,
|
|
88
|
+
// idempotencyKey } // ready for aq.artifacts.record()
|
|
89
|
+
// }
|
|
90
|
+
//
|
|
91
|
+
// The caller decides whether to persist the artifact record and where the
|
|
92
|
+
// rendered file lands. Aquifer itself doesn't touch disk.
|
|
93
|
+
|
|
94
|
+
async function renderDailyMd({
|
|
95
|
+
aquifer, date, agentId, tenantId, categories,
|
|
96
|
+
destinationTemplate = 'workspace://memory/{date}.md',
|
|
97
|
+
producerId = 'miranda.workspace.daily-log',
|
|
98
|
+
}) {
|
|
99
|
+
if (!aquifer) throw new Error('aquifer instance is required');
|
|
100
|
+
if (!agentId) throw new Error('agentId is required');
|
|
101
|
+
const day = ensureDate(date);
|
|
102
|
+
|
|
103
|
+
const since = startOfDayIso(day);
|
|
104
|
+
const until = endOfDayIso(day);
|
|
105
|
+
|
|
106
|
+
const timelineResult = await aquifer.timeline.list({
|
|
107
|
+
tenantId, agentId, categories, since, until, limit: 500,
|
|
108
|
+
});
|
|
109
|
+
if (!timelineResult.ok) throw new Error(`timeline.list failed: ${timelineResult.error.message}`);
|
|
110
|
+
|
|
111
|
+
const stateResult = await aquifer.state.getLatest({ tenantId, agentId });
|
|
112
|
+
if (!stateResult.ok) throw new Error(`state.getLatest failed: ${stateResult.error.message}`);
|
|
113
|
+
|
|
114
|
+
const handoffResult = await aquifer.handoff.getLatest({ tenantId, agentId });
|
|
115
|
+
if (!handoffResult.ok) throw new Error(`handoff.getLatest failed: ${handoffResult.error.message}`);
|
|
116
|
+
|
|
117
|
+
const narrativeResult = await aquifer.narratives.getLatest({ tenantId, agentId });
|
|
118
|
+
if (!narrativeResult.ok) throw new Error(`narratives.getLatest failed: ${narrativeResult.error.message}`);
|
|
119
|
+
|
|
120
|
+
const events = (timelineResult.data.rows || []).slice().sort((a, b) =>
|
|
121
|
+
new Date(a.occurredAt).getTime() - new Date(b.occurredAt).getTime());
|
|
122
|
+
|
|
123
|
+
// Group timeline by category.
|
|
124
|
+
const grouped = new Map();
|
|
125
|
+
for (const evt of events) {
|
|
126
|
+
if (!grouped.has(evt.category)) grouped.set(evt.category, []);
|
|
127
|
+
grouped.get(evt.category).push(evt);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const sections = [];
|
|
131
|
+
sections.push(`# ${day}\n`);
|
|
132
|
+
const stateBlock = formatState(stateResult.data.state);
|
|
133
|
+
if (stateBlock) sections.push(`## State\n\n${stateBlock}`);
|
|
134
|
+
|
|
135
|
+
if (narrativeResult.data.narrative) {
|
|
136
|
+
sections.push(`## Narrative\n\n${narrativeResult.data.narrative.text}\n`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const categoryOrder = ['focus', 'todo', 'mood', 'handoff', 'narrative',
|
|
140
|
+
'organized', 'note', 'stats', 'health', 'garmin', 'weekly', 'monthly', 'cli'];
|
|
141
|
+
const emitted = new Set();
|
|
142
|
+
for (const cat of categoryOrder) {
|
|
143
|
+
if (!grouped.has(cat)) continue;
|
|
144
|
+
const lines = grouped.get(cat).map(formatTimelineLine);
|
|
145
|
+
const sec = renderSection(cat.charAt(0).toUpperCase() + cat.slice(1), lines);
|
|
146
|
+
if (sec) sections.push(sec);
|
|
147
|
+
emitted.add(cat);
|
|
148
|
+
}
|
|
149
|
+
for (const [cat, evts] of grouped) {
|
|
150
|
+
if (emitted.has(cat)) continue;
|
|
151
|
+
const lines = evts.map(formatTimelineLine);
|
|
152
|
+
const sec = renderSection(cat, lines);
|
|
153
|
+
if (sec) sections.push(sec);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const handoffBlock = formatHandoff(handoffResult.data.handoff);
|
|
157
|
+
if (handoffBlock) sections.push(`## Handoff\n\n${handoffBlock}`);
|
|
158
|
+
|
|
159
|
+
const markdown = sections.join('\n').replace(/\n{3,}/g, '\n\n').trim() + '\n';
|
|
160
|
+
|
|
161
|
+
const destination = destinationTemplate.replace('{date}', day);
|
|
162
|
+
const idempotencyKey = crypto.createHash('sha256')
|
|
163
|
+
.update(`miranda:daily:${tenantId || 'default'}:${agentId}:${day}`)
|
|
164
|
+
.digest('hex');
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
markdown,
|
|
168
|
+
artifact: {
|
|
169
|
+
producerId,
|
|
170
|
+
type: 'daily-log',
|
|
171
|
+
format: 'markdown',
|
|
172
|
+
destination,
|
|
173
|
+
triggerPhase: 'artifact_dispatch',
|
|
174
|
+
payload: {
|
|
175
|
+
date: day,
|
|
176
|
+
event_count: events.length,
|
|
177
|
+
has_state: !!stateResult.data.state,
|
|
178
|
+
has_handoff: !!handoffResult.data.handoff,
|
|
179
|
+
has_narrative: !!narrativeResult.data.narrative,
|
|
180
|
+
},
|
|
181
|
+
idempotencyKey,
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = { renderDailyMd };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
// Host-owned file paths: emotional state + recap JSON + learned skills.
|
|
7
|
+
// These are Miranda persona artifacts; other consumers won't want them.
|
|
8
|
+
|
|
9
|
+
function extractFilePaths(conversationText) {
|
|
10
|
+
const re = /(?:^|\s|["'`])((?:\/[\w.@-]+)+(?:\/[\w.@-]+)*\.[\w]+)/g;
|
|
11
|
+
const paths = new Set();
|
|
12
|
+
let m;
|
|
13
|
+
while ((m = re.exec(conversationText || '')) !== null) {
|
|
14
|
+
const p = m[1];
|
|
15
|
+
if (p.startsWith('/bin/') || p.startsWith('/usr/') || p.startsWith('/etc/')) continue;
|
|
16
|
+
if (p.includes('node_modules/')) continue;
|
|
17
|
+
paths.add(p);
|
|
18
|
+
}
|
|
19
|
+
return [...paths].slice(0, 30);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function writeWorkspaceFiles(sections, recap, workspaceDir, context, logger = console) {
|
|
23
|
+
let emotionalStateWritten = false;
|
|
24
|
+
const recapFilesWritten = [];
|
|
25
|
+
let learnedSkillsWritten = 0;
|
|
26
|
+
|
|
27
|
+
if (sections?.emotional_state) {
|
|
28
|
+
await fs.promises.mkdir(path.join(workspaceDir, 'memory'), { recursive: true });
|
|
29
|
+
await fs.promises.writeFile(
|
|
30
|
+
path.join(workspaceDir, 'memory', 'emotional-state.md'),
|
|
31
|
+
sections.emotional_state, 'utf8',
|
|
32
|
+
);
|
|
33
|
+
emotionalStateWritten = true;
|
|
34
|
+
if (logger.info) logger.info('[miranda] wrote emotional state');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (recap?.title) {
|
|
38
|
+
const sessDir = path.join(workspaceDir, 'memory', 'sessions');
|
|
39
|
+
await fs.promises.mkdir(sessDir, { recursive: true });
|
|
40
|
+
if (context?.conversationText) recap.files_mentioned = extractFilePaths(context.conversationText);
|
|
41
|
+
|
|
42
|
+
await fs.promises.writeFile(
|
|
43
|
+
path.join(sessDir, 'afterburn-latest.json'),
|
|
44
|
+
JSON.stringify(recap, null, 2), 'utf8',
|
|
45
|
+
);
|
|
46
|
+
recapFilesWritten.push('afterburn-latest.json');
|
|
47
|
+
|
|
48
|
+
const safeId = String(context?.sessionId || '').replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
49
|
+
if (safeId) {
|
|
50
|
+
const fname = `afterburn-${safeId}.json`;
|
|
51
|
+
await fs.promises.writeFile(path.join(sessDir, fname), JSON.stringify(recap, null, 2), 'utf8');
|
|
52
|
+
recapFilesWritten.push(fname);
|
|
53
|
+
}
|
|
54
|
+
if (logger.info) logger.info(`[miranda] wrote recap (title=${recap.title.slice(0, 40)})`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (Array.isArray(recap?.reusable_patterns) && recap.reusable_patterns.length > 0) {
|
|
58
|
+
try {
|
|
59
|
+
const skillsPath = path.join(workspaceDir, 'memory', 'topics', 'learned-skills.md');
|
|
60
|
+
let existing = '';
|
|
61
|
+
try { existing = await fs.promises.readFile(skillsPath, 'utf8'); } catch { /* first write */ }
|
|
62
|
+
if (!existing) {
|
|
63
|
+
existing = '# Learned Skills\n\n> 自動從 session 中抽取的可重用操作模式。\n> invariant = 永久規則。derived = 情境洞察(可能過期)。\n\n';
|
|
64
|
+
}
|
|
65
|
+
const now = new Intl.DateTimeFormat('sv-SE', {
|
|
66
|
+
timeZone: 'Asia/Taipei',
|
|
67
|
+
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
68
|
+
}).format(new Date());
|
|
69
|
+
const patterns = recap.reusable_patterns.filter(p => p.pattern && p.trigger);
|
|
70
|
+
const lines = [];
|
|
71
|
+
for (const p of patterns) {
|
|
72
|
+
const icon = p.durability === 'invariant' ? '🔒' : '📌';
|
|
73
|
+
const suffix = p.durability !== 'invariant' ? '(expires ~14d)' : '';
|
|
74
|
+
lines.push(`- ${icon} **${p.pattern}** — 觸發:${p.trigger};做法:${p.action || '(見 session)'}${suffix}`);
|
|
75
|
+
}
|
|
76
|
+
if (lines.length > 0) {
|
|
77
|
+
await fs.promises.mkdir(path.dirname(skillsPath), { recursive: true });
|
|
78
|
+
const section = `\n### ${now} (${String(context?.sessionId || '').slice(0, 8)})\n${lines.join('\n')}\n`;
|
|
79
|
+
await fs.promises.writeFile(skillsPath, existing + section, 'utf8');
|
|
80
|
+
learnedSkillsWritten = patterns.length;
|
|
81
|
+
if (logger.info) logger.info(`[miranda] wrote ${patterns.length} skill(s)`);
|
|
82
|
+
}
|
|
83
|
+
} catch (err) {
|
|
84
|
+
if (logger.info) logger.info(`[miranda] skill write skip: ${err.message}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { emotionalStateWritten, recapFilesWritten, learnedSkillsWritten };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { writeWorkspaceFiles, extractFilePaths };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Aquifer Memory — drop-in OpenClaw extension
|
|
4
|
+
//
|
|
5
|
+
// Host layout:
|
|
6
|
+
// $OPENCLAW_HOME/extensions/aquifer-memory/ ← symlink to this directory
|
|
7
|
+
// (or run `bash scripts/install-openclaw.sh $OPENCLAW_HOME` from the tarball)
|
|
8
|
+
//
|
|
9
|
+
// Behavior:
|
|
10
|
+
// - Loads $OPENCLAW_HOME/.env so DATABASE_URL / EMBED_PROVIDER /
|
|
11
|
+
// AQUIFER_LLM_PROVIDER etc. are visible to the plugin.
|
|
12
|
+
// - Delegates to consumers/openclaw-plugin.js. If AQUIFER_PERSONA is set
|
|
13
|
+
// (pluginConfig.persona or env), the plugin loads the persona module
|
|
14
|
+
// and hands off mountOnOpenClaw(api); otherwise the default generic
|
|
15
|
+
// path runs (before_reset capture + session_recall + session_feedback).
|
|
16
|
+
//
|
|
17
|
+
// Host-specific customization goes in a persona module, not here.
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const os = require('os');
|
|
22
|
+
|
|
23
|
+
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(os.homedir(), '.openclaw');
|
|
24
|
+
|
|
25
|
+
function loadEnvFile(envPath) {
|
|
26
|
+
try {
|
|
27
|
+
const text = fs.readFileSync(envPath, 'utf8');
|
|
28
|
+
for (const line of text.split('\n')) {
|
|
29
|
+
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
|
30
|
+
if (m && !process.env[m[1]]) process.env[m[1]] = m[2].trim();
|
|
31
|
+
}
|
|
32
|
+
} catch { /* .env missing — ok */ }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
loadEnvFile(path.join(OPENCLAW_HOME, '.env'));
|
|
36
|
+
|
|
37
|
+
// Re-export the plugin as-is. OpenClaw expects { id, name, register }.
|
|
38
|
+
module.exports = require('../openclaw-plugin');
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "aquifer-memory",
|
|
3
|
+
"name": "Aquifer Memory",
|
|
4
|
+
"version": "1.2.0",
|
|
5
|
+
"description": "Session ingest + recall + feedback. Reads DATABASE_URL / EMBED_PROVIDER / AQUIFER_LLM_PROVIDER from host env; delegates to AQUIFER_PERSONA module if set.",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"hooks": ["before_reset"],
|
|
8
|
+
"configSchema": {}
|
|
9
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "aquifer-openclaw-ext",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"description": "Drop-in OpenClaw extension for Aquifer Memory. Symlink into $OPENCLAW_HOME/extensions/aquifer-memory/ — no host-side boilerplate required.",
|
|
7
|
+
"openclaw": {
|
|
8
|
+
"plugin": "./openclaw.plugin.json"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
const { createAquiferFromConfig } = require('./shared/factory');
|
|
19
|
+
const { runIngest } = require('./shared/ingest');
|
|
20
|
+
const { formatRecallResults: sharedFormatRecallResults } = require('./shared/recall-format');
|
|
19
21
|
|
|
20
22
|
// ---------------------------------------------------------------------------
|
|
21
23
|
// Helpers
|
|
@@ -38,6 +40,7 @@ function normalizeEntries(rawEntries) {
|
|
|
38
40
|
let startedAt = null, lastMessageAt = null;
|
|
39
41
|
|
|
40
42
|
for (const entry of rawEntries) {
|
|
43
|
+
if (!entry) continue;
|
|
41
44
|
const msg = entry.message || entry;
|
|
42
45
|
if (!msg || !msg.role) continue;
|
|
43
46
|
if (!['user', 'assistant', 'system'].includes(msg.role)) continue;
|
|
@@ -79,43 +82,63 @@ function normalizeEntries(rawEntries) {
|
|
|
79
82
|
};
|
|
80
83
|
}
|
|
81
84
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
if (r.matchedTurnText) {
|
|
101
|
-
lines.push(`Matched: ${r.matchedTurnText.slice(0, 200)}`);
|
|
102
|
-
}
|
|
103
|
-
return lines.join('\n');
|
|
104
|
-
}).join('\n\n');
|
|
105
|
-
}
|
|
85
|
+
// Thin adapter over the shared formatter. OpenClaw's tool output historically
|
|
86
|
+
// used "Matched:" instead of "Matched turn:" and joined with blank lines, so
|
|
87
|
+
// we supply a pair of renderer overrides to preserve that shape.
|
|
88
|
+
const formatRecallResults = (function () {
|
|
89
|
+
const { createRecallFormatter } = require('./shared/recall-format');
|
|
90
|
+
const _fmt = createRecallFormatter({
|
|
91
|
+
header: () => null,
|
|
92
|
+
matched: (r) => r.matchedTurnText ? `Matched: ${String(r.matchedTurnText).slice(0, 200)}` : null,
|
|
93
|
+
separator: () => '',
|
|
94
|
+
});
|
|
95
|
+
return (results) => {
|
|
96
|
+
if (!results || results.length === 0) return 'No matching sessions found.';
|
|
97
|
+
return _fmt(results);
|
|
98
|
+
};
|
|
99
|
+
})();
|
|
100
|
+
// Re-export the shared formatter too for callers that want the default shape.
|
|
101
|
+
formatRecallResults.shared = sharedFormatRecallResults;
|
|
106
102
|
|
|
107
103
|
// ---------------------------------------------------------------------------
|
|
108
104
|
// Plugin
|
|
109
105
|
// ---------------------------------------------------------------------------
|
|
110
106
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
107
|
+
function buildPlugin() {
|
|
108
|
+
return {
|
|
109
|
+
id: 'aquifer-memory',
|
|
110
|
+
name: 'Aquifer Memory',
|
|
111
|
+
register,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = buildPlugin();
|
|
116
|
+
// Expose helpers for unit testing. Not part of the plugin's OpenClaw-visible
|
|
117
|
+
// contract; OpenClaw reads { id, name, register } only.
|
|
118
|
+
module.exports.normalizeEntries = normalizeEntries;
|
|
119
|
+
module.exports.coerceRawEntries = coerceRawEntries;
|
|
114
120
|
|
|
115
|
-
|
|
121
|
+
function register(api) {
|
|
116
122
|
const pluginConfig = api.pluginConfig || {};
|
|
117
|
-
let aquifer;
|
|
118
123
|
|
|
124
|
+
// v1.2.0: delegate to a persona layer if one is configured, otherwise
|
|
125
|
+
// run the generic default path (before_reset + session_recall + feedback).
|
|
126
|
+
const personaPath = pluginConfig.persona || process.env.AQUIFER_PERSONA;
|
|
127
|
+
if (personaPath) {
|
|
128
|
+
try {
|
|
129
|
+
const persona = require(personaPath);
|
|
130
|
+
if (persona && typeof persona.mountOnOpenClaw === 'function') {
|
|
131
|
+
persona.mountOnOpenClaw(api, pluginConfig);
|
|
132
|
+
api.logger.info(`[aquifer-memory] registered via persona: ${personaPath}`);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
api.logger.warn(`[aquifer-memory] persona at ${personaPath} lacks mountOnOpenClaw; falling back to default`);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
api.logger.warn(`[aquifer-memory] failed to load persona ${personaPath}: ${err.message}; falling back to default`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let aquifer;
|
|
119
142
|
try {
|
|
120
143
|
aquifer = createAquiferFromConfig(pluginConfig);
|
|
121
144
|
} catch (err) {
|
|
@@ -138,65 +161,35 @@ module.exports = {
|
|
|
138
161
|
if ((sessionKey || '').includes('subagent')) return;
|
|
139
162
|
if ((sessionKey || '').includes(':cron:')) return;
|
|
140
163
|
|
|
141
|
-
const dedupKey = `${agentId}:${sessionId}`;
|
|
142
|
-
if (recentlyProcessed.has(dedupKey) || inFlight.has(dedupKey)) return;
|
|
143
|
-
|
|
144
164
|
const rawEntries = coerceRawEntries(event?.messages || []);
|
|
145
165
|
if (rawEntries.length < 3) {
|
|
146
166
|
api.logger.info(`[aquifer-memory] skip: ${sessionId} only ${rawEntries.length} msgs`);
|
|
147
167
|
return;
|
|
148
168
|
}
|
|
149
169
|
|
|
150
|
-
inFlight.add(dedupKey);
|
|
151
170
|
api.logger.info(`[aquifer-memory] capturing ${sessionId} (${rawEntries.length} entries)`);
|
|
152
171
|
|
|
153
172
|
(async () => {
|
|
154
173
|
try {
|
|
174
|
+
// OpenClaw hands us flat {role, content} entries; normalizeEntries
|
|
175
|
+
// produces the commit-ready shape, which we feed to shared runIngest
|
|
176
|
+
// as 'preNormalized' so commit+enrich+dedup stays host-agnostic.
|
|
155
177
|
const norm = normalizeEntries(rawEntries);
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Commit
|
|
162
|
-
await aquifer.commit(sessionId, norm.messages, {
|
|
178
|
+
await runIngest({
|
|
179
|
+
aquifer,
|
|
180
|
+
sessionId,
|
|
163
181
|
agentId,
|
|
164
182
|
source: 'openclaw',
|
|
165
183
|
sessionKey,
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
184
|
+
adapter: 'preNormalized',
|
|
185
|
+
preNormalized: norm,
|
|
186
|
+
minUserMessages,
|
|
187
|
+
dedupMap: recentlyProcessed,
|
|
188
|
+
inFlight,
|
|
189
|
+
logger: api.logger,
|
|
171
190
|
});
|
|
172
|
-
api.logger.info(`[aquifer-memory] committed ${sessionId}`);
|
|
173
|
-
|
|
174
|
-
// Enrich (if enough messages)
|
|
175
|
-
if (norm.userCount >= minUserMessages) {
|
|
176
|
-
try {
|
|
177
|
-
const result = await aquifer.enrich(sessionId, { agentId });
|
|
178
|
-
api.logger.info(`[aquifer-memory] enriched ${sessionId} (${result.turnsEmbedded} turns, ${result.entitiesFound} entities)`);
|
|
179
|
-
} catch (enrichErr) {
|
|
180
|
-
api.logger.warn(`[aquifer-memory] enrich failed for ${sessionId}: ${enrichErr.message}`);
|
|
181
|
-
}
|
|
182
|
-
} else {
|
|
183
|
-
try {
|
|
184
|
-
await aquifer.skip(sessionId, { agentId, reason: `user_count=${norm.userCount} < min=${minUserMessages}` });
|
|
185
|
-
} catch (e) { api.logger.warn(`[aquifer-memory] skip failed for ${sessionId}: ${e.message}`); }
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
recentlyProcessed.set(dedupKey, Date.now());
|
|
189
191
|
} catch (err) {
|
|
190
192
|
api.logger.warn(`[aquifer-memory] capture failed for ${sessionId}: ${err.message}`);
|
|
191
|
-
} finally {
|
|
192
|
-
inFlight.delete(dedupKey);
|
|
193
|
-
// Evict old entries
|
|
194
|
-
if (recentlyProcessed.size > 200) {
|
|
195
|
-
const cutoff = Date.now() - 30 * 60 * 1000;
|
|
196
|
-
for (const [k, ts] of recentlyProcessed) {
|
|
197
|
-
if (ts < cutoff) recentlyProcessed.delete(k);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
193
|
}
|
|
201
194
|
})();
|
|
202
195
|
});
|
|
@@ -292,6 +285,5 @@ module.exports = {
|
|
|
292
285
|
};
|
|
293
286
|
}, { name: 'session_feedback' });
|
|
294
287
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
};
|
|
288
|
+
api.logger.info('[aquifer-memory] registered (before_reset + session_recall + session_feedback)');
|
|
289
|
+
}
|
package/consumers/opencode.js
CHANGED
|
@@ -26,8 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
const path = require('path');
|
|
28
28
|
const os = require('os');
|
|
29
|
-
const {
|
|
30
|
-
|
|
29
|
+
const { runIngest } = require('./shared/ingest');
|
|
31
30
|
// ---------------------------------------------------------------------------
|
|
32
31
|
// SQLite access — use Node 22+ built-in or fall back to better-sqlite3
|
|
33
32
|
// ---------------------------------------------------------------------------
|
|
@@ -37,7 +36,7 @@ function openSqlite(dbPath) {
|
|
|
37
36
|
try {
|
|
38
37
|
const { DatabaseSync } = require('node:sqlite');
|
|
39
38
|
return new DatabaseSync(dbPath, { open: true, readOnly: true });
|
|
40
|
-
} catch
|
|
39
|
+
} catch {
|
|
41
40
|
// not available
|
|
42
41
|
}
|
|
43
42
|
|
|
@@ -45,7 +44,7 @@ function openSqlite(dbPath) {
|
|
|
45
44
|
try {
|
|
46
45
|
const Database = require('better-sqlite3');
|
|
47
46
|
return new Database(dbPath, { readonly: true });
|
|
48
|
-
} catch
|
|
47
|
+
} catch {
|
|
49
48
|
// not available
|
|
50
49
|
}
|
|
51
50
|
|
|
@@ -220,7 +219,7 @@ async function ingestOpenCode(aquifer, args) {
|
|
|
220
219
|
try {
|
|
221
220
|
const existing = await aquifer.exportSessions({ source: 'opencode', limit: 10000 });
|
|
222
221
|
for (const row of existing) existingSet.add(row.session_id);
|
|
223
|
-
} catch
|
|
222
|
+
} catch {
|
|
224
223
|
// exportSessions may not exist in all versions
|
|
225
224
|
}
|
|
226
225
|
|
|
@@ -271,36 +270,34 @@ async function ingestOpenCode(aquifer, args) {
|
|
|
271
270
|
continue;
|
|
272
271
|
}
|
|
273
272
|
|
|
274
|
-
// Commit
|
|
273
|
+
// Commit + optional enrich via shared ingest pipeline
|
|
275
274
|
try {
|
|
276
|
-
await
|
|
275
|
+
const ingestResult = await runIngest({
|
|
276
|
+
aquifer,
|
|
277
|
+
sessionId: sid,
|
|
277
278
|
agentId,
|
|
278
279
|
source: 'opencode',
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
280
|
+
adapter: 'preNormalized',
|
|
281
|
+
preNormalized: norm,
|
|
282
|
+
enrich: doEnrich,
|
|
283
|
+
minUserMessages,
|
|
284
|
+
logger: { info() {}, warn(m) { info.enrichError = m; } },
|
|
284
285
|
});
|
|
286
|
+
|
|
285
287
|
committed++;
|
|
286
288
|
info.status = 'committed';
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
const enrichResult = await aquifer.enrich(sid, { agentId });
|
|
292
|
-
info.status = 'enriched';
|
|
293
|
-
info.turnsEmbedded = enrichResult.turnsEmbedded;
|
|
294
|
-
info.entitiesFound = enrichResult.entitiesFound;
|
|
295
|
-
} catch (enrichErr) {
|
|
296
|
-
info.enrichError = enrichErr.message;
|
|
297
|
-
}
|
|
289
|
+
if (ingestResult.enrichResult) {
|
|
290
|
+
info.status = 'enriched';
|
|
291
|
+
info.turnsEmbedded = ingestResult.enrichResult.turnsEmbedded;
|
|
292
|
+
info.entitiesFound = ingestResult.enrichResult.entitiesFound;
|
|
298
293
|
}
|
|
299
294
|
|
|
300
295
|
if (jsonOutput) {
|
|
301
296
|
results.push(info);
|
|
302
297
|
} else {
|
|
303
|
-
const enrichNote = info.turnsEmbedded
|
|
298
|
+
const enrichNote = info.turnsEmbedded !== null && info.turnsEmbedded !== undefined
|
|
299
|
+
? ` (${info.turnsEmbedded} turns, ${info.entitiesFound} entities)`
|
|
300
|
+
: '';
|
|
304
301
|
console.log(` [${committed}] ${sid} "${session.title}"${enrichNote}`);
|
|
305
302
|
}
|
|
306
303
|
} catch (err) {
|