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.
- package/README.md +187 -48
- package/index.js +148 -9
- package/package.json +6 -3
- package/scripts/daemon-admin-commands.js +254 -9
- package/scripts/daemon-agent-commands.js +64 -6
- package/scripts/daemon-agent-tools.js +26 -5
- package/scripts/daemon-bridges.js +110 -20
- package/scripts/daemon-claude-engine.js +704 -268
- package/scripts/daemon-command-router.js +24 -8
- package/scripts/daemon-default.yaml +28 -4
- package/scripts/daemon-engine-runtime.js +275 -0
- package/scripts/daemon-exec-commands.js +10 -4
- package/scripts/daemon-notify.js +37 -1
- package/scripts/daemon-runtime-lifecycle.js +2 -1
- package/scripts/daemon-session-commands.js +52 -4
- package/scripts/daemon-session-store.js +2 -1
- package/scripts/daemon-task-scheduler.js +87 -28
- package/scripts/daemon-user-acl.js +26 -9
- package/scripts/daemon.js +81 -17
- package/scripts/distill.js +323 -18
- package/scripts/docs/agent-guide.md +12 -0
- package/scripts/docs/maintenance-manual.md +119 -0
- package/scripts/docs/pointer-map.md +88 -0
- package/scripts/feishu-adapter.js +6 -1
- package/scripts/hooks/stop-session-capture.js +243 -0
- package/scripts/memory-extract.js +100 -5
- package/scripts/memory-nightly-reflect.js +196 -11
- package/scripts/memory.js +134 -3
- package/scripts/mentor-engine.js +405 -0
- package/scripts/platform.js +2 -0
- package/scripts/providers.js +169 -21
- package/scripts/schema.js +12 -0
- package/scripts/session-analytics.js +245 -12
- package/scripts/skill-changelog.js +245 -0
- package/scripts/skill-evolution.js +288 -5
- package/scripts/usage-classifier.js +1 -1
- package/scripts/daemon-admin-commands.test.js +0 -333
- package/scripts/daemon-task-envelope.test.js +0 -59
- package/scripts/daemon-task-scheduler.test.js +0 -106
- package/scripts/reliability-core.test.js +0 -280
- package/scripts/skill-evolution.test.js +0 -113
- package/scripts/task-board.test.js +0 -83
- package/scripts/test_daemon.js +0 -1407
- 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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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 = {
|
|
474
|
+
module.exports = {
|
|
475
|
+
run,
|
|
476
|
+
_private: {
|
|
477
|
+
queryHotFacts,
|
|
478
|
+
buildSynthesizedFacts,
|
|
479
|
+
collectCapsuleGroups,
|
|
480
|
+
entityPrefix,
|
|
481
|
+
parseJsonFromLlm,
|
|
482
|
+
EXCLUDED_RELATIONS,
|
|
483
|
+
},
|
|
484
|
+
};
|