metame-cli 1.4.34 → 1.5.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 (48) hide show
  1. package/README.md +136 -94
  2. package/index.js +312 -57
  3. package/package.json +8 -4
  4. package/scripts/agent-layer.js +320 -0
  5. package/scripts/daemon-admin-commands.js +328 -28
  6. package/scripts/daemon-agent-commands.js +145 -6
  7. package/scripts/daemon-agent-tools.js +163 -7
  8. package/scripts/daemon-bridges.js +110 -20
  9. package/scripts/daemon-checkpoints.js +36 -7
  10. package/scripts/daemon-claude-engine.js +849 -358
  11. package/scripts/daemon-command-router.js +31 -10
  12. package/scripts/daemon-default.yaml +28 -4
  13. package/scripts/daemon-engine-runtime.js +328 -0
  14. package/scripts/daemon-exec-commands.js +15 -7
  15. package/scripts/daemon-notify.js +37 -1
  16. package/scripts/daemon-ops-commands.js +8 -6
  17. package/scripts/daemon-runtime-lifecycle.js +129 -5
  18. package/scripts/daemon-session-commands.js +60 -25
  19. package/scripts/daemon-session-store.js +121 -13
  20. package/scripts/daemon-task-scheduler.js +129 -49
  21. package/scripts/daemon-user-acl.js +35 -9
  22. package/scripts/daemon.js +268 -33
  23. package/scripts/distill.js +327 -18
  24. package/scripts/docs/agent-guide.md +12 -0
  25. package/scripts/docs/maintenance-manual.md +155 -0
  26. package/scripts/docs/pointer-map.md +110 -0
  27. package/scripts/feishu-adapter.js +42 -13
  28. package/scripts/hooks/stop-session-capture.js +243 -0
  29. package/scripts/memory-extract.js +105 -6
  30. package/scripts/memory-nightly-reflect.js +199 -11
  31. package/scripts/memory.js +134 -3
  32. package/scripts/mentor-engine.js +405 -0
  33. package/scripts/platform.js +24 -0
  34. package/scripts/providers.js +182 -22
  35. package/scripts/schema.js +12 -0
  36. package/scripts/session-analytics.js +245 -12
  37. package/scripts/skill-changelog.js +245 -0
  38. package/scripts/skill-evolution.js +288 -5
  39. package/scripts/telegram-adapter.js +12 -8
  40. package/scripts/usage-classifier.js +1 -1
  41. package/scripts/daemon-admin-commands.test.js +0 -333
  42. package/scripts/daemon-task-envelope.test.js +0 -59
  43. package/scripts/daemon-task-scheduler.test.js +0 -106
  44. package/scripts/reliability-core.test.js +0 -280
  45. package/scripts/skill-evolution.test.js +0 -113
  46. package/scripts/task-board.test.js +0 -83
  47. package/scripts/test_daemon.js +0 -1407
  48. package/scripts/utils.test.js +0 -192
