metame-cli 1.4.33 → 1.5.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 (44) hide show
  1. package/README.md +187 -48
  2. package/index.js +148 -9
  3. package/package.json +6 -3
  4. package/scripts/daemon-admin-commands.js +254 -9
  5. package/scripts/daemon-agent-commands.js +64 -6
  6. package/scripts/daemon-agent-tools.js +26 -5
  7. package/scripts/daemon-bridges.js +110 -20
  8. package/scripts/daemon-claude-engine.js +704 -268
  9. package/scripts/daemon-command-router.js +24 -8
  10. package/scripts/daemon-default.yaml +28 -4
  11. package/scripts/daemon-engine-runtime.js +275 -0
  12. package/scripts/daemon-exec-commands.js +10 -4
  13. package/scripts/daemon-notify.js +37 -1
  14. package/scripts/daemon-runtime-lifecycle.js +2 -1
  15. package/scripts/daemon-session-commands.js +52 -4
  16. package/scripts/daemon-session-store.js +2 -1
  17. package/scripts/daemon-task-scheduler.js +87 -28
  18. package/scripts/daemon-user-acl.js +26 -9
  19. package/scripts/daemon.js +81 -17
  20. package/scripts/distill.js +323 -18
  21. package/scripts/docs/agent-guide.md +12 -0
  22. package/scripts/docs/maintenance-manual.md +119 -0
  23. package/scripts/docs/pointer-map.md +88 -0
  24. package/scripts/feishu-adapter.js +6 -1
  25. package/scripts/hooks/stop-session-capture.js +243 -0
  26. package/scripts/memory-extract.js +100 -5
  27. package/scripts/memory-nightly-reflect.js +196 -11
  28. package/scripts/memory.js +134 -3
  29. package/scripts/mentor-engine.js +405 -0
  30. package/scripts/platform.js +2 -0
  31. package/scripts/providers.js +169 -21
  32. package/scripts/schema.js +12 -0
  33. package/scripts/session-analytics.js +245 -12
  34. package/scripts/skill-changelog.js +245 -0
  35. package/scripts/skill-evolution.js +288 -5
  36. package/scripts/usage-classifier.js +1 -1
  37. package/scripts/daemon-admin-commands.test.js +0 -333
  38. package/scripts/daemon-task-envelope.test.js +0 -59
  39. package/scripts/daemon-task-scheduler.test.js +0 -106
  40. package/scripts/reliability-core.test.js +0 -280
  41. package/scripts/skill-evolution.test.js +0 -113
  42. package/scripts/task-board.test.js +0 -83
  43. package/scripts/test_daemon.js +0 -1407
  44. package/scripts/utils.test.js +0 -192
