@shadowforge0/aquifer-memory 1.2.1 → 1.5.8

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 (43) hide show
  1. package/README.md +8 -9
  2. package/consumers/cli.js +11 -1
  3. package/consumers/default/index.js +17 -4
  4. package/consumers/mcp.js +21 -0
  5. package/consumers/miranda/index.js +15 -4
  6. package/consumers/miranda/profile.json +145 -0
  7. package/consumers/miranda/recall-format.js +5 -3
  8. package/consumers/miranda/render-daily-md.js +186 -0
  9. package/consumers/shared/config.js +8 -0
  10. package/consumers/shared/factory.js +2 -1
  11. package/consumers/shared/llm.js +1 -1
  12. package/consumers/shared/recall-format.js +21 -1
  13. package/core/aquifer.js +693 -87
  14. package/core/artifacts.js +174 -0
  15. package/core/bundles.js +400 -0
  16. package/core/consolidation.js +340 -0
  17. package/core/decisions.js +164 -0
  18. package/core/entity-state.js +483 -0
  19. package/core/errors.js +97 -0
  20. package/core/handoff.js +153 -0
  21. package/core/insights.js +499 -0
  22. package/core/mcp-manifest.js +131 -0
  23. package/core/narratives.js +212 -0
  24. package/core/profiles.js +171 -0
  25. package/core/state.js +163 -0
  26. package/core/storage.js +82 -5
  27. package/core/timeline.js +152 -0
  28. package/index.js +14 -0
  29. package/package.json +1 -1
  30. package/pipeline/extract-state-changes.js +205 -0
  31. package/schema/001-base.sql +186 -16
  32. package/schema/002-entities.sql +35 -1
  33. package/schema/004-completion.sql +391 -0
  34. package/schema/005-entity-state-history.sql +87 -0
  35. package/schema/006-insights.sql +138 -0
  36. package/scripts/diagnose-fts-zh.js +37 -4
  37. package/scripts/drop-entity-state-history.sql +17 -0
  38. package/scripts/drop-insights.sql +12 -0
  39. package/scripts/extract-insights-from-recent-sessions.js +315 -0
  40. package/scripts/find-dburl-hints.js +29 -0
  41. package/scripts/queries.json +45 -0
  42. package/scripts/retro-recall-bench.js +409 -0
  43. package/scripts/sample-bench-queries.sql +75 -0
package/README.md CHANGED
@@ -333,18 +333,17 @@ Built-in entity extraction and relationship tracking:
333
333
 
334
334
  ## Benchmark: LongMemEval
335
335
 
336
- We tested Aquifer's retrieval pipeline on [LongMemEval_S](https://github.com/xiaowu0162/LongMemEval) — 470 questions across 19,195 sessions (98,845 turn embeddings).
336
+ We tested Aquifer's retrieval pipeline on [LongMemEval_S](https://github.com/xiaowu0162/LongMemEval) — 470 questions across 19,195 sessions with 98,795 turn embeddings. Per-question haystack scoping (matching the official protocol), bge-m3 embeddings via OpenRouter.
337
337
 
338
- **Setup:** Per-question haystack scoping (matching official methodology), bge-m3 embeddings via OpenRouter, turn-level user-only embedding.
338
+ | Pipeline | R@1 | R@3 | R@5 | R@10 |
339
+ |----------|-----|-----|-----|------|
340
+ | Turn-only (cosine) | 89.5% | 96.6% | 98.1% | 98.9% |
341
+ | Three-way hybrid (FTS + session_emb + turn_emb → RRF) | 79.2% | 94.0% | 97.7% | 98.9% |
342
+ | **Hybrid + Cohere Rerank v3.5 (top-30)** | **96.0%** | **98.5%** | **99.3%** | **99.8%** |
339
343
 
340
- | Metric | Aquifer (bge-m3) |
341
- |--------|-----------------|
342
- | R@1 | 89.6% |
343
- | R@3 | 96.6% |
344
- | R@5 | 98.1% |
345
- | R@10 | 98.9% |
344
+ Measured 2026-04-19 on Aquifer 1.2.1.
346
345
 
