@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.
Files changed (60) hide show
  1. package/.env.example +23 -0
  2. package/README.md +84 -73
  3. package/README_CN.md +676 -0
  4. package/README_TW.md +684 -0
  5. package/aquifer.config.example.json +34 -0
  6. package/consumers/claude-code.js +11 -11
  7. package/consumers/cli.js +421 -53
  8. package/consumers/codex-handoff.js +258 -0
  9. package/consumers/codex.js +1676 -0
  10. package/consumers/default/daily-entries.js +23 -4
  11. package/consumers/default/index.js +2 -2
  12. package/consumers/default/prompts/summary.js +6 -6
  13. package/consumers/mcp.js +96 -5
  14. package/consumers/openclaw-ext/index.js +0 -1
  15. package/consumers/openclaw-plugin.js +1 -1
  16. package/consumers/shared/config.js +8 -0
  17. package/consumers/shared/factory.js +1 -0
  18. package/consumers/shared/ingest.js +1 -1
  19. package/consumers/shared/normalize.js +14 -3
  20. package/consumers/shared/recall-format.js +27 -0
  21. package/consumers/shared/summary-parser.js +151 -0
  22. package/core/aquifer.js +380 -18
  23. package/core/finalization-review.js +319 -0
  24. package/core/mcp-manifest.js +52 -2
  25. package/core/memory-bootstrap.js +200 -0
  26. package/core/memory-consolidation.js +1590 -0
  27. package/core/memory-promotion.js +544 -0
  28. package/core/memory-recall.js +247 -0
  29. package/core/memory-records.js +797 -0
  30. package/core/memory-safety-gate.js +224 -0
  31. package/core/session-finalization.js +365 -0
  32. package/core/storage.js +385 -2
  33. package/docs/getting-started.md +105 -0
  34. package/docs/postprocess-contract.md +2 -2
  35. package/docs/setup.md +92 -2
  36. package/package.json +25 -11
  37. package/pipeline/normalize/adapters/codex.js +106 -0
  38. package/pipeline/normalize/detect.js +3 -2
  39. package/schema/001-base.sql +3 -0
  40. package/schema/007-v1-foundation.sql +273 -0
  41. package/schema/008-session-finalizations.sql +50 -0
  42. package/schema/009-v1-assertion-plane.sql +193 -0
  43. package/schema/010-v1-finalization-review.sql +160 -0
  44. package/schema/011-v1-compaction-claim.sql +46 -0
  45. package/schema/012-v1-compaction-lease.sql +39 -0
  46. package/schema/013-v1-compaction-lineage.sql +193 -0
  47. package/scripts/codex-recovery.js +672 -0
  48. package/consumers/miranda/context-inject.js +0 -120
  49. package/consumers/miranda/daily-entries.js +0 -224
  50. package/consumers/miranda/index.js +0 -364
  51. package/consumers/miranda/instance.js +0 -55
  52. package/consumers/miranda/llm.js +0 -99
  53. package/consumers/miranda/profile.json +0 -145
  54. package/consumers/miranda/prompts/summary.js +0 -303
  55. package/consumers/miranda/recall-format.js +0 -76
  56. package/consumers/miranda/render-daily-md.js +0 -186
  57. package/consumers/miranda/workspace-files.js +0 -91
  58. package/scripts/drop-entity-state-history.sql +0 -17
  59. package/scripts/drop-insights.sql +0 -12
  60. 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.