@@ -0,0 +1,243 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * MetaMe Stop Hook — Session Event Logger + Tool Failure Capture
5
+ *
6
+ * Runs as a Claude Code "Stop" hook.
7
+ * On each turn end:
8
+ * 1. Appends a lightweight session event to session_events.jsonl
9
+ * 2. Reads the tail of the transcript file to extract tool failures (is_error: true)
10
+ * and appends them to skill_signals.jsonl
11
+ *
12
+ * Performance target: < 50ms total. Only reads last TAIL_BYTES of transcript.
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const os = require('os');
18
+
19
+ const METAME_DIR = path.join(os.homedir(), '.metame');
20
+ const SESSION_EVENTS = path.join(METAME_DIR, 'session_events.jsonl');
21
+ const SKILL_SIGNALS = path.join(METAME_DIR, 'skill_signals.jsonl');
22
+
23
+ // Only read the last N bytes of the transcript to stay under 50ms.
24
+ // 20KB covers ~10-20 conversation turns — enough to capture recent failures.
25
+ const TAIL_BYTES = 20 * 1024;
26
+
27
+ // Cap signal file sizes to prevent unbounded growth.
28
+ const MAX_SESSION_EVENTS_LINES = 2000;
29
+ const MAX_SKILL_SIGNALS_LINES = 500;
30
+
31
+ // Deduplicate: remember tool_use_ids we already captured (within this invocation).
32
+ // Cross-invocation dedup uses the session_events timestamp as a watermark.
33
+ const capturedIds = new Set();
34
+
35
+ let input = '';
36
+ process.stdin.setEncoding('utf8');
37
+ process.stdin.on('data', (chunk) => { input += chunk; });
38
+ process.stdin.on('end', () => {
39
+ try {
40
+ const data = JSON.parse(input);
41
+ const now = new Date().toISOString();
42
+
43
+ fs.mkdirSync(METAME_DIR, { recursive: true });
44
+
45
+ // ── 1. Session event (lightweight metadata) ──
46
+ const sessionEntry = {
47
+ ts: now,
48
+ session_id: data.session_id || null,
49
+ cwd: data.cwd || null,
50
+ hint: (data.last_assistant_message || '').slice(0, 200),
51
+ };
52
+ appendWithCap(SESSION_EVENTS, sessionEntry, MAX_SESSION_EVENTS_LINES);
53
+
54
+ // ── 2. Tool failure extraction from transcript tail ──
55
+ const transcriptPath = data.transcript_path;
56
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) {
57
+ process.exit(0);
58
+ }
59
+
60
+ const stat = fs.statSync(transcriptPath);
61
+ if (stat.size === 0) {
62
+ process.exit(0);
63
+ }
64
+
65
+ const readSize = Math.min(stat.size, TAIL_BYTES);
66
+ const buf = Buffer.alloc(readSize);
67
+ const fd = fs.openSync(transcriptPath, 'r');
68
+ try {
69
+ fs.readSync(fd, buf, 0, readSize, Math.max(0, stat.size - readSize));
70
+ } finally {
71
+ fs.closeSync(fd);
72
+ }
73
+
74
+ const tail = buf.toString('utf8');
75
+ // The first line may be truncated (we read from mid-file), skip it.
76
+ const lines = tail.split('\n');
77
+ if (lines.length > 1) {
78
+ lines.shift();
79
+ }
80
+
81
+ // Single-pass: build tool_use_id → tool_name map + collect error signals.
82
+ const toolNameMap = new Map();
83
+ const newSignals = [];
84
+
85
+ for (const line of lines) {
86
+ if (!line.trim()) continue;
87
+ try {
88
+ const entry = JSON.parse(line);
89
+ const msg = entry.message;
90
+ if (!msg || !Array.isArray(msg.content)) continue;
91
+
92
+ for (const block of msg.content) {
93
+ // Index tool_use entries for name lookup.
94
+ if (block.type === 'tool_use' && block.id) {
95
+ toolNameMap.set(block.id, block.name || 'unknown');
96
+ }
97
+ // Collect tool failures.
98
+ if (
99
+ block.type === 'tool_result' &&
100
+ block.is_error === true &&
101
+ block.tool_use_id &&
102
+ !capturedIds.has(block.tool_use_id)
103
+ ) {
104
+ capturedIds.add(block.tool_use_id);
105
+
106
+ const errorContent = typeof block.content === 'string'
107
+ ? block.content
108
+ : Array.isArray(block.content)
109
+ ? block.content.map(c => (typeof c === 'string' ? c : c.text || '')).join('\n')
110
+ : JSON.stringify(block.content);
111
+
112
+ newSignals.push({
113
+ ts: now,
114
+ type: 'tool_failure',
115
+ tool_use_id: block.tool_use_id,
116
+ error: errorContent.slice(0, 500),
117
+ session_id: data.session_id || null,
118
+ cwd: data.cwd || null,
119
+ });
120
+ }
121
+ }
122
+ } catch {
123
+ // Skip malformed lines (expected for the first truncated line).
124
+ }
125
+ }
126
+
127
+ // Resolve tool names from the map built in the same pass.
128
+ for (const signal of newSignals) {
129
+ signal.tool = toolNameMap.get(signal.tool_use_id) || 'unknown';
130
+ }
131
+
132
+ // Only load watermark and write signals if there are failures to process.
133
+ if (newSignals.length > 0) {
134
+ const watermark = loadWatermark(data.session_id);
135
+ const fresh = watermark
136
+ ? newSignals.filter(s => !watermark.has(s.tool_use_id))
137
+ : newSignals;
138
+
139
+ if (fresh.length > 0) {
140
+ // Batch append: single write for all signals.
141
+ const batch = fresh.map(s => JSON.stringify(s)).join('\n') + '\n';
142
+ fs.appendFileSync(SKILL_SIGNALS, batch);
143
+ capFileIfNeeded(SKILL_SIGNALS, MAX_SKILL_SIGNALS_LINES);
144
+ saveWatermark(data.session_id, capturedIds);
145
+ }
146
+ }
147
+
148
+ // Probabilistic cleanup of stale watermark files (1 in 50 invocations).
149
+ if (Math.random() < 0.02) {
150
+ cleanOldWatermarks(7 * 24 * 60 * 60 * 1000);
151
+ }
152
+
153
+ } catch (e) {
154
+ // Never block the user's workflow. Log to stderr for diagnostics.
155
+ try { process.stderr.write(`[metame-stop-hook] ${e.message}\n`); } catch {}
156
+ }
157
+ process.exit(0);
158
+ });
159
+
160
+ /**
161
+ * Append a JSON entry to a file, then check cap.
162
+ */
163
+ function appendWithCap(filePath, entry, maxLines) {
164
+ fs.appendFileSync(filePath, JSON.stringify(entry) + '\n');
165
+ capFileIfNeeded(filePath, maxLines);
166
+ }
167
+
168
+ /**
169
+ * Amortized cap check: only trim when file size suggests overflow.
170
+ */
171
+ function capFileIfNeeded(filePath, maxLines) {
172
+ try {
173
+ const stat = fs.statSync(filePath);
174
+ if (stat.size > maxLines * 250) {
175
+ const content = fs.readFileSync(filePath, 'utf8');
176
+ const allLines = content.split('\n').filter(Boolean);
177
+ if (allLines.length > maxLines) {
178
+ fs.writeFileSync(filePath, allLines.slice(-maxLines).join('\n') + '\n');
179
+ }
180
+ }
181
+ } catch {
182
+ // Non-fatal.
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Delete watermark files older than maxAge (ms).
188
+ */
189
+ function cleanOldWatermarks(maxAge) {
190
+ const wmDir = path.join(METAME_DIR, '.hook_watermarks');
191
+ try {
192
+ const now = Date.now();
193
+ for (const file of fs.readdirSync(wmDir)) {
194
+ if (!file.endsWith('.json')) continue;
195
+ const filePath = path.join(wmDir, file);
196
+ try {
197
+ const age = now - fs.statSync(filePath).mtimeMs;
198
+ if (age > maxAge) fs.unlinkSync(filePath);
199
+ } catch { /* skip individual file errors */ }
200
+ }
201
+ } catch { /* wmDir doesn't exist yet — normal */ }
202
+ }
203
+
204
+ /**
205
+ * Load watermark (set of captured tool_use_ids) for a session.
206
+ * Stored as a simple JSON file per session to avoid cross-turn duplicates.
207
+ */
208
+ function loadWatermark(sessionId) {
209
+ if (!sessionId) return null;
210
+ const wmPath = path.join(METAME_DIR, '.hook_watermarks', `${sessionId}.json`);
211
+ try {
212
+ const data = JSON.parse(fs.readFileSync(wmPath, 'utf8'));
213
+ return new Set(data.ids || []);
214
+ } catch {
215
+ return null;
216
+ }
217
+ }
218
+
219
+ function saveWatermark(sessionId, ids) {
220
+ if (!sessionId) return;
221
+ const wmDir = path.join(METAME_DIR, '.hook_watermarks');
222
+ fs.mkdirSync(wmDir, { recursive: true });
223
+ const wmPath = path.join(wmDir, `${sessionId}.json`);
224
+
225
+ // Merge with existing watermark.
226
+ let existing = new Set();
227
+ try {
228
+ const data = JSON.parse(fs.readFileSync(wmPath, 'utf8'));
229
+ existing = new Set(data.ids || []);
230
+ } catch {
231
+ // New watermark.
232
+ }
233
+
234
+ for (const id of ids) {
235
+ existing.add(id);
236
+ }
237
+
238
+ // Cap watermark size (keep last 200 IDs).
239
+ const allIds = [...existing];
240
+ const capped = allIds.length > 200 ? allIds.slice(-200) : allIds;
241
+
242
+ fs.writeFileSync(wmPath, JSON.stringify({ ids: capped, updated: new Date().toISOString() }));
243
+ }
@@ -43,7 +43,15 @@ const FACT_EXTRACTION_PROMPT = `你是精准的知识提取引擎。从以下会
43
43
  {
44
44
  "session_name": "用3-5个词极其精简地概括这起会话的主题(例如:优化微信登录架构、排查Redis连接泄漏、配置Nginx反向代理)",
45
45
  "facts": [
46
- {"entity":"主体(点号层级如MetaMe.daemon.askClaude)","relation":"类型","value":"脱离上下文可独立理解的一句话","confidence":"high或medium","tags":["最多3个标签"]}
46
+ {
47
+ "entity":"主体(点号层级如MetaMe.daemon.askClaude)",
48
+ "relation":"类型",
49
+ "value":"脱离上下文可独立理解的一句话",
50
+ "confidence":"high或medium",
51
+ "tags":["最多3个标签"],
52
+ "concepts":["最多3个抽象概念标签,如流量控制/背压/解耦"],
53
+ "domain":"可选领域标签,如backend/frontend/devops"
54
+ }
47
55
  ]
48
56
  }
49
57
 
@@ -53,6 +61,7 @@ const FACT_EXTRACTION_PROMPT = `你是精准的知识提取引擎。从以下会
53
61
  - value长度20-200字
54
62
  - entity用英文点号路径,value可用中文
55
63
  - medium confidence必须有非空tags
64
+ - concepts 可为空;若存在,最多3个,必须是抽象概念词而非文件名
56
65
  - 优先引用证据里的具体锚点(文件名、命令、报错关键词);没有锚点时不要硬编
57
66
  - 没有值得提取的事实时 facts 返回 []
58
67
 
@@ -98,6 +107,68 @@ function saveSessionTag(sessionId, sessionName, facts) {
98
107
  }
99
108
  }
100
109
 
110
+ function normalizeConceptList(input) {
111
+ if (!Array.isArray(input)) return [];
112
+ const out = [];
113
+ const seen = new Set();
114
+ for (const raw of input) {
115
+ const v = String(raw || '').trim();
116
+ if (!v || v.length > 40) continue;
117
+ if (seen.has(v)) continue;
118
+ seen.add(v);
119
+ out.push(v);
120
+ if (out.length >= 3) break;
121
+ }
122
+ return out;
123
+ }
124
+
125
+ function normalizeDomain(input) {
126
+ const v = String(input || '').trim();
127
+ if (!v) return null;
128
+ return v.length > 40 ? v.slice(0, 40) : v;
129
+ }
130
+
131
+ function factFingerprint(fact) {
132
+ if (!fact || typeof fact !== 'object') return '';
133
+ const entity = String(fact.entity || '').trim();
134
+ const relation = String(fact.relation || '').trim();
135
+ const value = String(fact.value || '').trim().slice(0, 100);
136
+ if (!entity || !relation || !value) return '';
137
+ return `${entity}||${relation}||${value}`;
138
+ }
139
+
140
+ function buildFactLabelRows(extractedFacts, savedFacts) {
141
+ const source = Array.isArray(extractedFacts) ? extractedFacts : [];
142
+ const saved = Array.isArray(savedFacts) ? savedFacts : [];
143
+ if (source.length === 0 || saved.length === 0) return [];
144
+
145
+ const byFp = new Map();
146
+ for (const fact of source) {
147
+ const fp = factFingerprint(fact);
148
+ if (!fp) continue;
149
+ if (!byFp.has(fp)) byFp.set(fp, fact);
150
+ }
151
+
152
+ const rows = [];
153
+ const dedup = new Set();
154
+ for (const sf of saved) {
155
+ const fp = factFingerprint(sf);
156
+ if (!fp) continue;
157
+ const src = byFp.get(fp);
158
+ if (!src) continue;
159
+ const concepts = normalizeConceptList(src.concepts);
160
+ if (concepts.length === 0) continue;
161
+ const domain = normalizeDomain(src.domain);
162
+ for (const label of concepts) {
163
+ const rowKey = `${sf.id}::${label}`;
164
+ if (dedup.has(rowKey)) continue;
165
+ dedup.add(rowKey);
166
+ rows.push({ fact_id: sf.id, label, domain });
167
+ }
168
+ }
169
+ return rows;
170
+ }
171
+
101
172
  const VAGUE_PATTERNS = [
102
173
  /^用户(问|提|说|提到)/, /^我们(讨论|分析|查看)/,
103
174
  /这个问题/, /上面(提到|说的|的)/, /可能是因为/,
@@ -144,7 +215,13 @@ async function extractFacts(skeleton, evidence, distillEnv) {
144
215
  return true;
145
216
  });
146
217
 
147
- return { ok: true, facts: filteredFacts, session_name };
218
+ const normalizedFacts = filteredFacts.map(f => ({
219
+ ...f,
220
+ concepts: normalizeConceptList(f.concepts),
221
+ domain: normalizeDomain(f.domain),
222
+ }));
223
+
224
+ return { ok: true, facts: normalizedFacts, session_name };
148
225
  }