347
- **Key finding:** Turn-level embedding is the main driver going from session-level (R@1=26.8%) to turn-level (R@1=89.6%) is a 3x improvement.
346
+ **Key findings.** Turn-level embedding alone beats session-level (26.8% 89.5% R@1, a 3× improvement). Hybrid fusion adds robustness at R@3-R@10 but trades R@1 because FTS + session-level signals spread the top candidate across adjacent sessions. Re-ranking the hybrid top-30 with a cross-encoder (Cohere Rerank v3.5) wins back the top-1 precision and then some — +16.9pt R@1 over hybrid baseline, and 6.5pt above pure turn-level cosine. That's the production pipeline Aquifer ships by default when a reranker is configured.
348
347
 
349
348
  ### Multi-Tenant
350
349
 
package/consumers/cli.js CHANGED
@@ -43,7 +43,7 @@ function parsePositiveInt(value, fallback) {
43
43
  function parseArgs(argv) {
44
44
  const args = { _: [], flags: {} };
45
45
  // Flags that take a value (not boolean)
46
- const VALUE_FLAGS = new Set(['limit', 'agent-id', 'source', 'date-from', 'date-to', 'output', 'format', 'config', 'status', 'concurrency', 'entities', 'entity-mode', 'session-id', 'verdict', 'note', 'db', 'since', 'min-messages', 'lookback-days', 'max-chars']);
46
+ const VALUE_FLAGS = new Set(['limit', 'agent-id', 'source', 'date-from', 'date-to', 'output', 'format', 'config', 'status', 'concurrency', 'entities', 'entity-mode', 'session-id', 'verdict', 'note', 'db', 'since', 'min-messages', 'lookback-days', 'max-chars', 'out']);
47
47
  for (let i = 0; i < argv.length; i++) {
48
48
  if (argv[i] === '--') { args._.push(...argv.slice(i + 1)); break; }
49
49
  if (argv[i].startsWith('--')) {
@@ -360,6 +360,16 @@ Options:
360
360
  return;
361
361
  }
362
362
 
363
+ // mcp-contract: write canonical MCP tool manifest to disk. No Aquifer
364
+ // instance needed — manifest is static. Default path /tmp/aquifer-mcp-contract.json.
365
+ if (command === 'mcp-contract') {
366
+ const { writeMcpManifestFile } = require('../index');
367
+ const outPath = args.flags.out || '/tmp/aquifer-mcp-contract.json';
368
+ const written = writeMcpManifestFile(outPath);
369
+ console.log(`Wrote MCP manifest to ${written}`);
370
+ return;
371
+ }
372
+
363
373
  // All other commands need an Aquifer instance
364
374
  const configOverrides = {};
365
375
  if (args.flags.config) {
@@ -222,26 +222,39 @@ function createPersona(personaOpts = {}) {
222
222
  if ((ctx?.sessionKey || '').includes('subagent')) return null;
223
223
  return {
224
224
  name: 'session_recall',
225
- description: 'Search stored sessions by keyword.',
225
+ description: 'Search stored sessions by keyword or natural language. Use entities when the user names specific people, projects, files, tools, or concepts; use entity_mode="all" when every named entity must co-occur (default "any" boosts). Use mode to force fts/vector/hybrid (default hybrid).',
226
226
  parameters: {
227
227
  type: 'object',
228
228
  properties: {
229
- query: { type: 'string' },
229
+ query: { type: 'string', minLength: 1, description: 'Non-empty keyword or natural-language query' },
230
230
  limit: { type: 'number' },
231
231
  agent_id: { type: 'string' },
232
+ source: { type: 'string' },
232
233
  date_from: { type: 'string' },
233
234
  date_to: { type: 'string' },
235
+ entities: { type: 'array', items: { type: 'string' }, description: 'Named entities (person/project/tool/file)' },
236
+ entity_mode: { type: 'string', enum: ['any', 'all'], description: '"any" boosts; "all" hard-filters to sessions containing every entity' },
237
+ mode: { type: 'string', enum: ['fts', 'hybrid', 'vector'], description: 'Recall strategy, default hybrid' },
234
238
  },
235
239
  },
236
240
  async execute(_toolCallId, params) {
237
241
  try {
238
242
  const limit = Math.max(1, Math.min(20, parseInt(params?.limit ?? 5, 10) || 5));
239
- const results = await aquifer.recall(String(params?.query || ''), {
243
+ const recallOpts = {
240
244
  agentId: params?.agent_id || ctx?.agentId || undefined,
245
+ source: params?.source || undefined,
241
246
  dateFrom: params?.date_from || undefined,
242
247
  dateTo: params?.date_to || undefined,
243
248
  limit,
244
- });
249
+ };
250
+ if (Array.isArray(params?.entities) && params.entities.length > 0) {
251
+ recallOpts.entities = params.entities;
252
+ recallOpts.entityMode = params?.entity_mode || 'any';
253
+ }
254
+ if (params?.mode === 'fts' || params?.mode === 'hybrid' || params?.mode === 'vector') {
255
+ recallOpts.mode = params.mode;
256
+ }
257
+ const results = await aquifer.recall(String(params?.query || ''), recallOpts);
245
258
  const lines = results.map((r, i) =>
246
259
  `${i+1}. ${r.structuredSummary?.title || r.summaryText?.slice(0, 80) || '(untitled)'}`
247
260
  );
package/consumers/mcp.js CHANGED
@@ -225,6 +225,27 @@ async function main() {
225
225
  process.on('SIGINT', cleanup);
226
226
  process.on('SIGTERM', cleanup);
227
227
 
228
+ // Startup handshake: instantiate aquifer + drive init() before MCP transport
229
+ // so schema state is resolved before the first tool call. apply-mode failure
230
+ // is fatal (exit non-zero) — an MCP instance with pending DDL would serve
231
+ // tool traffic against a stale schema and surface confusing errors later.
232
+ const aquifer = getAquifer();
233
+ const envelope = await aquifer.init();
234
+ if (!envelope.ready) {
235
+ const err = envelope.error || { code: 'AQ_MIGRATION_NOT_READY', message: 'aquifer.init() did not reach ready state' };
236
+ process.stderr.write(
237
+ `[aquifer-mcp] startup aborted: migrationMode=${envelope.migrationMode} ` +
238
+ `memoryMode=${envelope.memoryMode} pending=${envelope.pendingMigrations.length} ` +
239
+ `error=${err.code || 'unknown'}: ${err.message}\n`
240
+ );
241
+ await aquifer.close().catch(() => {});
242
+ process.exit(1);
243
+ }
244
+ process.stderr.write(
245
+ `[aquifer-mcp] init ok: mode=${envelope.migrationMode} applied=${envelope.appliedMigrations.length} ` +
246
+ `pending=${envelope.pendingMigrations.length} durationMs=${envelope.durationMs}\n`
247
+ );
248
+
228
249
  const transport = new StdioServerTransport();
229
250
  await server.connect(transport);
230
251
 
@@ -275,13 +275,16 @@ function registerRecallTool(api, opts = {}) {
275
275
  if ((ctx?.sessionKey || '').includes('subagent')) return null;
276
276
  return {
277
277
  name: 'session_recall',
278
- description: '搜尋歷史 session 的摘要和對話記錄。可按關鍵字、日期範圍、agent 搜尋。',
278
+ description: '搜尋歷史 session 的摘要和對話記錄。當問題明確提到具體人名、專案、工具、檔名時,傳 entities;只想保留全部命中的 session 用 entity_mode="all",否則 "any" 是 boost。mode 可選 "fts"/"vector"/"hybrid",default hybrid。',
279
279
  parameters: {
280
280
  type: 'object',
281
281
  properties: {
282
- query: { type: 'string', description: '搜尋關鍵字(可空,空時按時間排序)' },
282
+ query: { type: 'string', description: '搜尋關鍵字或自然語言描述(必填非空)', minLength: 1 },
283
283
  date_from: { type: 'string' }, date_to: { type: 'string' },
284
284
  agent_id: { type: 'string' }, source: { type: 'string' },
285
+ entities: { type: 'array', items: { type: 'string' }, description: '具名 entity 清單(人/專案/工具/檔名)' },
286
+ entity_mode: { type: 'string', enum: ['any', 'all'], description: '"any" boost / "all" 硬過濾必含全部 entity' },
287
+ mode: { type: 'string', enum: ['fts', 'hybrid', 'vector'], description: 'recall 模式,default hybrid' },
285
288
  detail: { type: 'string' },
286
289
  limit: { type: 'number' },
287
290
  },
@@ -289,13 +292,21 @@ function registerRecallTool(api, opts = {}) {
289
292
  async execute(_toolCallId, params) {
290
293
  try {
291
294
  const limit = Math.max(1, Math.min(20, parseInt(params?.limit ?? 5, 10) || 5));
292
- const results = await aquifer.recall(String(params?.query || ''), {
295
+ const recallOpts = {
293
296
  agentId: params?.agent_id || ctx?.agentId || undefined,
294
297
  source: params?.source || undefined,
295
298
  dateFrom: params?.date_from || undefined,
296
299
  dateTo: params?.date_to || undefined,
297
300
  limit,
298
- });
301
+ };
302
+ if (Array.isArray(params?.entities) && params.entities.length > 0) {
303
+ recallOpts.entities = params.entities;
304
+ recallOpts.entityMode = params?.entity_mode || 'any';
305
+ }
306
+ if (params?.mode === 'fts' || params?.mode === 'hybrid' || params?.mode === 'vector') {
307
+ recallOpts.mode = params.mode;
308
+ }
309
+ const results = await aquifer.recall(String(params?.query || ''), recallOpts);
299
310
  const text = mirandaRecallFormat.formatRecallResults(results.map(r => ({
300
311
  sessionId: r.sessionId, agentId: r.agentId, source: r.source,
301
312
  startedAt: r.startedAt, summaryText: r.summaryText,
@@ -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
+ }
@@ -3,7 +3,7 @@
3
3
  // Miranda zh-TW recall formatter — overrides the shared default renderers
4
4
  // to produce narrative-style output instead of score-flavored markdown.
5
5
 
6
- const { createRecallFormatter, truncate, formatDateIso } = require('../shared/recall-format');
6
+ const { createRecallFormatter, truncate, formatDateIso, formatRelativeZhTw } = require('../shared/recall-format');
7
7
 
8
8
  function formatTopicLines(topics) {
9
9
  if (!Array.isArray(topics) || topics.length === 0) return '- 無';
@@ -32,10 +32,12 @@ function coalesceTitle(structuredSummary, summaryText) {
32
32
  const mirandaRenderers = {
33
33
  empty: () => '找不到符合條件的 session。',
34
34
  header: () => null,
35
- title: (r, i) => {
35
+ title: (r, i, ctx) => {
36
36
  const ss = r.structuredSummary || {};
37
37
  const title = coalesceTitle(ss, r.summaryText);
38
- const date = formatDateIso(r.startedAt);
38
+ const iso = formatDateIso(r.startedAt);
39
+ const rel = formatRelativeZhTw(r.startedAt, ctx?.now);
40
+ const date = rel ? `${rel}(${iso})` : iso;
39
41
  const agent = r.agentId || r.agent_id || 'main';
40
42
  return `### ${i + 1}. ${title}\n**Agent**: ${agent} | **Date**: ${date}`;
41
43
  },
@@ -0,0 +1,186 @@
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 };
@@ -42,6 +42,12 @@ const DEFAULTS = {
42
42
  timeoutMs: 2000,
43
43
  maxRetries: 1,
44
44
  },
45
+ migrations: {
46
+ mode: 'apply', // 'apply' | 'check' | 'off'
47
+ lockTimeoutMs: 30000,
48
+ startupTimeoutMs: 60000,
49
+ onEvent: null, // (event) => void, optional observability hook
50
+ },
45
51
  };
46
52
 
47
53
  // ---------------------------------------------------------------------------
@@ -77,6 +83,8 @@ const ENV_MAP = [
77
83
  ['AQUIFER_RERANK_TOP_K', 'rerank.topK', Number],
78
84
  ['AQUIFER_RERANK_MAX_CHARS', 'rerank.maxChars', Number],
79
85
  ['AQUIFER_RERANK_TIMEOUT_MS','rerank.timeoutMs', Number],
86
+ ['AQUIFER_MIGRATIONS_MODE', 'migrations.mode'],
87
+ ['AQUIFER_MIGRATION_LOCK_TIMEOUT_MS', 'migrations.lockTimeoutMs', Number],
80
88
  ];
81
89
 
82
90
  // ---------------------------------------------------------------------------
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { Pool } = require('pg');
4
- const { createAquifer, createEmbedder, createReranker } = require('../../index');
4
+ const { createAquifer, createEmbedder } = require('../../index');
5
5
  const { loadConfig } = require('./config');
6
6
  const { createLlmFn } = require('./llm');
7
7
 
@@ -90,6 +90,7 @@ function createAquiferFromConfig(overrides) {
90
90
  entities: config.entities,
91
91
  rank: config.rank,
92
92
  rerank: rerankOpts,
93
+ migrations: config.migrations,
93
94
  });
94
95
 
95
96
  return aquifer;
@@ -31,7 +31,7 @@ function httpRequest(url, options, body) {
31
31
  }
32
32
  try {
33
33
  finish(resolve, JSON.parse(raw));
34
- } catch (e) {
34
+ } catch {
35
35
  finish(reject, new Error(`Invalid JSON from LLM (${raw.length} bytes)`));
36
36
  }
37
37
  });
@@ -18,6 +18,25 @@ function formatDateIso(value) {
18
18
  return Number.isNaN(d.getTime()) ? 'unknown' : d.toISOString().slice(0, 10);
19
19
  }
20
20
 
21
+ // Humanize a past timestamp into zh-TW relative form (e.g. "3 天前", "昨天").
22
+ // Bucketed on raw ms-diff — good enough for model intuition, not calendar-precise.
23
+ // Returns null for invalid / future timestamps so callers can fall back.
24
+ function formatRelativeZhTw(value, now) {
25
+ if (!value) return null;
26
+ const t = new Date(value).getTime();
27
+ if (Number.isNaN(t)) return null;
28
+ const nowMs = typeof now === 'number' ? now : Date.now();
29
+ const diffMs = nowMs - t;
30
+ if (diffMs < 0) return null;
31
+ const day = 86400000;
32
+ if (diffMs < day) return '今天';
33
+ if (diffMs < 2 * day) return '昨天';
34
+ if (diffMs < 7 * day) return `${Math.floor(diffMs / day)} 天前`;
35
+ if (diffMs < 30 * day) return `${Math.floor(diffMs / (7 * day))} 週前`;
36
+ if (diffMs < 365 * day) return `${Math.floor(diffMs / (30 * day))} 個月前`;
37
+ return `${Math.floor(diffMs / (365 * day))} 年前`;
38
+ }
39
+
21
40
  // Default English renderers --------------------------------------------------
22
41
 
23
42
  const defaultRenderers = {
@@ -63,7 +82,7 @@ function createRecallFormatter(overrides = {}) {
63
82
 
64
83
  return function format(results, opts = {}) {
65
84
  const safeResults = Array.isArray(results) ? results : [];
66
- const ctx = { query: opts.query || null, results: safeResults };
85
+ const ctx = { query: opts.query || null, results: safeResults, now: opts.now };
67
86
 
68
87
  if (safeResults.length === 0) {
69
88
  return r.empty(ctx);
@@ -106,5 +125,6 @@ module.exports = {
106
125
  formatRecallResults,
107
126
  truncate,
108
127
  formatDateIso,
128
+ formatRelativeZhTw,
109
129
  defaultRenderers,
110
130
  };