@shadowforge0/aquifer-memory 1.5.12 → 1.7.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/.env.example +23 -0
- package/README.md +84 -73
- package/README_CN.md +676 -0
- package/README_TW.md +684 -0
- package/aquifer.config.example.json +34 -0
- package/consumers/claude-code.js +11 -11
- package/consumers/cli.js +421 -53
- package/consumers/codex-handoff.js +258 -0
- package/consumers/codex.js +1676 -0
- package/consumers/default/daily-entries.js +23 -4
- package/consumers/default/index.js +2 -2
- package/consumers/default/prompts/summary.js +6 -6
- package/consumers/mcp.js +96 -5
- package/consumers/openclaw-ext/index.js +0 -1
- package/consumers/openclaw-plugin.js +1 -1
- package/consumers/shared/config.js +8 -0
- package/consumers/shared/factory.js +1 -0
- package/consumers/shared/ingest.js +1 -1
- package/consumers/shared/normalize.js +14 -3
- package/consumers/shared/recall-format.js +27 -0
- package/consumers/shared/summary-parser.js +151 -0
- package/core/aquifer.js +380 -18
- package/core/finalization-review.js +319 -0
- package/core/mcp-manifest.js +52 -2
- package/core/memory-bootstrap.js +200 -0
- package/core/memory-consolidation.js +1590 -0
- package/core/memory-promotion.js +544 -0
- package/core/memory-recall.js +247 -0
- package/core/memory-records.js +797 -0
- package/core/memory-safety-gate.js +224 -0
- package/core/session-finalization.js +365 -0
- package/core/storage.js +385 -2
- package/docs/getting-started.md +105 -0
- package/docs/postprocess-contract.md +2 -2
- package/docs/setup.md +92 -2
- package/package.json +25 -11
- package/pipeline/normalize/adapters/codex.js +106 -0
- package/pipeline/normalize/detect.js +3 -2
- package/schema/001-base.sql +3 -0
- package/schema/007-v1-foundation.sql +273 -0
- package/schema/008-session-finalizations.sql +50 -0
- package/schema/009-v1-assertion-plane.sql +193 -0
- package/schema/010-v1-finalization-review.sql +160 -0
- package/schema/011-v1-compaction-claim.sql +46 -0
- package/schema/012-v1-compaction-lease.sql +39 -0
- package/schema/013-v1-compaction-lineage.sql +193 -0
- package/scripts/codex-recovery.js +672 -0
- package/consumers/miranda/context-inject.js +0 -120
- package/consumers/miranda/daily-entries.js +0 -224
- package/consumers/miranda/index.js +0 -364
- package/consumers/miranda/instance.js +0 -55
- package/consumers/miranda/llm.js +0 -99
- package/consumers/miranda/profile.json +0 -145
- package/consumers/miranda/prompts/summary.js +0 -303
- package/consumers/miranda/recall-format.js +0 -76
- package/consumers/miranda/render-daily-md.js +0 -186
- package/consumers/miranda/workspace-files.js +0 -91
- package/scripts/drop-entity-state-history.sql +0 -17
- package/scripts/drop-insights.sql +0 -12
- package/scripts/install-openclaw.sh +0 -59
|
@@ -1,303 +0,0 @@
|
|
|
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
|
-
};
|
|
@@ -1,76 +0,0 @@
|
|
|
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, formatRelativeZhTw } = 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, ctx) => {
|
|
36
|
-
const ss = r.structuredSummary || {};
|
|
37
|
-
const title = coalesceTitle(ss, r.summaryText);
|
|
38
|
-
const iso = formatDateIso(r.startedAt);
|
|
39
|
-
const rel = formatRelativeZhTw(r.startedAt, ctx?.now);
|
|
40
|
-
const date = rel ? `${rel}(${iso})` : iso;
|
|
41
|
-
const agent = r.agentId || r.agent_id || 'main';
|
|
42
|
-
return `### ${i + 1}. ${title}\n**Agent**: ${agent} | **Date**: ${date}`;
|
|
43
|
-
},
|
|
44
|
-
body: (r) => {
|
|
45
|
-
const ss = r.structuredSummary || {};
|
|
46
|
-
const parts = [];
|
|
47
|
-
|
|
48
|
-
if (ss.overview) parts.push(`**Overview**:${truncate(ss.overview, 400)}`);
|
|
49
|
-
else if (r.summaryText) parts.push(`**Overview**:${truncate(r.summaryText, 400)}`);
|
|
50
|
-
|
|
51
|
-
if (Array.isArray(ss.topics) && ss.topics.length > 0) {
|
|
52
|
-
parts.push(`**主題**:\n${formatTopicLines(ss.topics)}`);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const decisions = formatDecisions(ss.decisions);
|
|
56
|
-
if (decisions) parts.push(`**決策**:\n${decisions}`);
|
|
57
|
-
|
|
58
|
-
if (Array.isArray(ss.open_loops) && ss.open_loops.length > 0) {
|
|
59
|
-
const items = ss.open_loops.map(l => `- ${typeof l === 'string' ? l : (l.item || '')}`).join('\n');
|
|
60
|
-
parts.push(`**待辦**:\n${items}`);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return parts.length > 0 ? parts.join('\n') : null;
|
|
64
|
-
},
|
|
65
|
-
matched: (r) => r.matchedTurnText ? `**命中段落**: ${truncate(r.matchedTurnText, 200)}` : null,
|
|
66
|
-
score: () => null,
|
|
67
|
-
separator: () => '\n---\n',
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
const mirandaFormatter = createRecallFormatter(mirandaRenderers);
|
|
71
|
-
|
|
72
|
-
function formatRecallResults(results, opts = {}) {
|
|
73
|
-
return mirandaFormatter(results, opts);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
module.exports = { formatRecallResults, mirandaRenderers };
|
|
@@ -1,186 +0,0 @@
|
|
|
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 };
|
|
@@ -1,91 +0,0 @@
|
|
|
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 };
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
-- DROP-clean script for entity_state_history (Q3 bitter-lesson escape hatch).
|
|
2
|
-
--
|
|
3
|
-
-- Run this if you decide native long-context / agentic memory has obviated the
|
|
4
|
-
-- temporal state-change layer. Removes the table and all dependent indexes;
|
|
5
|
-
-- nothing else in Aquifer references it directly (FK is one-way: this table
|
|
6
|
-
-- references entities/sessions, not the reverse).
|
|
7
|
-
--
|
|
8
|
-
-- Usage:
|
|
9
|
-
-- psql $DATABASE_URL -v schema=miranda -f scripts/drop-entity-state-history.sql
|
|
10
|
-
|
|
11
|
-
DROP TABLE IF EXISTS :"schema".entity_state_history CASCADE;
|
|
12
|
-
|
|
13
|
-
-- Verify nothing remains.
|
|
14
|
-
SELECT to_regclass(:'schema' || '.entity_state_history') AS table_after_drop;
|
|
15
|
-
SELECT to_regclass(:'schema' || '.idx_entity_state_history_current') AS idx_current_after_drop;
|
|
16
|
-
SELECT to_regclass(:'schema' || '.idx_entity_state_history_idempotency') AS idx_idempotency_after_drop;
|
|
17
|
-
-- All three should report NULL.
|