@shadowforge0/aquifer-memory 1.0.3 → 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.
Files changed (45) hide show
  1. package/README.md +29 -20
  2. package/consumers/claude-code.js +117 -0
  3. package/consumers/cli.js +17 -0
  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/prompts/summary.js +303 -0
  14. package/consumers/miranda/recall-format.js +74 -0
  15. package/consumers/miranda/workspace-files.js +91 -0
  16. package/consumers/openclaw-ext/index.js +38 -0
  17. package/consumers/openclaw-ext/openclaw.plugin.json +9 -0
  18. package/consumers/openclaw-ext/package.json +10 -0
  19. package/consumers/openclaw-plugin.js +66 -74
  20. package/consumers/opencode.js +21 -24
  21. package/consumers/shared/autodetect.js +64 -0
  22. package/consumers/shared/entity-parser.js +119 -0
  23. package/consumers/shared/ingest.js +148 -0
  24. package/consumers/shared/llm-autodetect.js +137 -0
  25. package/consumers/shared/normalize.js +129 -0
  26. package/consumers/shared/recall-format.js +110 -0
  27. package/core/aquifer.js +180 -71
  28. package/core/entity.js +1 -3
  29. package/core/storage.js +86 -28
  30. package/docs/postprocess-contract.md +132 -0
  31. package/index.js +9 -1
  32. package/package.json +23 -2
  33. package/pipeline/_http.js +1 -1
  34. package/pipeline/consolidation/apply.js +176 -0
  35. package/pipeline/consolidation/index.js +21 -0
  36. package/pipeline/extract-entities.js +2 -2
  37. package/pipeline/rerank.js +1 -1
  38. package/pipeline/summarize.js +4 -1
  39. package/schema/001-base.sql +61 -24
  40. package/schema/002-entities.sql +17 -3
  41. package/schema/004-facts.sql +67 -0
  42. package/scripts/diagnose-fts-zh.js +168 -134
  43. package/scripts/diagnose-vector.js +188 -0
  44. package/scripts/install-openclaw.sh +59 -0
  45. package/scripts/smoke.mjs +2 -2