149
226
 
150
227
  /**
@@ -235,16 +312,25 @@ async function run() {
235
312
  const fallbackScope = skeleton.session_id
236
313
  ? `sess_${String(skeleton.session_id).replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 24)}`
237
314
  : null;
238
- const { saved, skipped, superseded } = memory.saveFacts(
315
+ const { saved, skipped, superseded, savedFacts } = memory.saveFacts(
239
316
  skeleton.session_id,
240
317
  skeleton.project || 'unknown',
241
318
  facts,
242
319
  { scope: skeleton.project_id || fallbackScope }
243
320
  );
321
+ let labelsSaved = 0;
322
+ if (typeof memory.saveFactLabels === 'function' && Array.isArray(savedFacts) && savedFacts.length > 0) {
323
+ const labelRows = buildFactLabelRows(facts, savedFacts);
324
+ if (labelRows.length > 0) {
325
+ const labelResult = memory.saveFactLabels(labelRows);
326
+ labelsSaved = Number(labelResult && labelResult.saved) || 0;
327
+ }
328
+ }
244
329
  totalSaved += saved;
245
330
  totalSkipped += skipped;
246
331
  const superMsg = superseded > 0 ? `, ${superseded} superseded` : '';
247
- console.log(`[memory-extract] Session ${skeleton.session_id.slice(0, 8)}: ${saved} facts saved, ${skipped} skipped${superMsg}`);
332
+ const labelMsg = labelsSaved > 0 ? `, ${labelsSaved} labels` : '';
333
+ console.log(`[memory-extract] Session ${skeleton.session_id.slice(0, 8)}: ${saved} facts saved, ${skipped} skipped${superMsg}${labelMsg}`);
248
334
  } else {
249
335
  console.log(`[memory-extract] Session ${skeleton.session_id.slice(0, 8)} (${session_name}): no facts extracted`);
250
336
  }
@@ -276,4 +362,13 @@ if (require.main === module) {
276
362
  });
277
363
  }
278
364
 
279
- module.exports = { run, extractFacts };
365
+ module.exports = {
366
+ run,
367
+ extractFacts,
368
+ _private: {
369
+ normalizeConceptList,
370
+ normalizeDomain,
371
+ buildFactLabelRows,
372
+ factFingerprint,
373
+ },
374
+ };
@@ -26,15 +26,17 @@ const REFLECT_LOG_FILE = path.join(METAME_DIR, 'memory_reflect_log.jsonl');
26
26
  const MEMORY_DIR = path.join(HOME, '.metame', 'memory');
27
27
  const DECISIONS_DIR = path.join(MEMORY_DIR, 'decisions');
28
28
  const LESSONS_DIR = path.join(MEMORY_DIR, 'lessons');
29
+ const CAPSULES_DIR = path.join(MEMORY_DIR, 'capsules');
29
30
 
30
31
  // Hot zone thresholds
31
32
  const MIN_SEARCH_COUNT = 3;
32
33
  const WINDOW_DAYS = 7;
33
34
  const MAX_FACTS = 20;
34
35
  const LOCK_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
36
+ const EXCLUDED_RELATIONS = ['project_milestone', 'synthesized_insight', 'knowledge_capsule', 'bug_lesson'];
35
37
 
36
38
  // Ensure output directories exist at startup
37
- [MEMORY_DIR, DECISIONS_DIR, LESSONS_DIR].forEach(d => fs.mkdirSync(d, { recursive: true }));
39
+ [MEMORY_DIR, DECISIONS_DIR, LESSONS_DIR, CAPSULES_DIR].forEach(d => fs.mkdirSync(d, { recursive: true }));
38
40
 
39
41
  /**
40
42
  * Load callHaiku + buildDistillEnv from deployed path, fallback to scripts dir.
@@ -107,18 +109,19 @@ function writeReflectLog(record) {
107
109
  * Returns array of plain objects.
108
110
  */