@@ -0,0 +1,110 @@
1
+ # MetaMe 脚本/文档指针地图
2
+
3
+ > 目的:回答“这段能力在哪个文件”“当前升级做到哪一步”“先看哪个脚本”。
4
+
5
+ ## 快速入口
6
+
7
+ - 主入口:`index.js`
8
+ - CLI 双入口:`metame`(Claude)/`metame codex [args]`(Codex)
9
+ - Daemon 主循环:`scripts/daemon.js`
10
+ - 多引擎 runtime 适配层:`scripts/daemon-engine-runtime.js`
11
+ - 会话执行引擎(Claude/Codex 共用入口):`scripts/daemon-claude-engine.js`
12
+ - 管理命令:`scripts/daemon-admin-commands.js`
13
+ - 命令路由:`scripts/daemon-command-router.js`
14
+ - 执行命令(`/stop`、`/compact` 等):`scripts/daemon-exec-commands.js`
15
+ - 会话存储:`scripts/daemon-session-store.js`
16
+ - 默认配置:`scripts/daemon-default.yaml`
17
+ - Provider/蒸馏模型配置:`scripts/providers.js`(`/provider`、`/distill-model`)
18
+ - 跨平台基础设施:`scripts/platform.js`(`killProcessTree`、`socketPath`、`sleepSync`、`icon`)
19
+ - 热重载安全机制:`scripts/daemon-runtime-lifecycle.js`(语法预检、last-good 备份、crash-loop 自愈)
20
+ - 维护手册:`scripts/docs/maintenance-manual.md`
21
+
22
+ ## 多引擎(Claude/Codex)定位
23
+
24
+ - Runtime 工厂与事件归一化:
25
+ - `scripts/daemon-engine-runtime.js`
26
+ - 关键点:`normalizeEngineName()`、`buildClaudeArgs()`、`buildCodexArgs()`、`parseCodexStreamEvent()`
27
+
28
+ - 会话与引擎选择:
29
+ - `scripts/daemon-claude-engine.js`
30
+ - 关键点:`askClaude()` 按 `project.engine`/session 选择 runtime;`patchSessionSerialized()` 串行回写 session
31
+ - Codex 规则:`exec`/`resume`、10 分钟窗口内一次自动重试、`thread_id` 迁移回写
32
+
33
+ - Agent Soul 身份层(新):
34
+ - `scripts/agent-layer.js`
35
+ - 关键点:`ensureAgentLayer()` 创建 `~/.metame/agents/<id>/`(soul.md、memory-snapshot.md、agent.yaml);
36
+ `createLinkOrMirror()` Windows 兼容(symlink → hardlink → copy 降级);
37
+ `ensureClaudeMdSoulImport()` 在 CLAUDE.md 头部注入 `@SOUL.md`(Claude CLI 自动加载);
38
+ Codex 引擎在每次新 session 时将 CLAUDE.md + SOUL.md 合并写入 AGENTS.md(见 daemon-claude-engine.js:957);
39
+ `repairAgentLayer()` 懒迁移:老项目补建 soul 层,幂等安全
40
+
41
+ - Agent 命令处理(新):
42
+ - `scripts/daemon-agent-commands.js`
43
+ - 关键点:`createAgentCommandHandler()` 处理 `/agent`、`/activate`、`/resume`;
44
+ `/agent soul [repair|edit]`;`pendingActivations` 无 TTL(消费即删);防止创建群自激活
45
+
46
+ - 路由与 Agent 创建:
47
+ - `scripts/daemon-command-router.js`
48
+ - `scripts/daemon-agent-tools.js`
49
+ - 关键点:自然语言提取 `codex` 关键词;默认 `claude` 不写 `engine` 字段,仅 `codex` 持久化 `engine: codex`;
50
+ `bindAgentToChat()` 自动调用 `ensureAgentMetadata()` 建立 soul 层
51
+
52
+ - 会话命令与兼容边界:
53
+ - `scripts/daemon-exec-commands.js`
54
+ - 关键点:`/stop` 引擎中性;`/compact` 在 codex 会话返回“暂不支持”
55
+
56
+ - 运行时引擎切换与诊断:
57
+ - `scripts/daemon-admin-commands.js`
58
+ - 关键点:`/engine` 切换默认引擎;`/doctor` 按默认引擎检查 CLI 可用性(Claude/Codex)并兼容自定义 provider 模型名
59
+
60
+ ## Mentor Mode(Step 1-4)定位
61
+
62
+ - Step 1 数据基建:
63
+ - `scripts/session-analytics.js`
64
+ - 关键点:`extractSkeleton()` 新增数值指标、`detectSignificantSession()`
65
+ - `scripts/schema.js`:`growth.mentor_mode`、`growth.mentor_friction_level`、`growth.weekly_report_last`
66
+ - `scripts/memory.js`:`fact_labels` 表结构
67
+
68
+ - Step 2 决策引擎:
69
+ - `scripts/mentor-engine.js`
70
+ - 关键 API:`checkEmotionBreaker` / `buildMentorPrompt` / `computeZone` / `registerDebt` / `collectDebt` / `detectPatterns`
71
+ - 运行时状态文件:`~/.metame/mentor_runtime.json`
72
+
73
+ - Step 3 Hook 接入:
74
+ - `scripts/daemon-claude-engine.js`:Pre-flight / Context / Post-flight 三段 Hook
75
+ - `scripts/daemon-admin-commands.js`:`/mentor on|off|level|status`
76
+ - `scripts/daemon-default.yaml`:`daemon.mentor` 配置段
77
+
78
+ - Step 4 Distiller & Memory 闭环:
79
+ - `scripts/distill.js`:`competence_signals` 合并、significant session postmortem 产出、`bug_lesson` 回写
80
+ - `scripts/memory-extract.js`:消费 `saveFacts().savedFacts`,写入 `fact_labels`
81
+ - `scripts/memory.js`:`saveFactLabels()` 原子写入 API
82
+ - `scripts/memory-nightly-reflect.js`:`synthesized_insight` 回写、知识胶囊聚合与 `knowledge_capsule` 回写
83
+
84
+ ## 运行时数据位置
85
+
86
+ - 画像:`~/.claude_profile.yaml`
87
+ - 记忆数据库:`~/.metame/memory.db`
88
+ - 会话标签:`~/.metame/session_tags.json`
89
+ - 进程 PID 记录:`~/.metame/active_agent_pids.json`
90
+ - 夜间反思文档:`~/.metame/memory/decisions/`、`~/.metame/memory/lessons/`
91
+ - 知识胶囊:`~/.metame/memory/capsules/`
92
+ - 复盘文档:`~/.metame/memory/postmortems/`
93
+ - **Agent Soul 层**:`~/.metame/agents/<agent_id>/`
94
+ - `agent.yaml` — id / name / engine / aliases
95
+ - `soul.md` — 身份定义(主文件,项目目录的 SOUL.md 是其链接)
96
+ - `memory-snapshot.md` — 近期记忆快照(注入 session prompt)
97
+ - 项目视图:`<cwd>/SOUL.md`(symlink/hardlink/copy)、`<cwd>/MEMORY.md`(同)
98
+ - `<cwd>/AGENTS.md` — Codex 专用,每次新 session 由 daemon 合并 CLAUDE.md + SOUL.md 写入
99
+
100
+ ## 诊断顺序(推荐)
101
+
102
+ 1. 先看配置:`~/.metame/daemon.yaml` 与 `scripts/daemon-default.yaml`
103
+ 2. 再看命令入口:`scripts/daemon-admin-commands.js`、`scripts/daemon-command-router.js`、`scripts/daemon-exec-commands.js`
104
+ 3. 再看执行链路:`scripts/daemon-engine-runtime.js` → `scripts/daemon-claude-engine.js` → `scripts/mentor-engine.js`
105
+ 4. 最后看离线任务:`scripts/distill.js`、`scripts/memory-extract.js`、`scripts/memory-nightly-reflect.js`
106
+
107
+ ## 同步提示
108
+
109
+ - 每次改 `scripts/` 后执行:`npm run sync:plugin`
110
+ - plugin 镜像路径:`plugin/scripts/*`
@@ -10,15 +10,40 @@ const fs = require('fs');
10
10
  const path = require('path');
