@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.
- package/README.md +29 -20
- package/consumers/claude-code.js +117 -0
- package/consumers/cli.js +17 -0
- 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/prompts/summary.js +303 -0
- package/consumers/miranda/recall-format.js +74 -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 +200 -82
- package/core/entity.js +29 -17
- package/core/storage.js +116 -45
- package/docs/postprocess-contract.md +132 -0
- package/index.js +9 -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-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,99 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
// Default host home. Callers can override via opts.envPath / opts.configDir.
|
|
8
|
+
const DEFAULT_HOME = process.env.OPENCLAW_HOME || path.join(os.homedir(), '.openclaw');
|
|
9
|
+
|
|
10
|
+
function loadEnvFile(envPath) {
|
|
11
|
+
if (!envPath) envPath = path.join(DEFAULT_HOME, '.env');
|
|
12
|
+
try {
|
|
13
|
+
const text = fs.readFileSync(envPath, 'utf8');
|
|
14
|
+
for (const line of text.split('\n')) {
|
|
15
|
+
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
|
16
|
+
if (m && !process.env[m[1]]) process.env[m[1]] = m[2].trim();
|
|
17
|
+
}
|
|
18
|
+
} catch { /* no .env */ }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function loadConfig(pluginConfig = {}, opts = {}) {
|
|
22
|
+
const home = opts.home || DEFAULT_HOME;
|
|
23
|
+
loadEnvFile(opts.envPath || path.join(home, '.env'));
|
|
24
|
+
|
|
25
|
+
let defaults = {};
|
|
26
|
+
const configPath = opts.configPath || path.join(home, 'extensions/afterburn/config.default.json');
|
|
27
|
+
try {
|
|
28
|
+
let raw = fs.readFileSync(configPath, 'utf8');
|
|
29
|
+
raw = raw.replace(/\$\{(\w+)\}/g, (_, key) => process.env[key] || '');
|
|
30
|
+
defaults = JSON.parse(raw);
|
|
31
|
+
} catch { /* use empty */ }
|
|
32
|
+
|
|
33
|
+
return { ...defaults['afterburn'], ...pluginConfig };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Model defaults per runtime — Miranda's choices on MiniMax
|
|
37
|
+
const RUNTIME_DEFAULTS = {
|
|
38
|
+
gateway: 'MiniMax-M2.7',
|
|
39
|
+
cc: 'MiniMax-M2.5',
|
|
40
|
+
opencode: 'MiniMax-M2.5',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const RUNTIME_ENV_KEY = {
|
|
44
|
+
cc: 'CC_AFTERBURN_MODEL',
|
|
45
|
+
opencode: 'OPENCODE_AFTERBURN_MODEL',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function resolveModel({ runtime, explicitModel, configModel } = {}) {
|
|
49
|
+
if (explicitModel) return explicitModel;
|
|
50
|
+
const envKey = RUNTIME_ENV_KEY[runtime] || 'AFTERBURN_LLM_MODEL';
|
|
51
|
+
if (process.env[envKey]) return process.env[envKey];
|
|
52
|
+
if (configModel) return configModel;
|
|
53
|
+
return RUNTIME_DEFAULTS[runtime] || RUNTIME_DEFAULTS.gateway;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function callLlm(prompt, { runtime, model, timeoutMs } = {}) {
|
|
57
|
+
const resolvedModel = model || resolveModel({ runtime });
|
|
58
|
+
const timeout = timeoutMs || 120000;
|
|
59
|
+
const apiKey = process.env.MINIMAX_API_KEY || process.env.OPENCODE_API_KEY || '';
|
|
60
|
+
if (!apiKey) throw new Error('MINIMAX_API_KEY not set');
|
|
61
|
+
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch('https://api.minimax.io/anthropic/v1/messages', {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: {
|
|
68
|
+
'Content-Type': 'application/json',
|
|
69
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
70
|
+
'anthropic-version': '2023-06-01',
|
|
71
|
+
},
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
model: resolvedModel,
|
|
74
|
+
messages: [{ role: 'user', content: prompt }],
|
|
75
|
+
max_tokens: 4096,
|
|
76
|
+
}),
|
|
77
|
+
signal: controller.signal,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (!res.ok) {
|
|
81
|
+
const body = await res.text().catch(() => '');
|
|
82
|
+
throw new Error(`LLM ${res.status}: ${body.slice(0, 200)}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const data = await res.json();
|
|
86
|
+
let raw;
|
|
87
|
+
if (data.content && Array.isArray(data.content)) {
|
|
88
|
+
raw = data.content.map(c => c.text || '').join('');
|
|
89
|
+
} else {
|
|
90
|
+
raw = data.choices?.[0]?.message?.content || '';
|
|
91
|
+
}
|
|
92
|
+
// Strip <think>...</think> reasoning tags (MiniMax M2.5)
|
|
93
|
+
return raw.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
|
|
94
|
+
} finally {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { loadEnvFile, loadConfig, resolveModel, callLlm };
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Miranda six-section summary prompt + parsers.
|
|
5
|
+
//
|
|
6
|
+
// Sections (all use 繁體中文):
|
|
7
|
+
// SESSION_ENTRIES bullets for today's log (outcome-level)
|
|
8
|
+
// EMOTIONAL_STATE frontmatter + agent mood + observation of MK
|
|
9
|
+
// RECAP tagged fields (TITLE/OVERVIEW/TOPIC/DECISION/...)
|
|
10
|
+
// ENTITIES ENTITY: name|type|aliases + RELATION: src|dst
|
|
11
|
+
// WORKING_FACTS WFACT: subject | statement
|
|
12
|
+
// HANDOFF STATUS/LAST_STEP/NEXT/STOP_REASON/...
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
function buildSummaryPrompt({ conversationText, agentId, now, dailyContext }) {
|
|
16
|
+
if (!now) now = new Date();
|
|
17
|
+
const fmt = new Intl.DateTimeFormat('sv-SE', {
|
|
18
|
+
timeZone: 'Asia/Taipei',
|
|
19
|
+
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
20
|
+
hour: '2-digit', minute: '2-digit', hour12: false,
|
|
21
|
+
});
|
|
22
|
+
const local = fmt.format(now).replace(' ', 'T');
|
|
23
|
+
const [date, time] = local.split('T');
|
|
24
|
+
|
|
25
|
+
return `You are processing a completed chat session for agent "${agentId}".
|
|
26
|
+
Current time: ${date} ${time} (UTC+8)
|
|
27
|
+
|
|
28
|
+
${dailyContext ? `## Today's daily log so far:\n\n${dailyContext}\n\n---\n\n` : ''}## Conversation transcript:
|
|
29
|
+
|
|
30
|
+
${conversationText}
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
Generate SIX sections separated by the exact markers shown below.
|
|
35
|
+
Use 繁體中文. Output content ONLY after each marker.
|
|
36
|
+
|
|
37
|
+
===SESSION_ENTRIES===
|
|
38
|
+
Generate bullet points for this session only. Each line: "- (HH:MM) key point".
|
|
39
|
+
Summarize at the OUTCOME level — one bullet per topic, not per step. Merge related actions into a single entry with the final result.
|
|
40
|
+
Bad: "嘗試檢視 hook" + "嘗試檢視 settings" + "授權中斷" (3 lines, process noise)
|
|
41
|
+
Good: "CC 記憶管理審查,因授權提示反覆出現而中斷" (1 line, outcome)
|
|
42
|
+
Drop greetings, trivial exchanges, and intermediate steps that led to a stated outcome.
|
|
43
|
+
Check "Today's daily log so far" above — if a topic already has an entry there, skip it. Compare by topic/subject, not exact wording.
|
|
44
|
+
Also output one line starting with "焦點:" listing 2-5 current priorities comma-separated.
|
|
45
|
+
|
|
46
|
+
===EMOTIONAL_STATE===
|
|
47
|
+
---
|
|
48
|
+
updated: ${date}T${time}
|
|
49
|
+
session_mood: (one word)
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 情緒狀態
|
|
53
|
+
|
|
54
|
+
(2-3 sentences about the agent emotional state after this session)
|
|
55
|
+
|
|
56
|
+
## 對 MK 的觀察
|
|
57
|
+
|
|
58
|
+
(1-2 sentences about MK state/energy/mood as perceived in this session)
|
|
59
|
+
|
|
60
|
+
===RECAP===
|
|
61
|
+
Output each field on its own tagged line. Use 繁體中文.
|
|
62
|
+
|
|
63
|
+
TITLE: 一句話標題
|
|
64
|
+
OVERVIEW: 80-200字摘要
|
|
65
|
+
TOPIC: 主題名 | 1-3句 summary
|
|
66
|
+
TOPIC: 第二個主題 | summary
|
|
67
|
+
DECISION: 做了什麼 | 原因
|
|
68
|
+
ACTION: 完成的事 | done
|
|
69
|
+
ACTION: 另一件 | partial
|
|
70
|
+
OPEN: 未完成事項 | mk
|
|
71
|
+
FACT: 重要事實
|
|
72
|
+
PATTERN: 可重用模式 | 觸發條件 | 做法 | invariant
|
|
73
|
+
FOCUS_DECISION: keep|update
|
|
74
|
+
FOCUS: 新焦點1, 焦點2(只在 FOCUS_DECISION 為 update 時輸出)
|
|
75
|
+
TODO_NEW: 新增的待辦事項
|
|
76
|
+
TODO_DONE: 已完成的待辦事項(需與當前待辦精確匹配)
|
|
77
|
+
|
|
78
|
+
Rules:
|
|
79
|
+
- One tag per line. Multiple items = multiple lines with same tag.
|
|
80
|
+
- TITLE and OVERVIEW are required. OVERVIEW should be a dense paragraph (80-200 chars), not a short phrase.
|
|
81
|
+
- Others only if relevant.
|
|
82
|
+
- ACTION status: done or partial
|
|
83
|
+
- OPEN owner: mk, agent, or unknown
|
|
84
|
+
- PATTERN durability: invariant or derived
|
|
85
|
+
- FOCUS_DECISION is required. Output "keep" if focus hasn't changed, "update" if it should change.
|
|
86
|
+
- FOCUS: only output when FOCUS_DECISION is update. Comma-separated list of current priorities.
|
|
87
|
+
- TODO_NEW: only if this session created genuinely new action items. One item per line.
|
|
88
|
+
- TODO_DONE: only if a previously listed TODO was clearly completed in this session. Must match existing TODO text closely.
|
|
89
|
+
- When in doubt about TODO changes, do NOT output TODO_NEW or TODO_DONE.
|
|
90
|
+
- If the session was trivial, only output TITLE and OVERVIEW.
|
|
91
|
+
- Do NOT output JSON.
|
|
92
|
+
|
|
93
|
+
===ENTITIES===
|
|
94
|
+
輸出本 session 可落地到知識圖譜的實體與共現關係。每行一筆,禁止額外說明文字。
|
|
95
|
+
|
|
96
|
+
ENTITY: <name> | <type> | <aliases_用逗號分隔,無則填->
|
|
97
|
+
RELATION: <src_name> | <dst_name>
|
|
98
|
+
|
|
99
|
+
類型枚舉(只能用以下 12 種):
|
|
100
|
+
person / project / concept / tool / metric / org / place / event / doc / task / topic / other
|
|
101
|
+
|
|
102
|
+
什麼是好的 entity:
|
|
103
|
+
- 專有名詞:OpenClaw, Aquifer, MiniMax-M2.7, Driftwood, HDBSCAN
|
|
104
|
+
- 具名的人/專案/工具/組織:MK, Jenny, Evan, Garmin, Discord
|
|
105
|
+
- 可被再次查詢的概念:hybrid search, turn embedding, knowledge graph
|
|
106
|
+
|
|
107
|
+
什麼不是 entity(禁止輸出):
|
|
108
|
+
- 角色泛稱:助理、使用者、用戶、assistant、user
|
|
109
|
+
- 動作或事件描述:Gateway 重啟、afterburn 故障排除、cleanup
|
|
110
|
+
- 純數值/metric 片段:120秒超時、401錯誤、600秒、22K cache write
|
|
111
|
+
- 泛用工具名:API、DB、LLM、CLI、Bash、diff
|
|
112
|
+
- 檔案路徑或程式碼符號:cc-hook-context.sh、extractUserTurns、.claude.json
|
|
113
|
+
- Discord message ID 或其他不透明 ID
|
|
114
|
+
- 帶版本的變體(用 aliases 代替):afterburn v0.2 → 用 afterburn + alias "v0.2"
|
|
115
|
+
- 太廣泛的概念:Bug、config、agent、extensions、hooks
|
|
116
|
+
|
|
117
|
+
規則:
|
|
118
|
+
1. 只輸出專有名詞級的實體,最多 10 筆 ENTITY(寧少勿多)。
|
|
119
|
+
2. aliases 填同義詞、縮寫、別名;無則填 -。
|
|
120
|
+
3. RELATION 只輸出共現對(src != dst);src/dst 必須是本段已出現的 ENTITY name。
|
|
121
|
+
4. 同一 pair 只輸出一次;最多 15 對 RELATION。
|
|
122
|
+
5. 若本 session 無值得記錄的實體,仍輸出 ===ENTITIES=== 標籤,內容留空。
|
|
123
|
+
6. 禁止輸出 JSON。
|
|
124
|
+
|
|
125
|
+
===WORKING_FACTS===
|
|
126
|
+
Extract 0-5 current-state facts from this session.
|
|
127
|
+
Each fact describes what IS true NOW, not what happened.
|
|
128
|
+
|
|
129
|
+
Format: WFACT: <subject> | <statement>
|
|
130
|
+
Rules:
|
|
131
|
+
- Subject: entity/project/concept canonical name
|
|
132
|
+
- Statement: current state in 繁體中文, NOT action taken
|
|
133
|
+
- Merge related actions into one state
|
|
134
|
+
- If nothing changed, leave empty
|
|
135
|
+
|
|
136
|
+
Bad: "WFACT: cc-afterburn | 改用 enrich() 和 summaryFn"
|
|
137
|
+
Good: "WFACT: miranda-memory | 已上線,11 模組,CC + gateway 都走 thin wrapper"
|
|
138
|
+
|
|
139
|
+
===HANDOFF===
|
|
140
|
+
Write a handoff note for the next session to pick up where this one left off.
|
|
141
|
+
One tag per line. Use 繁體中文 for values.
|
|
142
|
+
|
|
143
|
+
STATUS: in_progress | interrupted | completed | blocked
|
|
144
|
+
LAST_STEP: 上一段最後在做的具體事情(一句話)
|
|
145
|
+
NEXT: 下一步最小可執行動作(一句話)
|
|
146
|
+
STOP_REASON: natural | interrupted | blocked | context_full
|
|
147
|
+
DECIDED: 本 session 做了的關鍵決策(選填)
|
|
148
|
+
BLOCKER: 卡住的原因(選填,只有 STATUS 是 blocked 時才寫)
|
|
149
|
+
|
|
150
|
+
Rules:
|
|
151
|
+
- STATUS, LAST_STEP, NEXT, STOP_REASON are required.
|
|
152
|
+
- DECIDED and BLOCKER are optional. Omit if not applicable.
|
|
153
|
+
- LAST_STEP and NEXT must refer to things actually discussed in the conversation. Do NOT invent tasks.
|
|
154
|
+
- If the session was trivial (greetings only, < 3 substantive exchanges), output these 4 lines:
|
|
155
|
+
STATUS: completed
|
|
156
|
+
LAST_STEP: 簡短交談
|
|
157
|
+
NEXT: 無
|
|
158
|
+
STOP_REASON: natural`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
const SUMMARY_MARKERS = [
|
|
164
|
+
'===SESSION_ENTRIES===',
|
|
165
|
+
'===EMOTIONAL_STATE===',
|
|
166
|
+
'===RECAP===',
|
|
167
|
+
'===ENTITIES===',
|
|
168
|
+
'===WORKING_FACTS===',
|
|
169
|
+
'===HANDOFF===',
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
function parseSummaryOutput(output) {
|
|
173
|
+
const sections = {};
|
|
174
|
+
for (let i = 0; i < SUMMARY_MARKERS.length; i++) {
|
|
175
|
+
const start = output.indexOf(SUMMARY_MARKERS[i]);
|
|
176
|
+
if (start === -1) continue;
|
|
177
|
+
const contentStart = start + SUMMARY_MARKERS[i].length;
|
|
178
|
+
let end = output.length;
|
|
179
|
+
for (let j = i + 1; j < SUMMARY_MARKERS.length; j++) {
|
|
180
|
+
const candidate = output.indexOf(SUMMARY_MARKERS[j], contentStart);
|
|
181
|
+
if (candidate !== -1) { end = candidate; break; }
|
|
182
|
+
}
|
|
183
|
+
const key = SUMMARY_MARKERS[i].replace(/===/g, '').toLowerCase();
|
|
184
|
+
sections[key] = (end > contentStart ? output.slice(contentStart, end) : output.slice(contentStart)).trim();
|
|
185
|
+
}
|
|
186
|
+
return sections;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function parseRecapLines(text) {
|
|
190
|
+
const recap = {
|
|
191
|
+
title: '', overview: '', topics: [], decisions: [], actions_completed: [],
|
|
192
|
+
open_loops: [], files_mentioned: [], important_facts: [], reusable_patterns: [],
|
|
193
|
+
focus_decision: 'keep', focus: '', todo_new: [], todo_done: [],
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
for (const line of (text || '').split('\n')) {
|
|
197
|
+
const trimmed = line.trim();
|
|
198
|
+
if (!trimmed) continue;
|
|
199
|
+
const match = trimmed.match(/^([A-Z_]+):\s*(.*)/);
|
|
200
|
+
if (!match) continue;
|
|
201
|
+
const [, tag, value] = match;
|
|
202
|
+
|
|
203
|
+
switch (tag) {
|
|
204
|
+
case 'TITLE': recap.title = value; break;
|
|
205
|
+
case 'OVERVIEW': recap.overview = value; break;
|
|
206
|
+
case 'TOPIC': {
|
|
207
|
+
const p = value.split('|').map(s => s.trim());
|
|
208
|
+
if (p[0]) recap.topics.push({ name: p[0], summary: p[1] || '' });
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
case 'DECISION': {
|
|
212
|
+
const p = value.split('|').map(s => s.trim());
|
|
213
|
+
if (p[0]) recap.decisions.push({ decision: p[0], reason: p[1] || '' });
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
case 'ACTION': {
|
|
217
|
+
const p = value.split('|').map(s => s.trim());
|
|
218
|
+
if (p[0]) recap.actions_completed.push({
|
|
219
|
+
action: p[0],
|
|
220
|
+
status: (p[1] || 'done').toLowerCase() === 'partial' ? 'partial' : 'done',
|
|
221
|
+
});
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
case 'OPEN': {
|
|
225
|
+
const p = value.split('|').map(s => s.trim());
|
|
226
|
+
const o = (p[1] || 'unknown').toLowerCase();
|
|
227
|
+
if (p[0]) recap.open_loops.push({
|
|
228
|
+
item: p[0],
|
|
229
|
+
owner: ['mk', 'agent', 'unknown'].includes(o) ? o : 'unknown',
|
|
230
|
+
});
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
case 'FACT': if (value) recap.important_facts.push(value); break;
|
|
234
|
+
case 'PATTERN': {
|
|
235
|
+
const p = value.split('|').map(s => s.trim());
|
|
236
|
+
if (p[0] && p[1]) recap.reusable_patterns.push({
|
|
237
|
+
pattern: p[0], trigger: p[1], action: p[2] || '',
|
|
238
|
+
durability: (p[3] || 'derived').toLowerCase() === 'invariant' ? 'invariant' : 'derived',
|
|
239
|
+
});
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
case 'FOCUS_DECISION':
|
|
243
|
+
recap.focus_decision = value.toLowerCase().trim() === 'update' ? 'update' : 'keep';
|
|
244
|
+
break;
|
|
245
|
+
case 'FOCUS': recap.focus = value; break;
|
|
246
|
+
case 'TODO_NEW': if (value) recap.todo_new.push(value); break;
|
|
247
|
+
case 'TODO_DONE': if (value) recap.todo_done.push(value); break;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return recap;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function parseWorkingFacts(text) {
|
|
254
|
+
if (!text || typeof text !== 'string') return [];
|
|
255
|
+
const facts = [];
|
|
256
|
+
for (const line of text.split('\n')) {
|
|
257
|
+
const m = line.trim().match(/^WFACT:\s*(.+?)\s*\|\s*(.+)/);
|
|
258
|
+
if (!m) continue;
|
|
259
|
+
const subject = m[1].trim().slice(0, 100);
|
|
260
|
+
const statement = m[2].trim().slice(0, 500);
|
|
261
|
+
if (!subject || !statement) continue;
|
|
262
|
+
facts.push({ subject, statement });
|
|
263
|
+
if (facts.length >= 5) break;
|
|
264
|
+
}
|
|
265
|
+
return facts;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const VALID_HANDOFF_STATUS = new Set(['in_progress', 'interrupted', 'completed', 'blocked']);
|
|
269
|
+
const VALID_STOP_REASON = new Set(['natural', 'interrupted', 'blocked', 'context_full']);
|
|
270
|
+
|
|
271
|
+
function normalizeEnum(raw, validSet) {
|
|
272
|
+
const v = raw.trim().toLowerCase().replace(/-/g, '_').replace(/\s+/g, '_');
|
|
273
|
+
return validSet.has(v) ? v : null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function parseHandoffSection(text) {
|
|
277
|
+
if (!text || typeof text !== 'string') return null;
|
|
278
|
+
const handoff = { status: 'completed', lastStep: '', next: '', stopReason: 'natural', decided: '', blocker: '' };
|
|
279
|
+
for (const line of text.split('\n')) {
|
|
280
|
+
const m = line.trim().match(/^([A-Z_]+):\s*(.*)/);
|
|
281
|
+
if (!m) continue;
|
|
282
|
+
const [, tag, value] = m;
|
|
283
|
+
switch (tag) {
|
|
284
|
+
case 'STATUS': handoff.status = normalizeEnum(value, VALID_HANDOFF_STATUS) || 'completed'; break;
|
|
285
|
+
case 'LAST_STEP': handoff.lastStep = value.trim().slice(0, 200); break;
|
|
286
|
+
case 'NEXT': handoff.next = value.trim().slice(0, 200); break;
|
|
287
|
+
case 'STOP_REASON': handoff.stopReason = normalizeEnum(value, VALID_STOP_REASON) || 'natural'; break;
|
|
288
|
+
case 'DECIDED': handoff.decided = value.trim().slice(0, 200); break;
|
|
289
|
+
case 'BLOCKER': handoff.blocker = value.trim().slice(0, 200); break;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (!handoff.lastStep || !handoff.next) return null;
|
|
293
|
+
return handoff;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
module.exports = {
|
|
297
|
+
buildSummaryPrompt,
|
|
298
|
+
parseSummaryOutput,
|
|
299
|
+
parseRecapLines,
|
|
300
|
+
parseWorkingFacts,
|
|
301
|
+
parseHandoffSection,
|
|
302
|
+
SUMMARY_MARKERS,
|
|
303
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Miranda zh-TW recall formatter — overrides the shared default renderers
|
|
4
|
+
// to produce narrative-style output instead of score-flavored markdown.
|
|
5
|
+
|
|
6
|
+
const { createRecallFormatter, truncate, formatDateIso } = require('../shared/recall-format');
|
|
7
|
+
|
|
8
|
+
function formatTopicLines(topics) {
|
|
9
|
+
if (!Array.isArray(topics) || topics.length === 0) return '- 無';
|
|
10
|
+
return topics.map((topic) => {
|
|
11
|
+
const name = topic?.name || '未命名主題';
|
|
12
|
+
const summary = topic?.summary ? `:${topic.summary}` : '';
|
|
13
|
+
return `- ${name}${summary}`;
|
|
14
|
+
}).join('\n');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function formatDecisions(decisions) {
|
|
18
|
+
if (!Array.isArray(decisions) || decisions.length === 0) return null;
|
|
19
|
+
return decisions.map(d => {
|
|
20
|
+
const decision = d?.decision || '';
|
|
21
|
+
const reason = d?.reason ? `(${d.reason})` : '';
|
|
22
|
+
return `- ${decision}${reason}`;
|
|
23
|
+
}).join('\n');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function coalesceTitle(structuredSummary, summaryText) {
|
|
27
|
+
if (structuredSummary && structuredSummary.title) return structuredSummary.title;
|
|
28
|
+
if (summaryText) return truncate(summaryText, 60);
|
|
29
|
+
return '(無標題)';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const mirandaRenderers = {
|
|
33
|
+
empty: () => '找不到符合條件的 session。',
|
|
34
|
+
header: () => null,
|
|
35
|
+
title: (r, i) => {
|
|
36
|
+
const ss = r.structuredSummary || {};
|
|
37
|
+
const title = coalesceTitle(ss, r.summaryText);
|
|
38
|
+
const date = formatDateIso(r.startedAt);
|
|
39
|
+
const agent = r.agentId || r.agent_id || 'main';
|
|
40
|
+
return `### ${i + 1}. ${title}\n**Agent**: ${agent} | **Date**: ${date}`;
|
|
41
|
+
},
|
|
42
|
+
body: (r) => {
|
|
43
|
+
const ss = r.structuredSummary || {};
|
|
44
|
+
const parts = [];
|
|
45
|
+
|
|
46
|
+
if (ss.overview) parts.push(`**Overview**:${truncate(ss.overview, 400)}`);
|
|
47
|
+
else if (r.summaryText) parts.push(`**Overview**:${truncate(r.summaryText, 400)}`);
|
|
48
|
+
|
|
49
|
+
if (Array.isArray(ss.topics) && ss.topics.length > 0) {
|
|
50
|
+
parts.push(`**主題**:\n${formatTopicLines(ss.topics)}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const decisions = formatDecisions(ss.decisions);
|
|
54
|
+
if (decisions) parts.push(`**決策**:\n${decisions}`);
|
|
55
|
+
|
|
56
|
+
if (Array.isArray(ss.open_loops) && ss.open_loops.length > 0) {
|
|
57
|
+
const items = ss.open_loops.map(l => `- ${typeof l === 'string' ? l : (l.item || '')}`).join('\n');
|
|
58
|
+
parts.push(`**待辦**:\n${items}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return parts.length > 0 ? parts.join('\n') : null;
|
|
62
|
+
},
|
|
63
|
+
matched: (r) => r.matchedTurnText ? `**命中段落**: ${truncate(r.matchedTurnText, 200)}` : null,
|
|
64
|
+
score: () => null,
|
|
65
|
+
separator: () => '\n---\n',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const mirandaFormatter = createRecallFormatter(mirandaRenderers);
|
|
69
|
+
|
|
70
|
+
function formatRecallResults(results, opts = {}) {
|
|
71
|
+
return mirandaFormatter(results, opts);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { formatRecallResults, mirandaRenderers };
|
|
@@ -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
|
+
}
|