@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,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,145 @@
1
+ {
2
+ "$schema": "https://aquifer.dev/schema/consumer-profile.v1.json",
3
+ "consumer_profile_id": "miranda",
4
+ "version": 1,
5
+ "description": "Miranda persona consumer profile — canonical shape for session state, handoff, decision log, timeline categories, and default artifact producers. Reference implementation shipped inside Aquifer; production deployments may register additional versions with schema changes.",
6
+ "defaults": {
7
+ "tenant_id": "default",
8
+ "agent_id": "main",
9
+ "time_zone": "Asia/Taipei"
10
+ },
11
+ "schemas": {
12
+ "default.session_state.v1": {
13
+ "kind": "default",
14
+ "target": "sessionState",
15
+ "description": "What Miranda is currently focused on — goal + active threads + affect tag.",
16
+ "json_schema": {
17
+ "type": "object",
18
+ "additionalProperties": true,
19
+ "properties": {
20
+ "goal": { "type": ["string", "null"] },
21
+ "active_work": { "type": "array", "items": { "type": "string" } },
22
+ "blockers": { "type": "array", "items": { "type": "string" } },
23
+ "affect": {
24
+ "type": "object",
25
+ "additionalProperties": true,
26
+ "properties": {
27
+ "mood": { "type": ["string", "null"] },
28
+ "energy": { "enum": ["low", "medium", "high", null] },
29
+ "confidence": { "enum": ["low", "medium", "high", null] },
30
+ "notes": { "type": ["string", "null"] }
31
+ }
32
+ }
33
+ },
34
+ "required": ["goal", "active_work", "blockers", "affect"]
35
+ }
36
+ },
37
+ "default.session_handoff.v1": {
38
+ "kind": "default",
39
+ "target": "sessionHandoff",
40
+ "description": "Session-end baton: what was the last step, where to pick up, what still blocks.",
41
+ "json_schema": {
42
+ "type": "object",
43
+ "additionalProperties": true,
44
+ "properties": {
45
+ "last_step": { "type": "string" },
46
+ "status": { "enum": ["in_progress", "completed", "blocked"] },
47
+ "next": { "type": ["string", "null"] },
48
+ "blockers": { "type": "array", "items": { "type": "string" } },
49
+ "decided": { "type": "array", "items": { "type": "string" } },
50
+ "open_loops": { "type": "array", "items": { "type": "string" } }
51
+ },
52
+ "required": ["last_step", "status", "next", "blockers", "decided", "open_loops"]
53
+ }
54
+ },
55
+ "default.decision_log.v1": {
56
+ "kind": "default",
57
+ "target": "decisionLog",
58
+ "description": "Committed / proposed / reversed decisions with optional fact linkage.",
59
+ "json_schema": {
60
+ "type": "object",
61
+ "additionalProperties": true,
62
+ "properties": {
63
+ "decision": { "type": "string" },
64
+ "reason": { "type": ["string", "null"] },
65
+ "status": { "enum": ["proposed", "committed", "reversed"] },
66
+ "related_fact_ids": {
67
+ "type": "array",
68
+ "items": { "type": "integer" }
69
+ }
70
+ },
71
+ "required": ["decision", "reason", "status"]
72
+ }
73
+ },
74
+ "default.timeline.v1": {
75
+ "kind": "default",
76
+ "target": "timeline",
77
+ "description": "Miranda daily timeline category vocabulary. Categories map to existing daily-log sections (focus/todo/mood/handoff) plus organisational tags Miranda uses in weekly/monthly rollups.",
78
+ "category_vocabulary": [
79
+ "cli",
80
+ "focus",
81
+ "todo",
82
+ "handoff",
83
+ "narrative",
84
+ "organized",
85
+ "weekly",
86
+ "monthly",
87
+ "stats",
88
+ "health",
89
+ "garmin",
90
+ "note"
91
+ ],
92
+ "json_schema": {
93
+ "type": "object",
94
+ "additionalProperties": false,
95
+ "properties": {
96
+ "occurred_at": { "type": "string", "format": "date-time" },
97
+ "source": { "type": "string" },
98
+ "session_ref": { "type": ["string", "null"] },
99
+ "category": { "type": "string" },
100
+ "text": { "type": "string" },
101
+ "metadata": { "type": "object" }
102
+ },
103
+ "required": ["occurred_at", "source", "session_ref", "category", "text", "metadata"]
104
+ }
105
+ }
106
+ },
107
+ "artifacts": {
108
+ "producers": [
109
+ {
110
+ "producer_id": "miranda.workspace.daily-log",
111
+ "type": "daily-log",
112
+ "trigger_phase": "timeline_write",
113
+ "format": "markdown",
114
+ "destination": "workspace://memory/{date}.md",
115
+ "description": "Renders the day's timeline events + state snapshot + handoff to a single markdown file under the Miranda workspace. Source of truth remains the DB; the .md is a rendered view."
116
+ },
117
+ {
118
+ "producer_id": "miranda.workspace.weekly-log",
119
+ "type": "weekly-log",
120
+ "trigger_phase": "artifact_dispatch",
121
+ "format": "markdown",
122
+ "destination": "workspace://memory/weekly/{week_start}.md",
123
+ "description": "Weekly rollup. Timeline events tagged [WEEKLY] plus cross-session narratives. Rendered from DB via rollupWeekly() helper."
124
+ },
125
+ {
126
+ "producer_id": "miranda.workspace.monthly-log",
127
+ "type": "monthly-log",
128
+ "trigger_phase": "artifact_dispatch",
129
+ "format": "markdown",
130
+ "destination": "workspace://memory/monthly/{month}.md",
131
+ "description": "Monthly rollup. Aggregates weekly entries + narrative supersede chain."
132
+ }
133
+ ]
134
+ },
135
+ "extraction_hints": {
136
+ "entities": {
137
+ "include_types": ["person", "project", "tool", "topic"],
138
+ "exclude_patterns": ["^/tmp/", "^/home/mingko/\\.", "^node_modules/"]
139
+ },
140
+ "facts": {
141
+ "prefer_subjects": ["MK", "Miranda", "Aquifer", "OpenClaw", "Jenny", "Evan", "Ivan"],
142
+ "avoid_ephemeral": true
143
+ }
144
+ }
145
+ }
@@ -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 };