109
111
  function queryHotFacts(db) {
112
+ const relationPlaceholders = EXCLUDED_RELATIONS.map(() => '?').join(', ');
110
113
  const stmt = db.prepare(`
111
- SELECT entity, relation, value, confidence, search_count, created_at
114
+ SELECT id, entity, relation, value, confidence, search_count, created_at
112
115
  FROM facts
113
116
  WHERE search_count >= ${MIN_SEARCH_COUNT}
114
117
  AND created_at >= datetime('now', '-${WINDOW_DAYS} days')
115
118
  AND superseded_by IS NULL
116
119
  AND (conflict_status IS NULL OR conflict_status = 'OK')
117
- AND relation != 'project_milestone'
120
+ AND relation NOT IN (${relationPlaceholders})
118
121
  ORDER BY search_count DESC, created_at DESC
119
122
  LIMIT ${MAX_FACTS}
120
123
  `);
121
- return stmt.all();
124
+ return stmt.all(...EXCLUDED_RELATIONS);
122
125
  }
123
126
 
124
127
  /**
@@ -142,6 +145,101 @@ ${sections}
142
145
  fs.writeFileSync(filePath, content, 'utf8');
143
146
  }
144
147
 
148
+ function sanitizeSlug(input, fallback = 'capsule') {
149
+ const v = String(input || '')
150
+ .trim()
151
+ .toLowerCase()
152
+ .replace(/[^a-z0-9\u4e00-\u9fff_-]+/g, '-')
153
+ .replace(/-+/g, '-')
154
+ .replace(/^-|-$/g, '');
155
+ if (!v) return fallback;
156
+ return v.slice(0, 50);
157
+ }
158
+
159
+ function stripMd(text) {
160
+ return String(text || '').replace(/[#*_`>\[\]\(\)]/g, ' ').replace(/\s+/g, ' ').trim();
161
+ }
162
+
163
+ function buildSynthesizedFacts(today, decisions, lessons) {
164
+ const all = []
165
+ .concat(Array.isArray(decisions) ? decisions : [])
166
+ .concat(Array.isArray(lessons) ? lessons : []);
167
+ const out = [];
168
+ for (const item of all) {
169
+ const title = String(item && item.title ? item.title : '').trim();
170
+ const content = String(item && item.content ? item.content : '').trim();
171
+ if (!title || !content) continue;
172
+ const value = stripMd(`${title}: ${content}`).slice(0, 280);
173
+ if (value.length < 20) continue;
174
+ out.push({
175
+ entity: `nightly.reflect.${today}`,
176
+ relation: 'synthesized_insight',
177
+ value,
178
+ confidence: 'high',
179
+ tags: ['nightly', 'reflection'],
180
+ });
181
+ }
182
+ return out;
183
+ }
184
+
185
+ function entityPrefix(entity) {
186
+ const src = String(entity || '').trim();
187
+ if (!src) return '';
188
+ const parts = src.split('.').map(s => s.trim()).filter(Boolean);
189
+ if (parts.length === 0) return '';
190
+ if (parts.length === 1) return parts[0];
191
+ return `${parts[0]}.${parts[1]}`;
192
+ }
193
+
194
+ function collectCapsuleGroups(facts, minGroupSize = 3) {
195
+ const groups = new Map();
196
+ for (const fact of Array.isArray(facts) ? facts : []) {
197
+ const prefix = entityPrefix(fact && fact.entity);
198
+ if (!prefix) continue;
199
+ if (!groups.has(prefix)) groups.set(prefix, []);
200
+ groups.get(prefix).push(fact);
201
+ }
202
+ return [...groups.entries()]
203
+ .map(([prefix, items]) => ({ prefix, items }))
204
+ .filter(g => g.items.length >= minGroupSize)
205
+ .sort((a, b) => b.items.length - a.items.length);
206
+ }
207
+
208
+ function parseJsonFromLlm(raw) {
209
+ const text = String(raw || '');
210
+ const cleaned = text.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
211
+ if (!cleaned) return null;
212
+ try { return JSON.parse(cleaned); } catch { return null; }
213
+ }
214
+
215
+ function writeCapsuleFile(filePath, capsule, facts, today, prefix) {
216
+ const related = Array.isArray(capsule.related_concepts) ? capsule.related_concepts.slice(0, 8) : [];
217
+ const supporting = Array.isArray(capsule.supporting_facts) ? capsule.supporting_facts.slice(0, 8) : [];
218
+ const content = `---
219
+ date: ${today}
220
+ source: nightly-reflect
221
+ type: knowledge-capsule
222
+ entity_prefix: ${prefix}
223
+ facts_analyzed: ${Array.isArray(facts) ? facts.length : 0}
224
+ ---
225
+
226
+ # ${capsule.title}
227
+
228
+ ## 核心结论
229
+ ${capsule.core_conclusion}
230
+
231
+ ## 适用场景
232
+ ${capsule.applicable_scenarios}
233
+
234
+ ## 关联概念
235
+ ${related.length > 0 ? related.map(x => `- ${x}`).join('\n') : '- (none)'}
236
+
237
+ ## 支撑事实
238
+ ${supporting.length > 0 ? supporting.map(x => `- ${x}`).join('\n') : '- (none)'}
239
+ `;
240
+ fs.writeFileSync(filePath, content, 'utf8');
241
+ }
242
+
145
243
  /**
146
244
  * Main nightly reflect run.
147
245
  */