11
11
 
12
12
  let Lark;
13
- try {
14
- Lark = require('@larksuiteoapi/node-sdk');
15
- } catch {
13
+ function _tryRequireLark() {
14
+ // 1. local node_modules (dev environment)
15
+ try { return require('@larksuiteoapi/node-sdk'); } catch {}
16
+ // 2. METAME_ROOT/node_modules (packaged metame-cli)
16
17
  const metameRoot = process.env.METAME_ROOT;
17
18
  if (metameRoot) {
18
- Lark = require(require('path').join(metameRoot, 'node_modules', '@larksuiteoapi/node-sdk'));
19
+ try { return require(path.join(metameRoot, 'node_modules', '@larksuiteoapi/node-sdk')); } catch {}
19
20
  }
20
- if (!Lark) {
21
- console.error('Cannot find @larksuiteoapi/node-sdk. Run: npm install @larksuiteoapi/node-sdk');
21
+ // 3. ~/.metame/node_modules (auto-installed for new users)
22
+ const home = process.env.HOME || process.env.USERPROFILE;
23
+ if (home) {
24
+ try { return require(path.join(home, '.metame', 'node_modules', '@larksuiteoapi', 'node-sdk')); } catch {}
25
+ }
26
+ return null;
27
+ }
28
+ Lark = _tryRequireLark();
29
+ if (!Lark) {
30
+ // Auto-install into ~/.metame so new users never see this error
31
+ const home = process.env.HOME || process.env.USERPROFILE;
32
+ const prefix = home ? path.join(home, '.metame') : null;
33
+ if (prefix) {
34
+ console.log('[feishu] @larksuiteoapi/node-sdk not found, auto-installing into ~/.metame ...');
35
+ const { execSync } = require('child_process');
36
+ try {
37
+ execSync(`npm install @larksuiteoapi/node-sdk --prefix "${prefix}" --silent`, { stdio: 'inherit' });
38
+ Lark = require(path.join(prefix, 'node_modules', '@larksuiteoapi', 'node-sdk'));
39
+ console.log('[feishu] SDK installed successfully.');
40
+ } catch (e) {
41
+ console.error('[feishu] Auto-install failed:', e.message);
42
+ console.error('Manual fix: npm install @larksuiteoapi/node-sdk --prefix ~/.metame');
43
+ process.exit(1);
44
+ }
45
+ } else {
46
+ console.error('[feishu] Cannot find @larksuiteoapi/node-sdk and HOME is not set.');
22
47
  process.exit(1);
23
48
  }
24
49
  }
@@ -26,12 +51,11 @@ try {
26
51
  // Timeout wrapper: prevents SDK calls from hanging indefinitely when
27
52
  // Feishu's token refresh HTTP request has no response (e.g. network down)
28
53
  function withTimeout(promise, ms = 10000) {
29
- return Promise.race([
30
- promise,
31
- new Promise((_, reject) =>
32
- setTimeout(() => reject(new Error(`Feishu API timeout after ${ms}ms`)), ms)
33
- ),
34
- ]);
54
+ let timer;
55
+ const timeout = new Promise((_, reject) => {
56
+ timer = setTimeout(() => reject(new Error(`Feishu API timeout after ${ms}ms`)), ms);
57
+ });
58
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
35
59
  }
36
60
 
37
61
  // Max chars per lark_md element (Feishu limit ~4000)
@@ -416,10 +440,15 @@ function createBot(config) {
416
440
  const chatId = data.open_chat_id || data.chat_id
417
441
  || (data.context && data.context.open_chat_id)
418
442
  || (data.event && data.event.open_chat_id);
443
+ const senderId = (data.operator && data.operator.open_id)
444
+ || (data.open_id)
445
+ || (data.user && data.user.open_id)
446
+ || (data.context && data.context.open_id)
447
+ || null;
419
448
  if (action && chatId) {
420
449
  const cmd = action.value && action.value.cmd;
421
450
  if (cmd) {
422
- Promise.resolve().then(() => onMessage(chatId, cmd, data)).catch((err) => {
451
+ Promise.resolve().then(() => onMessage(chatId, cmd, data, null, senderId)).catch((err) => {
423
452
  try { console.error(`[feishu-adapter] card action error: ${err && err.message || err}`); } catch { }
424
453
  });
425
454
  }
@@ -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
+ }
@@ -7,7 +7,7 @@
7
7
  * into memory.db. Runs independently of raw_signals.jsonl so that
8
8
  * pure technical sessions (no preference signals) are still captured.
9
9
  *
10
- * Designed to run as a standalone heartbeat task every 30 minutes.
10
+ * Designed to run as a standalone heartbeat task (default interval: 4h).
11
11
  */
12
12
 
13
13
  'use strict';
@@ -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
  }
@@ -270,10 +356,23 @@ async function run() {
270
356
  if (require.main === module) {
271
357
  run().then(({ sessionsProcessed, factsSaved, factsSkipped }) => {
272
358
  console.log(`✅ memory-extract: ${sessionsProcessed} session(s), ${factsSaved} facts saved, ${factsSkipped} skipped`);
359
+ // Report estimated token usage for daemon budget tracking
360
+ // Each session processed ≈ 1 callHaiku invocation ≈ 3k tokens
361
+ const estTokens = sessionsProcessed * 3000;
362
+ if (estTokens > 0) console.log(`__TOKENS__:${estTokens}`);
273
363
  }).catch(e => {
274
364
  console.error(`[memory-extract] Fatal: ${e.message}`);
275
365
  process.exit(1);
276
366
  });
277
367
  }
278
368
 
279
- module.exports = { run, extractFacts };
369
+ module.exports = {
370
+ run,
371
+ extractFacts,
372
+ _private: {
373
+ normalizeConceptList,
374
+ normalizeDomain,
375
+ buildFactLabelRows,
376
+ factFingerprint,
377
+ },
378
+ };