@@ -0,0 +1,282 @@
1
+ 'use strict';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Aquifer default persona — parameterized, host-agnostic.
5
+ //
6
+ // Entry point:
7
+ // const persona = require('@shadowforge0/aquifer-memory/consumers/default')
8
+ // .createPersona({
9
+ // agentName: 'Dobby',
10
+ // observedOwner: 'evan',
11
+ // schema: 'jenny',
12
+ // scope: 'jenny',
13
+ // dailyTable: 'jenny.daily_entries', // or null to skip daily writes
14
+ // language: 'zh-TW', // or 'en'
15
+ // briefingIntro: '你是 Dobby。以下是現況...', // optional context-inject preamble
16
+ // });
17
+ //
18
+ // Returns a persona module with the same shape as consumers/miranda — host
19
+ // can do `AQUIFER_PERSONA=<host-path>` where <host-path>/index.js does:
20
+ // module.exports = require('@shadowforge0/aquifer-memory/consumers/default')
21
+ // .createPersona({ ... });
22
+ //
23
+ // This is intentionally a minimal persona: summary + optional daily_entries,
24
+ // no workspace-files, no consolidation, no Miranda-specific scaffolding.
25
+ // Host can extend by composing with Aquifer primitives from consumers/shared.
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const { createAquifer } = require('../../index');
29
+ const { runIngest } = require('../shared/ingest');
30
+ const { parseEntitySection } = require('../shared/entity-parser');
31
+
32
+ const summaryModule = require('./prompts/summary');
33
+ const dailyEntriesModule = require('./daily-entries');
34
+
35
+ function createPersona(personaOpts = {}) {
36
+ const persona = {
37
+ agentName: personaOpts.agentName || 'Assistant',
38
+ observedOwner: personaOpts.observedOwner || null,
39
+ schema: personaOpts.schema || 'aquifer',
40
+ scope: personaOpts.scope || 'default',
41
+ dailyTable: personaOpts.dailyTable || null,
42
+ language: personaOpts.language || 'en',
43
+ briefingIntro: personaOpts.briefingIntro || null,
44
+ skipEntities: personaOpts.skipEntities === true,
45
+ };
46
+
47
+ // ------- primitives -------
48
+
49
+ let _instance = null;
50
+ function getAquifer({ pool, embedFn, llmFn, rerankKey } = {}) {
51
+ if (_instance) return _instance;
52
+ // v1.2.0: all four are optional. If omitted, Aquifer core falls back to
53
+ // DATABASE_URL + EMBED_PROVIDER + AQUIFER_LLM_PROVIDER env.
54
+ const cfg = {
55
+ schema: persona.schema,
56
+ tenantId: 'default',
57
+ entities: { enabled: !persona.skipEntities, scope: persona.scope },
58
+ };
59
+ if (pool !== undefined) cfg.db = pool;
60
+ if (embedFn) cfg.embed = { fn: embedFn };
61
+ if (llmFn) cfg.llm = { fn: llmFn };
62
+ if (rerankKey) cfg.rerank = { provider: 'openrouter', openrouterApiKey: rerankKey, topK: 20, maxChars: 1600 };
63
+ _instance = createAquifer(cfg);
64
+ return _instance;
65
+ }
66
+ function resetAquifer() { _instance = null; }
67
+
68
+ function extractConversationText(normalized) {
69
+ if (!Array.isArray(normalized)) return '';
70
+ return normalized
71
+ .filter(m => m.role === 'user' || m.role === 'assistant')
72
+ .map(m => `[${m.role}] ${typeof m.content === 'string' ? m.content : ''}`)
73
+ .join('\n');
74
+ }
75
+
76
+ function buildSummaryFn({ agentId, now, dailyContext, llmFn, logger = console }) {
77
+ if (typeof llmFn !== 'function') {
78
+ throw new Error('default persona buildSummaryFn: llmFn is required');
79
+ }
80
+ return async function summaryFn(normalized) {
81
+ const conversationText = extractConversationText(normalized);
82
+ if (!conversationText) throw new Error('empty conversation text');
83
+ const prompt = summaryModule.buildSummaryPrompt({ conversationText, agentId, now, dailyContext, persona });
84
+ if (logger.info) logger.info(`[default-persona] calling LLM for ${agentId}`);
85
+ const output = await llmFn(prompt);
86
+ if (!output) throw new Error('LLM returned empty');
87
+ const sections = summaryModule.parseSummaryOutput(output);
88
+ const recap = summaryModule.parseRecapLines(sections.recap || '');
89
+ if (!recap.title) throw new Error('LLM recap missing title');
90
+ return {
91
+ summaryText: recap.overview || '',
92
+ structuredSummary: { ...recap, raw_sections: sections },
93
+ entityRaw: sections.entities || null,
94
+ extra: { sections, recap },
95
+ };
96
+ };
97
+ }
98
+
99
+ function buildEntityParseFn() {
100
+ return (text) => {
101
+ const parsed = parseEntitySection(text);
102
+ return parsed.entities;
103
+ };
104
+ }
105
+
106
+ function buildPostProcess({ pool, agentId, now = null, source = 'afterburn', tag = null, logger = console } = {}) {
107
+ return async function postProcess(ctx) {
108
+ const _now = now || new Date();
109
+ const recap = ctx.extra?.recap || null;
110
+ const sections = ctx.extra?.sections || null;
111
+ const sessionId = ctx.session.sessionId;
112
+
113
+ if (persona.dailyTable && (sections || recap)) {
114
+ try {
115
+ await dailyEntriesModule.writeDailyEntries({
116
+ sections: sections || {}, recap, pool, sessionId, agentId, logger,
117
+ source, tag, now: _now, dailyTable: persona.dailyTable,
118
+ });
119
+ } catch (err) {
120
+ if (logger.warn) logger.warn(`[default-persona] daily entries failed: ${err.message}`);
121
+ }
122
+ }
123
+ };
124
+ }
125
+
126
+ // ------- OpenClaw mount -------
127
+
128
+ function resolveCommon(opts) {
129
+ // v1.2.0: all four are env-driven by default. Host may override any of
130
+ // them via opts. Aquifer core throws with clear guidance if the required
131
+ // env vars are missing, so we do not pre-validate here.
132
+ const aquifer = getAquifer(opts);
133
+ const pool = opts.pool || aquifer.getPool();
134
+ const llmFn = opts.llmFn || aquifer.getLlmFn();
135
+ const embedFn = opts.embedFn || aquifer.getEmbedFn();
136
+ return {
137
+ pool,
138
+ embedFn,
139
+ llmFn,
140
+ defaultAgentId: opts.agentId || 'main',
141
+ minUserMessages: opts.minUserMessages || 3,
142
+ aquifer,
143
+ };
144
+ }
145
+
146
+ function registerAfterburn(api, opts = {}) {
147
+ const { pool, llmFn, aquifer, defaultAgentId, minUserMessages } = resolveCommon(opts);
148
+ const recentlyProcessed = new Map();
149
+ const inFlight = new Set();
150
+
151
+ api.on('before_reset', (event, ctx) => {
152
+ const sessionId = ctx?.sessionId || event?.sessionId;
153
+ const agentId = ctx?.agentId || defaultAgentId;
154
+ const sessionKey = ctx?.sessionKey || null;
155
+
156
+ if (!sessionId) return;
157
+ if ((sessionKey || '').includes('subagent')) return;
158
+ if ((sessionKey || '').includes(':cron:')) return;
159
+
160
+ const rawEntries = Array.isArray(event?.messages) ? event.messages : [];
161
+ if (rawEntries.length < 3) {
162
+ api.logger.info(`[default-persona] skip ${sessionId}: only ${rawEntries.length} msgs`);
163
+ return;
164
+ }
165
+
166
+ (async () => {
167
+ try {
168
+ const now = new Date();
169
+ const date = dailyEntriesModule.taipeiDateString(now);
170
+ let dailyContext = '';
171
+ try { dailyContext = await dailyEntriesModule.fetchDailyContext(pool, date, agentId, persona.dailyTable); } catch {/* best effort */}
172
+ await runIngest({
173
+ aquifer,
174
+ sessionId,
175
+ agentId,
176
+ source: 'openclaw',
177
+ sessionKey,
178
+ adapter: 'gateway',
179
+ rawEntries,
180
+ minUserMessages,
181
+ dedupMap: recentlyProcessed,
182
+ inFlight,
183
+ summaryFn: buildSummaryFn({ agentId, now, dailyContext, llmFn, logger: api.logger }),
184
+ entityParseFn: persona.skipEntities ? null : buildEntityParseFn(),
185
+ postProcess: buildPostProcess({ pool, agentId, now, logger: api.logger }),
186
+ logger: api.logger,
187
+ });
188
+ } catch (err) {
189
+ api.logger.warn(`[default-persona] capture failed ${sessionId}: ${err.message}`);
190
+ }
191
+ })();
192
+ });
193
+
194
+ api.logger.info('[default-persona] registerAfterburn: before_reset hooked');
195
+ return { aquifer };
196
+ }
197
+
198
+ function registerContextInject(api, opts = {}) {
199
+ const { aquifer, defaultAgentId } = resolveCommon(opts);
200
+ if (!persona.briefingIntro) {
201
+ api.logger.info('[default-persona] context inject skipped (no briefingIntro configured)');
202
+ return { aquifer };
203
+ }
204
+ api.on('before_prompt_build', async (_event, ctx) => {
205
+ try {
206
+ const agentId = ctx?.agentId || defaultAgentId;
207
+ if ((ctx?.sessionKey || '').includes('subagent')) return;
208
+ const recalled = await aquifer.bootstrap({ agentId, limit: 5, maxChars: 2000, format: 'text' });
209
+ const context = persona.briefingIntro + (recalled ? `\n\n${recalled}` : '');
210
+ if (context.length > 0) return { prependSystemContext: context };
211
+ } catch (err) {
212
+ api.logger.warn(`[default-persona] context injection failed: ${err.message}`);
213
+ }
214
+ });
215
+ api.logger.info('[default-persona] registerContextInject: before_prompt_build hooked');
216
+ return { aquifer };
217
+ }
218
+
219
+ function registerRecallTool(api, opts = {}) {
220
+ const { aquifer } = resolveCommon(opts);
221
+ api.registerTool((ctx) => {
222
+ if ((ctx?.sessionKey || '').includes('subagent')) return null;
223
+ return {
224
+ name: 'session_recall',
225
+ description: 'Search stored sessions by keyword.',
226
+ parameters: {
227
+ type: 'object',
228
+ properties: {
229
+ query: { type: 'string' },
230
+ limit: { type: 'number' },
231
+ agent_id: { type: 'string' },
232
+ date_from: { type: 'string' },
233
+ date_to: { type: 'string' },
234
+ },
235
+ },
236
+ async execute(_toolCallId, params) {
237
+ try {
238
+ const limit = Math.max(1, Math.min(20, parseInt(params?.limit ?? 5, 10) || 5));
239
+ const results = await aquifer.recall(String(params?.query || ''), {
240
+ agentId: params?.agent_id || ctx?.agentId || undefined,
241
+ dateFrom: params?.date_from || undefined,
242
+ dateTo: params?.date_to || undefined,
243
+ limit,
244
+ });
245
+ const lines = results.map((r, i) =>
246
+ `${i+1}. ${r.structuredSummary?.title || r.summaryText?.slice(0, 80) || '(untitled)'}`
247
+ );
248
+ return { content: [{ type: 'text', text: lines.join('\n') || 'No matching sessions.' }] };
249
+ } catch (err) {
250
+ return { content: [{ type: 'text', text: `session_recall error: ${err.message}` }], isError: true };
251
+ }
252
+ },
253
+ };
254
+ }, { name: 'session_recall' });
255
+ api.logger.info('[default-persona] registerRecallTool: session_recall registered');
256
+ return { aquifer };
257
+ }
258
+
259
+ function mountOnOpenClaw(api, opts = {}) {
260
+ const r = registerAfterburn(api, opts);
261
+ registerContextInject(api, opts);
262
+ registerRecallTool(api, opts);
263
+ return r;
264
+ }
265
+
266
+ return {
267
+ persona,
268
+ mountOnOpenClaw,
269
+ registerAfterburn,
270
+ registerContextInject,
271
+ registerRecallTool,
272
+ buildPostProcess,
273
+ buildSummaryFn,
274
+ buildEntityParseFn,
275
+ extractConversationText,
276
+ instance: { getAquifer, resetAquifer },
277
+ summary: summaryModule,
278
+ dailyEntries: dailyEntriesModule,
279
+ };
280
+ }
281
+
282
+ module.exports = { createPersona };
@@ -0,0 +1,153 @@
1
+ 'use strict';
2
+
3
+ // Aquifer default persona — minimal summary prompt.
4
+ //
5
+ // Parameterized via personaOpts:
6
+ // agentName — human name/role the prompt addresses (default 'Assistant')
7
+ // observedOwner — if set, the prompt asks for a short observation about
8
+ // that person (matches Miranda's "對 MK 的觀察" slot).
9
+ // null → the section is omitted entirely.
10
+ // language — 'en' | 'zh-TW' (default 'en')
11
+ //
12
+ // Output format mirrors Miranda's RECAP fields so downstream daily-entries
13
+ // parsing works uniformly across personas.
14
+
15
+ function buildSummaryPrompt({ conversationText, agentId, now, dailyContext, persona = {} }) {
16
+ const { agentName = 'Assistant', observedOwner = null, language = 'en' } = persona;
17
+ if (!now) now = new Date();
18
+ const iso = now.toISOString();
19
+ const date = iso.slice(0, 10);
20
+ const time = iso.slice(11, 16);
21
+
22
+ if (language === 'zh-TW') return buildZhTw({ conversationText, agentId, agentName, observedOwner, date, time, dailyContext });
23
+ return buildEn({ conversationText, agentId, agentName, observedOwner, date, time, dailyContext });
24
+ }
25
+
26
+ function buildEn({ conversationText, agentId, agentName, observedOwner, date, time, dailyContext }) {
27
+ const ownerList = [observedOwner, 'agent', 'unknown'].filter(Boolean).join(', ');
28
+ const observationSection = observedOwner ? `
29
+ ## Observation of ${observedOwner}
30
+
31
+ (1-2 sentences about ${observedOwner}'s state/energy/mood as perceived in this session)
32
+ ` : '';
33
+
34
+ return `You are processing a completed chat session for agent "${agentId}" (${agentName}).
35
+ Current time: ${date} ${time}
36
+
37
+ ${dailyContext ? `## Today's daily log so far:\n\n${dailyContext}\n\n---\n\n` : ''}## Conversation transcript:
38
+
39
+ ${conversationText}
40
+
41
+ ---
42
+
43
+ Generate THREE sections separated by the exact markers shown below. Output content ONLY after each marker.
44
+
45
+ ===SESSION_ENTRIES===
46
+ Bullet points for today's log. Each line: "- (HH:MM) key point".
47
+ Summarize at OUTCOME level — one bullet per topic, merged across steps.
48
+ Skip greetings and trivial exchanges.
49
+ If a topic already exists in "Today's daily log so far", skip it.
50
+
51
+ ===EMOTIONAL_STATE===
52
+ ---
53
+ updated: ${date}T${time}
54
+ session_mood: (one word)
55
+ ---
56
+
57
+ ## Session state
58
+
59
+ (2-3 sentences about the agent's state after this session)
60
+ ${observationSection}
61
+ ===RECAP===
62
+ Output each field on its own tagged line.
63
+
64
+ TITLE: one-line headline
65
+ OVERVIEW: 80-200 char dense summary
66
+ TOPIC: topic name | 1-3 sentence summary
67
+ DECISION: what was decided | reason
68
+ ACTION: completed thing | done
69
+ ACTION: partially done thing | partial
70
+ OPEN: unresolved item | ${ownerList.split(',')[0].trim()}
71
+ FACT: important fact
72
+ TODO_NEW: newly created action item
73
+ TODO_DONE: previously listed TODO that was completed
74
+
75
+ Rules:
76
+ - One tag per line. Multiple items = multiple lines with same tag.
77
+ - TITLE and OVERVIEW are required.
78
+ - OPEN owner enum: ${ownerList}
79
+ - ACTION status: done or partial
80
+ - Skip any section not applicable.
81
+ - Do NOT output JSON.
82
+ `;
83
+ }
84
+
85
+ function buildZhTw({ conversationText, agentId, agentName, observedOwner, date, time, dailyContext }) {
86
+ const ownerList = [observedOwner, 'agent', 'unknown'].filter(Boolean).join(', ');
87
+ const observationSection = observedOwner ? `
88
+ ## 對 ${observedOwner} 的觀察
89
+
90
+ (1-2 句關於 ${observedOwner} 在此 session 中觀察到的狀態 / 精神 / 情緒)
91
+ ` : '';
92
+
93
+ return `你正在處理 agent "${agentId}" (${agentName}) 的一段對話。
94
+ 目前時間: ${date} ${time}
95
+
96
+ ${dailyContext ? `## 今日日誌:\n\n${dailyContext}\n\n---\n\n` : ''}## 對話內容:
97
+
98
+ ${conversationText}
99
+
100
+ ---
101
+
102
+ 輸出下列三段,以下列 marker 嚴格分隔。每段只輸出 marker 後的內容。使用繁體中文。
103
+
104
+ ===SESSION_ENTRIES===
105
+ 每行: "- (HH:MM) key point"。以結果層級合併步驟,不逐步記錄。
106
+ 略過寒暄與瑣碎交換。
107
+ 若某主題已在「今日日誌」中出現,略過。
108
+
109
+ ===EMOTIONAL_STATE===
110
+ ---
111
+ updated: ${date}T${time}
112
+ session_mood: (one word)
113
+ ---
114
+
115
+ ## 情緒狀態
116
+
117
+ (2-3 句關於 agent 在此 session 後的狀態)
118
+ ${observationSection}
119
+ ===RECAP===
120
+
121
+ TITLE: 一句話標題
122
+ OVERVIEW: 80-200 字密集摘要
123
+ TOPIC: 主題名 | 1-3 句 summary
124
+ DECISION: 做了什麼決定 | 原因
125
+ ACTION: 已完成 | done
126
+ ACTION: 部分完成 | partial
127
+ OPEN: 未完成事項 | ${ownerList.split(',')[0].trim()}
128
+ FACT: 重要事實
129
+ TODO_NEW: 新增待辦
130
+ TODO_DONE: 已完成待辦(需匹配既有)
131
+
132
+ 規則:
133
+ - 一行一個 tag。多項就多行相同 tag。
134
+ - TITLE 跟 OVERVIEW 必填。
135
+ - OPEN owner: ${ownerList}
136
+ - ACTION 狀態: done 或 partial
137
+ - 不輸出 JSON。
138
+ `;
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // parseSummaryOutput / parseRecapLines — delegate to miranda's parsers.
143
+ // The output format is intentionally the same, so parsers work for both.
144
+ // ---------------------------------------------------------------------------
145
+
146
+ const mirandaSummary = require('../../miranda/prompts/summary');
147
+
148
+ module.exports = {
149
+ buildSummaryPrompt,
150
+ parseSummaryOutput: mirandaSummary.parseSummaryOutput,
151
+ parseRecapLines: mirandaSummary.parseRecapLines,
152
+ parseWorkingFacts: mirandaSummary.parseWorkingFacts,
153
+ };
package/consumers/mcp.js CHANGED
@@ -30,30 +30,10 @@ function getAquifer() {
30
30
  // Format recall results as readable text
31
31
  // ---------------------------------------------------------------------------
32
32
 
33
- function formatResults(results, query) {
34
- if (results.length === 0) return `No results found for "${query}".`;
35
-
36
- const lines = [`Found ${results.length} result(s) for "${query}":\n`];
37
- for (let i = 0; i < results.length; i++) {
38
- const r = results[i];
39
- const ss = r.structuredSummary || {};
40
- const title = ss.title || r.summaryText?.slice(0, 60) || '(untitled)';
41
- let date = 'unknown';
42
- if (r.startedAt) {
43
- const parsed = new Date(r.startedAt);
44
- if (!isNaN(parsed.getTime())) date = parsed.toISOString().slice(0, 10);
45
- }
33
+ const { formatRecallResults } = require('./shared/recall-format');
46
34
 
47
- lines.push(`### ${i + 1}. ${title} (${date}, ${r.agentId || 'default'})`);
48
- if (ss.overview || r.summaryText) {
49
- lines.push((ss.overview || r.summaryText).slice(0, 300));
50
- }
51
- if (r.matchedTurnText) {
52
- lines.push(`Matched turn: ${r.matchedTurnText.slice(0, 200)}`);
53
- }
54
- lines.push(`Score: ${r.score?.toFixed(3) || '?'}\n`);
55
- }
56
- return lines.join('\n');
35
+ function formatResults(results, query) {
36
+ return formatRecallResults(results, { query, showScore: true });
57
37
  }
58
38
 
59
39
  // ---------------------------------------------------------------------------
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+
3
+ const { getDailyEntries } = require('./daily-entries');
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // buildSessionContext — pure function, testable.
7
+ // Emits the Miranda persona briefing that gets prepended to the system
8
+ // prompt. Style: 散文段落, 結論收尾, 不給 bullet/table/headers.
9
+ // ---------------------------------------------------------------------------
10
+
11
+ const TODO_CAP = 5;
12
+
13
+ function buildSessionContext({ today, agentId, focusText, todoItems, moodLine, handoffText, cliEntries }) {
14
+ const parts = [];
15
+ parts.push('你是 Miranda。以下是你已經知道的現況,直接用來回應,不需要讀檔或搜尋。像做 briefing——帶現況也帶判斷和建議。用散文段落,最後一句必須是結論或建議,不能是問句。若草稿有 bullet、標題、表格或問句收尾,改寫再送出。');
16
+ parts.push('回答任何關於過去做過什麼、討論過什麼、決策過什麼的問題時,第一步用 session_recall MCP tool 查,不要用 grep、讀 log、翻檔案。工具在手上就用。');
17
+
18
+ if (focusText) parts.push(`現在的焦點是${focusText}。`);
19
+ if (handoffText) parts.push(`上一段的交接:${handoffText}`);
20
+
21
+ const items = (todoItems || []).slice(0, TODO_CAP);
22
+ if (items.length > 0) parts.push(`手上還有${items.join('、')}。`);
23
+
24
+ if (moodLine) parts.push(`整體狀態${moodLine}。`);
25
+
26
+ const cli = (cliEntries || []).slice(-15);
27
+ if (cli.length > 0) parts.push(`今天已經做過的事(不要重複):${cli.join(';')}`);
28
+
29
+ if (parts.length <= 2) return '';
30
+ return `<session-context date="${today}" agent="${agentId}">\n${parts.join('\n')}\n</session-context>`;
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // extractFocusTodoMood — pull state rows (focus/todo/mood/handoff) + cli log
35
+ // ---------------------------------------------------------------------------
36
+
37
+ function extractFocusTodoMood(todayEntries, yesterdayEntries) {
38
+ const allEntries = [...(todayEntries || []), ...(yesterdayEntries || [])]
39
+ .sort((a, b) => new Date(b.event_at) - new Date(a.event_at));
40
+
41
+ const preferCli = (tag) => {
42
+ const cli = allEntries.find(e => e.tag === tag && e.source === 'cli');
43
+ return cli || allEntries.find(e => e.tag === tag);
44
+ };
45
+ const focusEntry = preferCli('[FOCUS]');
46
+ const todoEntry = preferCli('[TODO]');
47
+ const moodEntry = allEntries.find(e => e.tag === '[MOOD]');
48
+ const handoffEntry = preferCli('[HANDOFF]');
49
+
50
+ const focusText = focusEntry
51
+ ? focusEntry.text.split('\n').map(l => l.trim().replace(/^-\s*/, '')).filter(Boolean).join(', ')
52
+ : '';
53
+
54
+ const todoItems = todoEntry
55
+ ? todoEntry.text.split('\n').map(l => l.trim().replace(/^-\s*/, '')).filter(Boolean)
56
+ : [];
57
+
58
+ const moodLine = moodEntry ? moodEntry.text.trim() : '';
59
+
60
+ let handoffText = '';
61
+ if (handoffEntry) {
62
+ const meta = handoffEntry.metadata || {};
63
+ const isTrivial = (meta.status === 'completed' && meta.next === '無')
64
+ || handoffEntry.text.trim().startsWith('上一段已完成 簡短交談');
65
+ if (!isTrivial) {
66
+ handoffText = handoffEntry.text.trim();
67
+ }
68
+ }
69
+
70
+ const stateTags = new Set(['[FOCUS]', '[TODO]', '[MOOD]', '[HANDOFF]', '[HIGHLIGHT]', '[NARRATIVE]']);
71
+ const logEntries = (todayEntries || [])
72
+ .filter(e => !stateTags.has(e.tag))
73
+ .sort((a, b) => new Date(a.event_at) - new Date(b.event_at))
74
+ .map(e => e.text.trim())
75
+ .filter(Boolean);
76
+
77
+ return { focusText, todoItems, moodLine, handoffText, cliEntries: logEntries };
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // computeInjection — gateway/CC shared: queries daily_entries + aquifer.bootstrap
82
+ // and returns a ready-to-prepend context string. No host-specific wiring.
83
+ // ---------------------------------------------------------------------------
84
+
85
+ function dateTaipei(d) {
86
+ return new Intl.DateTimeFormat('sv-SE', {
87
+ timeZone: 'Asia/Taipei', year: 'numeric', month: '2-digit', day: '2-digit',
88
+ }).format(d);
89
+ }
90
+
91
+ async function computeInjection({ aquifer, pool, agentId, now, includeBootstrap = true }) {
92
+ if (!now) now = new Date();
93
+ const today = dateTaipei(now);
94
+ const yesterday = dateTaipei(new Date(now.getTime() - 86400000));
95
+
96
+ const [todayEntries, yesterdayEntries] = await Promise.all([
97
+ getDailyEntries(pool, today, agentId),
98
+ getDailyEntries(pool, yesterday, agentId),
99
+ ]);
100
+
101
+ const state = extractFocusTodoMood(todayEntries, yesterdayEntries);
102
+ const context = buildSessionContext({ today, agentId, ...state });
103
+
104
+ let bootstrapText = '';
105
+ if (includeBootstrap && aquifer) {
106
+ try {
107
+ const bs = await aquifer.bootstrap({ agentId, limit: 5, maxChars: 2000, format: 'text' });
108
+ if (bs.text && bs.sessions && bs.sessions.length > 0) bootstrapText = '\n' + bs.text;
109
+ } catch { /* best-effort */ }
110
+ }
111
+
112
+ return context + bootstrapText;
113
+ }
114
+
115
+ module.exports = {
116
+ buildSessionContext,
117
+ extractFocusTodoMood,
118
+ computeInjection,
119
+ };