@@ -238,12 +336,9 @@ Rules:
238
336
  }
239
337
 
240
338
  // Parse Haiku response
241
- let parsed;
242
- try {
243
- const cleaned = raw.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
244
- parsed = JSON.parse(cleaned);
245
- } catch (e) {
246
- console.log(`[NIGHTLY-REFLECT] Failed to parse Haiku output: ${e.message}`);
339
+ const parsed = parseJsonFromLlm(raw);
340
+ if (!parsed || typeof parsed !== 'object') {
341
+ console.log('[NIGHTLY-REFLECT] Failed to parse Haiku output.');
247
342
  writeReflectLog({ status: 'error', reason: 'parse_failed', facts_found: hotFacts.length });
248
343
  return;
249
344
  }
@@ -265,12 +360,92 @@ Rules:
265
360
  console.log(`[NIGHTLY-REFLECT] Lessons written: ${lessonFile}`);
266
361
  }
267
362
 
363
+ let synthesizedSaved = 0;
364
+ let capsulesWritten = 0;
365
+ let capsuleFactsSaved = 0;
366
+ let memory = null;
367
+ try {
368
+ try { memory = require('./memory'); } catch { /* optional */ }
369
+
370
+ // 3B: write distilled insights back into memory.db for closed-loop retrieval.
371
+ if (memory && typeof memory.saveFacts === 'function') {
372
+ const synthesizedFacts = buildSynthesizedFacts(today, decisions, lessons);
373
+ if (synthesizedFacts.length > 0) {
374
+ const writeRes = memory.saveFacts(`nightly-reflect-${today}`, '*', synthesizedFacts, { scope: '*' });
375
+ synthesizedSaved = Number(writeRes && writeRes.saved) || 0;
376
+ }
377
+ }
378
+
379
+ // 3C: knowledge capsule aggregation by entity prefix.
380
+ const capsuleGroups = collectCapsuleGroups(hotFacts, 3).slice(0, 3);
381
+ for (const group of capsuleGroups) {
382
+ const groupFacts = group.items.map(f => ({
383
+ entity: f.entity,
384
+ relation: f.relation,
385
+ value: f.value,
386
+ search_count: f.search_count,
387
+ }));
388
+ const capsulePrompt = `你是知识胶囊生成器。请将同一主题下的事实聚合成结构化胶囊。
389
+
390
+ entity_prefix: ${group.prefix}
391
+ facts(json): ${JSON.stringify(groupFacts, null, 2).slice(0, 5000)}
392
+
393
+ 输出 JSON:
394
+ {
395
+ "title":"标题",
396
+ "core_conclusion":"一句核心结论",
397
+ "applicable_scenarios":"适用场景(1-2句)",
398
+ "related_concepts":["概念1","概念2"],
399
+ "supporting_facts":["支撑点1","支撑点2"]
400
+ }
401
+
402
+ 规则:
403
+ - 只基于输入事实,不虚构
404
+ - 每个字段简洁具体
405
+ - 仅输出 JSON`;
406
+
407
+ let capsule = null;
408
+ try {
409
+ const rawCapsule = await Promise.race([
410
+ callHaiku(capsulePrompt, distillEnv, 60000),
411
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 65000)),
412
+ ]);
413
+ capsule = parseJsonFromLlm(rawCapsule);
414
+ } catch { /* non-fatal */ }
415
+ if (!capsule || !capsule.title || !capsule.core_conclusion || !capsule.applicable_scenarios) continue;
416
+
417
+ const capsuleSlug = sanitizeSlug(group.prefix.replace(/\./g, '-'), 'capsule');
418
+ const capsuleFile = path.join(CAPSULES_DIR, `${capsuleSlug}-${today}.md`);
419
+ writeCapsuleFile(capsuleFile, capsule, group.items, today, group.prefix);
420
+ capsulesWritten++;
421
+
422
+ if (memory && typeof memory.saveFacts === 'function') {
423
+ const capsuleValue = stripMd(`${capsule.title}: ${capsule.core_conclusion}`).slice(0, 280);
424
+ if (capsuleValue.length >= 20) {
425
+ const saveCapsule = memory.saveFacts(`capsule-${today}-${capsuleSlug}`, '*', [{
426
+ entity: `capsule.${group.prefix.replace(/\./g, '_')}`,
427
+ relation: 'knowledge_capsule',
428
+ value: capsuleValue,
429
+ confidence: 'high',
430
+ tags: ['capsule'],
431
+ }], { scope: '*' });
432
+ capsuleFactsSaved += Number(saveCapsule && saveCapsule.saved) || 0;
433
+ }
434
+ }
435
+ }
436
+ } finally {
437
+ try { if (memory && typeof memory.close === 'function') memory.close(); } catch { /* non-fatal */ }
438
+ }
439
+
268
440
  // Write audit log
269
441
  writeReflectLog({
270
442
  status: 'success',
271
443
  facts_analyzed: hotFacts.length,
272
444
  decisions_written: decisions.length,
273
445
  lessons_written: lessons.length,
446
+ synthesized_insights_saved: synthesizedSaved,
447
+ capsules_written: capsulesWritten,
448
+ capsule_facts_saved: capsuleFactsSaved,
274
449
  decision_file: decisions.length > 0 ? decisionFile : null,
275
450
  lesson_file: lessons.length > 0 ? lessonFile : null,
276
451
  });
@@ -296,4 +471,14 @@ if (require.main === module) {
296
471
  });
297
472
  }
298
473
 
299
- module.exports = { run };
474
+ module.exports = {
475
+ run,
476
+ _private: {
477
+ queryHotFacts,
478
+ buildSynthesizedFacts,
479
+ collectCapsuleGroups,
480
+ entityPrefix,
481
+ parseJsonFromLlm,
482
+ EXCLUDED_RELATIONS,
483
+ },
484
+ };