@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.
Files changed (59) hide show
  1. package/README.md +37 -29
  2. package/consumers/claude-code.js +117 -0
  3. package/consumers/cli.js +28 -1
  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/profile.json +145 -0
  14. package/consumers/miranda/prompts/summary.js +303 -0
  15. package/consumers/miranda/recall-format.js +74 -0
  16. package/consumers/miranda/render-daily-md.js +186 -0
  17. package/consumers/miranda/workspace-files.js +91 -0
  18. package/consumers/openclaw-ext/index.js +38 -0
  19. package/consumers/openclaw-ext/openclaw.plugin.json +9 -0
  20. package/consumers/openclaw-ext/package.json +10 -0
  21. package/consumers/openclaw-plugin.js +66 -74
  22. package/consumers/opencode.js +21 -24
  23. package/consumers/shared/autodetect.js +64 -0
  24. package/consumers/shared/entity-parser.js +119 -0
  25. package/consumers/shared/ingest.js +148 -0
  26. package/consumers/shared/llm-autodetect.js +137 -0
  27. package/consumers/shared/normalize.js +129 -0
  28. package/consumers/shared/recall-format.js +110 -0
  29. package/core/aquifer.js +209 -71
  30. package/core/artifacts.js +174 -0
  31. package/core/bundles.js +400 -0
  32. package/core/consolidation.js +340 -0
  33. package/core/decisions.js +164 -0
  34. package/core/entity.js +1 -3
  35. package/core/errors.js +97 -0
  36. package/core/handoff.js +153 -0
  37. package/core/mcp-manifest.js +131 -0
  38. package/core/narratives.js +212 -0
  39. package/core/profiles.js +171 -0
  40. package/core/state.js +163 -0
  41. package/core/storage.js +86 -28
  42. package/core/timeline.js +152 -0
  43. package/docs/postprocess-contract.md +132 -0
  44. package/index.js +23 -1
  45. package/package.json +23 -2
  46. package/pipeline/_http.js +1 -1
  47. package/pipeline/consolidation/apply.js +176 -0
  48. package/pipeline/consolidation/index.js +21 -0
  49. package/pipeline/extract-entities.js +2 -2
  50. package/pipeline/rerank.js +1 -1
  51. package/pipeline/summarize.js +4 -1
  52. package/schema/001-base.sql +61 -24
  53. package/schema/002-entities.sql +17 -3
  54. package/schema/004-completion.sql +375 -0
  55. package/schema/004-facts.sql +67 -0
  56. package/scripts/diagnose-fts-zh.js +168 -134
  57. package/scripts/diagnose-vector.js +188 -0
  58. package/scripts/install-openclaw.sh +59 -0
  59. 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
- function formatDate(value) {
83
- if (!value) return 'unknown';
84
- const parsed = new Date(value);
85
- return isNaN(parsed.getTime()) ? 'unknown' : parsed.toISOString().slice(0, 10);
86
- }
87
-
88
- function formatRecallResults(results) {
89
- if (results.length === 0) return 'No matching sessions found.';
90
-
91
- return results.map((r, i) => {
92
- const ss = r.structuredSummary || {};
93
- const title = ss.title || r.summaryText?.slice(0, 60) || '(untitled)';
94
- const date = formatDate(r.startedAt);
95
-
96
- const lines = [`### ${i + 1}. ${title} (${date}, ${r.agentId || 'default'})`];
97
- if (ss.overview || r.summaryText) {
98
- lines.push((ss.overview || r.summaryText).slice(0, 300));
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
- module.exports = {
112
- id: 'aquifer-memory',
113
- name: 'Aquifer Memory',
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
- register(api) {
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
- if (norm.userCount === 0) {
157
- api.logger.info(`[aquifer-memory] skip: no user messages in ${sessionId}`);
158
- return;
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
- model: norm.model,
167
- tokensIn: norm.tokensIn,
168
- tokensOut: norm.tokensOut,
169
- startedAt: norm.startedAt,
170
- lastMessageAt: norm.lastMessageAt,
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
- api.logger.info('[aquifer-memory] registered (before_reset + session_recall + session_feedback)');
296
- },
297
- };
288
+ api.logger.info('[aquifer-memory] registered (before_reset + session_recall + session_feedback)');
289
+ }
@@ -26,8 +26,7 @@
26
26
 
27
27
  const path = require('path');
28
28
  const os = require('os');
29
- const { createAquiferFromConfig } = require('./shared/factory');
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 to Aquifer
273
+ // Commit + optional enrich via shared ingest pipeline
275
274
  try {
276
- await aquifer.commit(sid, norm.messages, {
275
+ const ingestResult = await runIngest({
276
+ aquifer,
277
+ sessionId: sid,
277
278
  agentId,
278
279
  source: 'opencode',
279
- model: norm.model,
280
- tokensIn: norm.tokensIn,
281
- tokensOut: norm.tokensOut,
282
- startedAt: norm.startedAt,
283
- lastMessageAt: norm.lastMessageAt,
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
- // Enrich if requested
289
- if (doEnrich) {
290
- try {
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 != null ? ` (${info.turnsEmbedded} turns, ${info.entitiesFound} entities)` : '';
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) {