metame-cli 1.4.8 → 1.4.12

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 CHANGED
@@ -172,40 +172,26 @@ Task fails → skill-scout finds a skill → installs → retries → succeeds
172
172
 
173
173
  ## Quick Start
174
174
 
175
- ### Install
176
-
177
175
  ```bash
178
- # One-line install (includes Node.js + Claude Code if missing)
179
- curl -fsSL https://raw.githubusercontent.com/Yaron9/MetaMe/main/install.sh | bash
180
-
181
- # Or if you already have Claude Code
182
- npm install -g metame-cli
183
- ```
184
-
185
- ### First Run
186
-
187
- ```bash
188
- metame
176
+ npm install -g metame-cli && metame
189
177
  ```
190
178
 
191
- On first launch, MetaMe runs a brief cognitive interview to build your profile. After that, it's automatic.
179
+ **3 minutes to full setup:**
192
180
 
193
- ### Enable Mobile Access
181
+ | Step | Command | What happens |
182
+ |------|---------|-------------|
183
+ | 1. Install & profile | `metame` | First run: cognitive interview → builds `~/.claude_profile.yaml` |
184
+ | 2. Connect phone | Follow the setup wizard | Bot token + app credentials → `~/.metame/daemon.yaml` |
185
+ | 3. Start daemon | `metame start` | Background daemon launches, bot goes online |
186
+ | 4. Auto-start | `metame daemon install-launchd` | Survives reboot + crash recovery |
194
187
 
195
- ```bash
196
- metame daemon init # Creates config with setup guide
197
- metame start # Launches background daemon
198
- ```
188
+ **Create your first Agent:**
199
189
 
200
- Edit `~/.metame/daemon.yaml` with your Telegram bot token or Feishu app credentials, then:
190
+ 1. Create a group chat in Telegram/Feishu, add your bot
191
+ 2. Send `/agent bind <name>` in the group (e.g. `/agent bind personal`)
192
+ 3. Pick a working directory from the buttons, or type a path directly — non-existent directories are created automatically → done
201
193
 
202
- ```bash
203
- metame daemon install-launchd # Auto-start on boot + crash recovery
204
- ```
205
-
206
- Done. Open Telegram, message your bot.
207
-
208
- > **First message?** New chats aren't whitelisted yet. The bot will reply with a one-step setup command — just send `/agent bind personal ~/` and you're in.
194
+ > Want more Agents? Repeat: new group → add bot → `/agent bind <name>`. Each group = independent AI workspace.
209
195
 
210
196
  ---
211
197
 
@@ -234,7 +220,7 @@ The easiest way. Open any Telegram/Feishu group and use the `/agent` wizard:
234
220
 
235
221
  | Command | What it does |
236
222
  |---------|-------------|
237
- | `/agent new` | Step-by-step wizard: pick a directory → name the agent → describe its role. MetaMe writes the role into `CLAUDE.md` automatically. |
223
+ | `/agent new` | Step-by-step wizard: pick a directory → name the agent → describe its role. MetaMe writes the role into `CLAUDE.md` automatically. You can also type a path directly in chat — if it doesn't exist, MetaMe creates it for you. |
238
224
  | `/agent bind <name> [dir]` | Quick bind: register this group as a named agent, optionally set working directory. |
239
225
  | `/agent list` | Show all configured agents. |
240
226
  | `/agent edit` | Update the current agent's role description (rewrites its `CLAUDE.md` section). |
@@ -245,14 +231,17 @@ Example flow:
245
231
  You: /agent new
246
232
  Bot: Please select a working directory:
247
233
  📁 ~/AGI 📁 ~/projects 📁 ~/Desktop
248
- You: ~/AGI/MyProject
249
- Bot: What should we name this agent?
234
+ You: ~/AGI/MyProject/NewDir
235
+ Bot: 已新建目录:~/AGI/MyProject/NewDir
236
+ What should we name this agent?
250
237
  You: 小美
251
238
  Bot: Describe 小美's role and responsibilities:
252
239
  You: Personal assistant. Manages my calendar, drafts messages, and tracks todos.
253
240
  Bot: ✅ Agent「小美」created. CLAUDE.md updated with role definition.
254
241
  ```
255
242
 
243
+ You can tap a button to pick an existing directory, or type any path directly in chat. If the path doesn't exist, it's created automatically. All entry points (`/agent new` wizard and `/agent bind`) validate that the directory is real before saving.
244
+
256
245
  ### From config file (for power users)
257
246
 
258
247
  ```yaml
package/index.js CHANGED
@@ -390,10 +390,14 @@ You are entering **Calibration Mode**. You are not a chatbot; you are a Psycholo
390
390
  - Announce: "Link Established. Profile calibrated."
391
391
  - Then proceed to **Phase 2** below.
392
392
 
393
- **3. SETUP WIZARD (Phase 2 — Optional):**
393
+ **3. SETUP WIZARD (Phase 2 — Mobile Access):**
394
394
 
395
395
  After writing the profile, ask: *"Want to set up mobile access so you can reach me from your phone? (Telegram / Feishu / Skip)"*
396
396
 
397
+ **Step A: Create Bot & Connect Private Chat (必做)**
398
+
399
+ This step connects the bot to the user's PRIVATE chat — this is the admin channel.
400
+
397
401
  - If **Telegram:**
398
402
  1. Tell user to open Telegram, search @BotFather, send /newbot, create a bot, copy the token.
399
403
  2. Ask user to paste the bot token.
@@ -408,7 +412,7 @@ After writing the profile, ask: *"Want to set up mobile access so you can reach
408
412
  **⚠️ 重要:** 在「事件订阅」页面,必须开启「接收消息 im.message.receive_v1」事件。然后在该事件的配置中,勾选「获取群组中所有消息」(否则 bot 在群聊中只能收到 @它 的消息,无法接收普通群消息)。
409
413
  2. Ask user to paste App ID and App Secret.
410
414
  3. Write \`app_id\` and \`app_secret\` into \`~/.metame/daemon.yaml\` under \`feishu:\` section, set \`enabled: true\`.
411
- 4. Tell user: "Now open Feishu and send any message to your new bot, then tell me you're done."
415
+ 4. Tell user: "Now open Feishu and send any message to your new bot (private chat), then tell me you're done."
412
416
  5. After user confirms, auto-fetch the chat ID:
413
417
  \`\`\`bash
414
418
  TOKEN=$(curl -s -X POST https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal -H "Content-Type: application/json" -d '{"app_id":"<APP_ID>","app_secret":"<APP_SECRET>"}' | jq -r '.tenant_access_token')
@@ -419,22 +423,16 @@ After writing the profile, ask: *"Want to set up mobile access so you can reach
419
423
 
420
424
  - If **Skip:** Say "No problem. You can run \`metame daemon init\` anytime to set this up later." Then begin normal work.
421
425
 
422
- **After setup, teach the user about Agent Dispatch:**
423
-
424
- Tell the user: *"You now have multiple AI agents that can collaborate. Here's how to send messages between them:"*
425
-
426
- - **From mobile (Telegram/Feishu):** Each chat group is bound to a specific agent (project). To send a message to another agent, use:
427
- \`/dispatch <project_key> <message>\`
428
- Example: \`/dispatch desktop 帮我检查一下桌面端的日志\`
429
-
430
- - **From Claude Code terminal:** Use the dispatch command:
431
- \`~/.metame/bin/dispatch_to <project_key> "message"\`
426
+ **Step B: Create Your First Agent (引导用户建立第一个 Agent)**
432
427
 
433
- - **Natural language shortcut:** Just say "告诉小美…" or "让老马…" — the AI will automatically route the message to the right agent.
428
+ After bot is connected, explain the Agent concept and guide the user to create their first one:
434
429
 
435
- - **Available agents** are configured in \`~/.metame/daemon.yaml\` under \`projects:\`. Each project maps to an agent with its own working directory and Claude session.
430
+ Tell the user: *"Now let's create your first AI Agent. Each Agent is an independent AI workspace bound to a specific project folder and chat group."*
436
431
 
437
- - **To add a new agent:** Send \`/agent bind <name> <working_directory>\` from any chat. This creates a new project and binds it to that chat.
432
+ 1. Tell user to create a new group chat in Telegram/Feishu, add the bot to the group, and name it (e.g. "Personal Assistant" or "My Project").
433
+ 2. Tell user to send \`/agent bind <name>\` in that group (e.g. \`/agent bind personal\`). This will show a directory picker — user taps to select the working directory.
434
+ 3. Once bound, that group becomes a dedicated Agent channel — messages there go to that Agent's Claude session.
435
+ 4. Tell user: *"Want to create more Agents? Just repeat: create a group → add bot → send /agent bind <name>. Each group becomes an independent Agent."*
438
436
 
439
437
  **4. EVOLUTION MECHANISM (Manual Sync):**
440
438
  * **PHILOSOPHY:** You respect the User's flow. You do NOT interrupt.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metame-cli",
3
- "version": "1.4.8",
3
+ "version": "1.4.12",
4
4
  "description": "The Cognitive Profile Layer for Claude Code. Knows how you think, not just what you said.",
5
5
  "main": "index.js",
6
6
  "bin": {
package/scripts/daemon.js CHANGED
@@ -28,6 +28,7 @@ const BRAIN_FILE = path.join(HOME, '.claude_profile.yaml');
28
28
  const DISPATCH_DIR = path.join(METAME_DIR, 'dispatch');
29
29
  const DISPATCH_LOG = path.join(DISPATCH_DIR, 'dispatch-log.jsonl');
30
30
  const SOCK_PATH = path.join(METAME_DIR, 'daemon.sock');
31
+ const DISPATCH_SECRET_FILE = path.join(METAME_DIR, '.dispatch_secret');
31
32
 
32
33
  // Resolve claude binary path (daemon may not inherit user's full PATH)
33
34
  const CLAUDE_BIN = (() => {
@@ -78,7 +79,7 @@ function routeAgent(prompt, config) {
78
79
  }
79
80
 
80
81
  const yaml = require('./resolve-yaml');
81
- const { parseInterval, formatRelativeTime, createPathMap } = require('./utils');
82
+ const { parseInterval, formatRelativeTime, createPathMap, writeBrainFileSafe } = require('./utils');
82
83
  if (!yaml) {
83
84
  console.error('Cannot find js-yaml module. Ensure metame-cli is installed.');
84
85
  process.exit(1);
@@ -149,6 +150,21 @@ function backupConfig() {
149
150
  try { fs.copyFileSync(CONFIG_FILE, bak); } catch { }
150
151
  }
151
152
 
153
+ /**
154
+ * Atomically write cfg object to CONFIG_FILE.
155
+ * Writes to a .tmp file first, then fs.renameSync (POSIX atomic).
156
+ * Process crash leaves .tmp on disk, never a partially-written CONFIG_FILE.
157
+ */
158
+ function writeConfigSafe(cfg) {
159
+ const tmp = CONFIG_FILE + '.tmp.' + process.pid;
160
+ try {
161
+ fs.writeFileSync(tmp, yaml.dump(cfg, { lineWidth: -1 }), 'utf8');
162
+ fs.renameSync(tmp, CONFIG_FILE);
163
+ } finally {
164
+ try { fs.unlinkSync(tmp); } catch { } // no-op if rename succeeded
165
+ }
166
+ }
167
+
152
168
  function restoreConfig() {
153
169
  const bak = CONFIG_FILE + '.bak';
154
170
  if (!fs.existsSync(bak)) return false;
@@ -171,7 +187,7 @@ function restoreConfig() {
171
187
  );
172
188
  }
173
189
  }
174
- fs.writeFileSync(CONFIG_FILE, yaml.dump(bakCfg, { lineWidth: -1 }), 'utf8');
190
+ writeConfigSafe(bakCfg);
175
191
  config = loadConfig();
176
192
  return true;
177
193
  } catch {
@@ -797,11 +813,59 @@ function spawnSessionSummaries() {
797
813
  * Physiological heartbeat: zero-token awareness check.
798
814
  * Runs every tick unconditionally.
799
815
  */
816
+ /**
817
+ * Load or generate the dispatch HMAC secret.
818
+ */
819
+ function getDispatchSecret() {
820
+ try {
821
+ if (fs.existsSync(DISPATCH_SECRET_FILE)) {
822
+ return fs.readFileSync(DISPATCH_SECRET_FILE, 'utf8').trim();
823
+ }
824
+ } catch { /* fall through to generate */ }
825
+ // Auto-generate and persist
826
+ const secret = require('crypto').randomBytes(32).toString('hex');
827
+ try {
828
+ fs.writeFileSync(DISPATCH_SECRET_FILE, secret, { mode: 0o600 });
829
+ } catch (e) {
830
+ log('WARN', `[dispatch] Could not write secret file: ${e.message}`);
831
+ }
832
+ return secret;
833
+ }
834
+
835
+ /**
836
+ * Compute HMAC-SHA256 signature for a dispatch item.
837
+ */
838
+ function signDispatch(target, prompt, ts) {
839
+ const secret = getDispatchSecret();
840
+ const payload = JSON.stringify({ target, prompt, ts });
841
+ return require('crypto').createHmac('sha256', secret).update(payload).digest('hex');
842
+ }
843
+
844
+ /**
845
+ * Verify dispatch signature. Returns true if valid or if sig is absent and
846
+ * legacy mode (no secret file exists yet). Logs + returns false on mismatch.
847
+ */
848
+ function verifyDispatchSig(item) {
849
+ // If no secret file exists, accept unsigned messages (migration grace period)
850
+ if (!fs.existsSync(DISPATCH_SECRET_FILE)) return true;
851
+ if (!item.sig || !item.ts) {
852
+ log('WARN', `[dispatch] Unsigned message from "${item.from}" — dropped (sig/ts missing)`);
853
+ return false;
854
+ }
855
+ const expected = signDispatch(item.target, item.prompt, item.ts);
856
+ if (item.sig !== expected) {
857
+ log('WARN', `[dispatch] Signature mismatch from "${item.from}" → "${item.target}" — dropped`);
858
+ return false;
859
+ }
860
+ return true;
861
+ }
862
+
800
863
  /**
801
864
  * Handle a single dispatch message (from socket or pending.jsonl fallback).
802
865
  */
803
866
  function handleDispatchItem(item, config) {
804
867
  if (!item.target || !item.prompt) return;
868
+ if (!verifyDispatchSig(item)) return;
805
869
  if (!(config && config.projects && config.projects[item.target])) {
806
870
  log('WARN', `dispatch: unknown target "${item.target}"`);
807
871
  return;
@@ -918,6 +982,31 @@ function physiologicalHeartbeat(config) {
918
982
  log('WARN', `Pending dispatch drain failed: ${e.message}`);
919
983
  }
920
984
 
985
+ // 2a. Check distill budget alerts — notify user once then clear
986
+ const DISTILL_ALERTS = path.join(METAME_DIR, 'distill_alerts.jsonl');
987
+ try {
988
+ if (fs.existsSync(DISTILL_ALERTS)) {
989
+ const raw = fs.readFileSync(DISTILL_ALERTS, 'utf8').trim();
990
+ if (raw) {
991
+ const alerts = raw.split('\n').filter(Boolean)
992
+ .map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
993
+ const budgetAlerts = alerts.filter(a => a.type === 'budget_exceeded');
994
+ if (budgetAlerts.length > 0) {
995
+ const latest = budgetAlerts[budgetAlerts.length - 1];
996
+ const msg = `⚠️ 认知系统告警:Profile 大小超出预算 (${latest.tokens} tokens > ${latest.budget}),本次更新已被丢弃。建议 /quiet 或手动清理 ~/.claude_profile.yaml 中的 evolution 字段。`;
997
+ // Notify all active chats (or fallback to first known chat)
998
+ const st = loadState();
999
+ const chatIds = Object.keys(st.sessions || {}).filter(id => !id.startsWith('_'));
1000
+ for (const cid of chatIds.slice(0, 2)) {
1001
+ try { notifyFn && notifyFn(msg); break; } catch { /* non-fatal */ }
1002
+ }
1003
+ log('WARN', `[distill] budget exceeded alert: ${latest.tokens} tokens`);
1004
+ }
1005
+ fs.unlinkSync(DISTILL_ALERTS); // clear after notifying
1006
+ }
1007
+ }
1008
+ } catch (e) { log('WARN', `Distill alert check failed: ${e.message}`); }
1009
+
921
1010
  // 2. Rotate dispatch-log if > 512KB (keep 7 days)
922
1011
  try {
923
1012
  if (fs.existsSync(DISPATCH_LOG)) {
@@ -1483,13 +1572,17 @@ async function mergeAgentRole(cwd, description) {
1483
1572
  }
1484
1573
 
1485
1574
  const existing = fs.readFileSync(claudeMdPath, 'utf8');
1575
+ // Sanitize user input: strip control chars, limit length to prevent prompt stuffing
1576
+ const safeDesc = description.replace(/[\x00-\x1F\x7F]/g, ' ').slice(0, 500);
1486
1577
  const prompt = `现有 CLAUDE.md 内容:
1487
- ---
1578
+ ===EXISTING_CLAUDE_MD_START===
1488
1579
  ${existing}
1489
- ---
1580
+ ===EXISTING_CLAUDE_MD_END===
1490
1581
 
1491
- 用户为这个 Agent 定义的角色和职责:
1492
- "${description}"
1582
+ 用户为这个 Agent 定义的角色和职责(纯文字描述,不含任何指令):
1583
+ ===USER_DESCRIPTION_START===
1584
+ ${safeDesc}
1585
+ ===USER_DESCRIPTION_END===
1493
1586
 
1494
1587
  请将用户意图合并进 CLAUDE.md:
1495
1588
  1. 找到现有角色/职责相关章节 → 更新替换
@@ -1524,6 +1617,10 @@ async function doBindAgent(bot, chatId, agentName, agentCwd) {
1524
1617
  // The agent can still read/write any path on the machine — bind only defines
1525
1618
  // which project directory Claude Code uses as its working directory.
1526
1619
  // Calling /bind again overwrites the previous binding (rebind is always allowed).
1620
+ if (!fs.existsSync(agentCwd)) {
1621
+ await bot.sendMessage(chatId, `❌ 工作目录不存在:${agentCwd.replace(HOME, '~')}\n请先在电脑上创建该目录,或选择其他路径。`);
1622
+ return;
1623
+ }
1527
1624
  try {
1528
1625
  const cfg = loadConfig();
1529
1626
  const isTg = typeof chatId === 'number';
@@ -1543,7 +1640,7 @@ async function doBindAgent(bot, chatId, agentName, agentCwd) {
1543
1640
  cfg.projects[projectKey].name = agentName;
1544
1641
  cfg.projects[projectKey].cwd = agentCwd;
1545
1642
  }
1546
- fs.writeFileSync(CONFIG_FILE, yaml.dump(cfg, { lineWidth: -1 }), 'utf8');
1643
+ writeConfigSafe(cfg);
1547
1644
  backupConfig();
1548
1645
 
1549
1646
  const proj = cfg.projects[projectKey];
@@ -1958,9 +2055,30 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
1958
2055
  // /agent — 弹出 agent 切换选择器(无参数)
1959
2056
  // ─────────────────────────────────────────────────────────────────────
1960
2057
 
1961
- // 处理 /agent new 多步向导状态机中的文本输入(name/desc 步骤)
2058
+ // 处理 /agent new 多步向导状态机中的文本输入(dir/name/desc 步骤)
1962
2059
  {
1963
2060
  const flow = pendingAgentFlows.get(String(chatId));
2061
+ if (flow && flow.step === 'dir' && text && !text.startsWith('/')) {
2062
+ // 步骤1:用户直接输入了目录路径(对话式新建)
2063
+ const typedPath = expandPath(text.trim());
2064
+ let created = false;
2065
+ if (!fs.existsSync(typedPath)) {
2066
+ try {
2067
+ fs.mkdirSync(typedPath, { recursive: true });
2068
+ created = true;
2069
+ } catch (e) {
2070
+ await bot.sendMessage(chatId, `❌ 无法创建目录:${typedPath.replace(HOME, '~')}\n${e.message}`);
2071
+ return;
2072
+ }
2073
+ }
2074
+ flow.dir = typedPath;
2075
+ flow.step = 'name';
2076
+ pendingAgentFlows.set(String(chatId), flow);
2077
+ const displayPath = typedPath.replace(HOME, '~');
2078
+ const label = created ? `✅ 已新建目录:${displayPath}` : `✓ 已选择目录:${displayPath}`;
2079
+ await bot.sendMessage(chatId, `${label}\n\n步骤2/3:给这个 Agent 起个名字?`);
2080
+ return;
2081
+ }
1964
2082
  if (flow && flow.step === 'name' && text && !text.startsWith('/')) {
1965
2083
  // 步骤2: 用户回复了 Agent 名称
1966
2084
  flow.name = text.trim();
@@ -2034,7 +2152,12 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
2034
2152
  await sendDirPicker(bot, chatId, 'bind', `为「${bindName}」选择工作目录:`);
2035
2153
  return;
2036
2154
  }
2037
- await doBindAgent(bot, chatId, bindName, expandPath(bindCwd));
2155
+ const resolvedBindCwd = expandPath(bindCwd);
2156
+ if (!fs.existsSync(resolvedBindCwd)) {
2157
+ await bot.sendMessage(chatId, `❌ 目录不存在:${resolvedBindCwd.replace(HOME, '~')}\n请检查路径或选择其他目录。`);
2158
+ return;
2159
+ }
2160
+ await doBindAgent(bot, chatId, bindName, resolvedBindCwd);
2038
2161
  return;
2039
2162
  }
2040
2163
 
@@ -2167,11 +2290,23 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
2167
2290
  await bot.sendMessage(chatId, '❌ 没有待完成的 /agent new,请重新发送 /agent new');
2168
2291
  return;
2169
2292
  }
2293
+ let dirCreated = false;
2294
+ if (!fs.existsSync(dirPath)) {
2295
+ try {
2296
+ fs.mkdirSync(dirPath, { recursive: true });
2297
+ dirCreated = true;
2298
+ } catch (e) {
2299
+ await bot.sendMessage(chatId, `❌ 无法创建目录:${dirPath.replace(HOME, '~')}\n${e.message}`);
2300
+ await sendBrowse(bot, chatId, 'agent-new', HOME, '步骤1/3:选择这个 Agent 的工作目录');
2301
+ return;
2302
+ }
2303
+ }
2170
2304
  flow.dir = dirPath;
2171
2305
  flow.step = 'name';
2172
2306
  pendingAgentFlows.set(String(chatId), flow);
2173
2307
  const displayPath = dirPath.replace(HOME, '~');
2174
- await bot.sendMessage(chatId, `✓ 已选择目录:${displayPath}\n\n步骤2/3:给这个 Agent 起个名字?`);
2308
+ const dirLabel = dirCreated ? `✅ 已新建目录:${displayPath}` : `✓ 已选择目录:${displayPath}`;
2309
+ await bot.sendMessage(chatId, `${dirLabel}\n\n步骤2/3:给这个 Agent 起个名字?`);
2175
2310
  return;
2176
2311
  }
2177
2312
 
@@ -2919,7 +3054,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
2919
3054
  const doc = yaml.load(fs.readFileSync(BRAIN_FILE, 'utf8')) || {};
2920
3055
  if (!doc.growth) doc.growth = {};
2921
3056
  doc.growth.quiet_until = new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString();
2922
- fs.writeFileSync(BRAIN_FILE, yaml.dump(doc, { lineWidth: -1 }), 'utf8');
3057
+ await writeBrainFileSafe(yaml.dump(doc, { lineWidth: -1 }), BRAIN_FILE);
2923
3058
  await bot.sendMessage(chatId, 'Mirror & reflections silenced for 48h.');
2924
3059
  } catch (e) { await bot.sendMessage(chatId, `Error: ${e.message}`); }
2925
3060
  return;
@@ -2954,7 +3089,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
2954
3089
  const cfg = yaml.load(fs.readFileSync(CONFIG_FILE, 'utf8')) || {};
2955
3090
  if (!cfg.daemon) cfg.daemon = {};
2956
3091
  cfg.daemon.model = 'opus';
2957
- fs.writeFileSync(CONFIG_FILE, yaml.dump(cfg, { lineWidth: -1 }), 'utf8');
3092
+ writeConfigSafe(cfg);
2958
3093
  config = loadConfig();
2959
3094
  await bot.sendMessage(chatId, '✅ 模型已重置为 opus');
2960
3095
  } catch (e) {
@@ -3053,7 +3188,7 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName, sende
3053
3188
  const cfg = yaml.load(fs.readFileSync(CONFIG_FILE, 'utf8')) || {};
3054
3189
  if (!cfg.daemon) cfg.daemon = {};
3055
3190
  cfg.daemon.model = modelName;
3056
- fs.writeFileSync(CONFIG_FILE, yaml.dump(cfg, { lineWidth: -1 }), 'utf8');
3191
+ writeConfigSafe(cfg);
3057
3192
  config = loadConfig();
3058
3193
  await bot.sendMessage(chatId, `✅ 模型已切换: ${currentModel} → ${modelName}`);
3059
3194
  } catch (e) {
@@ -4245,7 +4380,7 @@ async function askClaude(bot, chatId, prompt, config, readOnly = false) {
4245
4380
  // Send a single status message, updated in-place, deleted on completion
4246
4381
  let statusMsgId = null;
4247
4382
  try {
4248
- const msg = await bot.sendMessage(chatId, '🤔');
4383
+ const msg = await (bot.sendMarkdown ? bot.sendMarkdown(chatId, '🤔') : bot.sendMessage(chatId, '🤔'));
4249
4384
  if (msg && msg.message_id) statusMsgId = msg.message_id;
4250
4385
  } catch (e) {
4251
4386
  log('ERROR', `Failed to send ack to ${chatId}: ${e.message}`);
@@ -4491,7 +4626,7 @@ async function askClaude(bot, chatId, prompt, config, readOnly = false) {
4491
4626
  const cfg = yaml.load(fs.readFileSync(CONFIG_FILE, 'utf8')) || {};
4492
4627
  if (!cfg.daemon) cfg.daemon = {};
4493
4628
  cfg.daemon.model = 'opus';
4494
- fs.writeFileSync(CONFIG_FILE, yaml.dump(cfg, { lineWidth: -1 }), 'utf8');
4629
+ writeConfigSafe(cfg);
4495
4630
  config = loadConfig();
4496
4631
  await bot.sendMessage(chatId, `⚠️ ${activeProvCheck}/${model} 疑似失败,已回退到 anthropic/opus\n输出: ${output.slice(0, 150)}`);
4497
4632
  } catch (fbErr) {
@@ -4587,7 +4722,7 @@ async function askClaude(bot, chatId, prompt, config, readOnly = false) {
4587
4722
  const cfg = yaml.load(fs.readFileSync(CONFIG_FILE, 'utf8')) || {};
4588
4723
  if (!cfg.daemon) cfg.daemon = {};
4589
4724
  cfg.daemon.model = 'opus';
4590
- fs.writeFileSync(CONFIG_FILE, yaml.dump(cfg, { lineWidth: -1 }), 'utf8');
4725
+ writeConfigSafe(cfg);
4591
4726
  config = loadConfig();
4592
4727
  await bot.sendMessage(chatId, `⚠️ ${activeProv}/${model} 失败,已回退到 anthropic/opus\n原因: ${errMsg.slice(0, 100)}`);
4593
4728
  } catch (fallbackErr) {
@@ -4626,7 +4761,7 @@ async function startFeishuBridge(config, executeTaskByName) {
4626
4761
  const isBindCmd = trimmedText && (trimmedText.startsWith('/bind') || trimmedText.startsWith('/agent bind') || trimmedText.startsWith('/agent new'));
4627
4762
  if (!allowedIds.includes(chatId) && !isBindCmd) {
4628
4763
  log('WARN', `Feishu: rejected message from ${chatId}`);
4629
- (bot.sendMarkdown ? bot.sendMarkdown(chatId, `⚠️ **此会话未授权**\n\n复制下方命令发送即可注册:\n\`\`\`\n/agent bind personal\n\`\`\``) : bot.sendMessage(chatId, `⚠️ 此会话未授权。\n\n复制下方命令发送即可注册:\n/agent bind personal`)).catch(() => {});
4764
+ (bot.sendMarkdown ? bot.sendMarkdown(chatId, `⚠️ 此会话未授权\n\n复制发送以下命令注册:\n\n/agent bind personal`) : bot.sendMessage(chatId, `⚠️ 此会话未授权\n\n复制发送以下命令注册:\n\n/agent bind personal`)).catch(() => {});
4630
4765
  return;
4631
4766
  }
4632
4767
 
@@ -4885,6 +5020,64 @@ async function main() {
4885
5020
  // Start heartbeat scheduler
4886
5021
  let heartbeatTimer = startHeartbeat(config, notifyFn);
4887
5022
 
5023
+ // Adapter modules that support require.cache invalidation on file change
5024
+ const ADAPTER_MODULES = ['feishu-adapter', 'telegram-adapter', 'providers', 'skill-evolution'];
5025
+ // Track last known mtime for each adapter file (populated after bridge startup)
5026
+ const _adapterMtimes = new Map();
5027
+ function _readAdapterMtimes() {
5028
+ for (const mod of ADAPTER_MODULES) {
5029
+ const fullPath = path.join(__dirname, `${mod}.js`);
5030
+ try { _adapterMtimes.set(fullPath, fs.statSync(fullPath).mtimeMs); } catch { /* file may not exist */ }
5031
+ }
5032
+ }
5033
+
5034
+ // Reload adapters whose files changed on disk (stop→clear cache→restart bridges)
5035
+ async function reloadAdapters() {
5036
+ const changed = [];
5037
+ for (const mod of ADAPTER_MODULES) {
5038
+ const fullPath = path.join(__dirname, `${mod}.js`);
5039
+ try {
5040
+ const mtime = fs.statSync(fullPath).mtimeMs;
5041
+ if (_adapterMtimes.get(fullPath) !== mtime) {
5042
+ changed.push({ mod, fullPath });
5043
+ _adapterMtimes.set(fullPath, mtime);
5044
+ }
5045
+ } catch { /* ignore */ }
5046
+ }
5047
+ if (changed.length === 0) return { reloaded: [] };
5048
+ log('INFO', `[reloadAdapters] changed files: ${changed.map(c => c.mod).join(', ')}`);
5049
+ const bridgeAdapters = new Set(changed.map(c => c.mod).filter(m => m === 'feishu-adapter' || m === 'telegram-adapter'));
5050
+ // Stop bridges before clearing cache to avoid duplicate listeners
5051
+ if (bridgeAdapters.has('feishu-adapter') && feishuBridge) {
5052
+ try { feishuBridge.stop(); } catch { /* ignore */ }
5053
+ feishuBridge = null;
5054
+ _dispatchBridgeRef = null;
5055
+ }
5056
+ if (bridgeAdapters.has('telegram-adapter') && telegramBridge) {
5057
+ try { telegramBridge.stop(); } catch { /* ignore */ }
5058
+ telegramBridge = null;
5059
+ }
5060
+ // Clear require.cache for changed modules
5061
+ for (const { fullPath } of changed) {
5062
+ try { delete require.cache[require.resolve(fullPath)]; } catch { /* ignore */ }
5063
+ }
5064
+ // Restart bridges
5065
+ if (bridgeAdapters.has('telegram-adapter')) {
5066
+ telegramBridge = await startTelegramBridge(config, executeTaskByName).catch(e => {
5067
+ log('ERROR', `[reloadAdapters] Telegram restart failed: ${e.message}`);
5068
+ return null;
5069
+ });
5070
+ }
5071
+ if (bridgeAdapters.has('feishu-adapter')) {
5072
+ feishuBridge = await startFeishuBridge(config, executeTaskByName).catch(e => {
5073
+ log('ERROR', `[reloadAdapters] Feishu restart failed: ${e.message}`);
5074
+ return null;
5075
+ });
5076
+ if (feishuBridge) _dispatchBridgeRef = feishuBridge;
5077
+ }
5078
+ return { reloaded: changed.map(c => c.mod) };
5079
+ }
5080
+
4888
5081
  // Hot reload: re-read config and restart heartbeat scheduler
4889
5082
  function reloadConfig() {
4890
5083
  const newConfig = loadConfig();
@@ -4895,6 +5088,12 @@ async function main() {
4895
5088
  heartbeatTimer = startHeartbeat(config, notifyFn);
4896
5089
  const { general, project } = getAllTasks(config);
4897
5090
  const totalCount = general.length + project.length;
5091
+ // Also check for adapter file changes (non-daemon.js modules)
5092
+ reloadAdapters().then(r => {
5093
+ if (r.reloaded.length > 0) {
5094
+ log('INFO', `[reloadAdapters] Reloaded: ${r.reloaded.join(', ')}`);
5095
+ }
5096
+ }).catch(e => log('WARN', `[reloadAdapters] Error: ${e.message}`));
4898
5097
  log('INFO', `Config reloaded: ${totalCount} tasks (${project.length} in projects)`);
4899
5098
  return { success: true, tasks: totalCount };
4900
5099
  }
@@ -4956,6 +5155,7 @@ async function main() {
4956
5155
  telegramBridge = await startTelegramBridge(config, executeTaskByName);
4957
5156
  feishuBridge = await startFeishuBridge(config, executeTaskByName);
4958
5157
  if (feishuBridge) _dispatchBridgeRef = feishuBridge; // store bridge, not bot, so .bot stays live after reconnects
5158
+ _readAdapterMtimes(); // Baseline mtimes for hot-reload detection
4959
5159
 
4960
5160
  // Notify once on startup (single message, no duplicates)
4961
5161
  await sleep(1500); // Let polling settle
@@ -20,13 +20,16 @@ const BRAIN_FILE = path.join(HOME, '.claude_profile.yaml');
20
20
  const LOCK_FILE = path.join(HOME, '.metame', 'distill.lock');
21
21
 
22
22
  const { hasKey, isLocked, getTier, getWritableKeysForPrompt, estimateTokens, TOKEN_BUDGET } = require('./schema');
23
- const { loadPending, savePending, upsertPending, getPromotable, removePromoted } = require('./pending-traits');
23
+ const { loadPending, savePending, upsertPending, getPromotable, removePromoted, expireStale } = require('./pending-traits');
24
+ const { writeBrainFileSafe } = require('./utils');
24
25
 
25
26
  // Session analytics — local skeleton extraction (zero API cost)
26
27
  let sessionAnalytics = null;
27
28
  try {
28
29
  sessionAnalytics = require('./session-analytics');
29
- } catch { /* session-analytics.js not available — graceful fallback */ }
30
+ } catch (e) {
31
+ console.log(`[distill] session-analytics unavailable: ${e.message} — behavior extraction disabled`);
32
+ }
30
33
 
31
34
  // Provider env for distillation (cheap relay for background tasks)
32
35
  let distillEnv = {};
@@ -115,7 +118,9 @@ async function distill() {
115
118
  // For long sessions, extract pivot points
116
119
  sessionSummary = sessionAnalytics.summarizeSession(skeleton, latest.path);
117
120
  }
118
- } catch { /* non-fatal */ }
121
+ } catch (e) {
122
+ console.log(`[distill] session context extraction failed: ${e.message}`);
123
+ }
119
124
  }
120
125
 
121
126
  // 4. Read current profile
@@ -169,9 +174,9 @@ async function distill() {
169
174
  budgetForMessages = availableForContent;
170
175
  }
171
176
 
172
- // Format signals: tag metacognitive signals so Haiku treats them differently
177
+ // Format signals: tag metacognitive and correction signals so Haiku treats them differently
173
178
  const formatSignal = (s, i) => {
174
- const tag = s.type === 'metacognitive' ? ' [META]' : '';
179
+ const tag = s.type === 'metacognitive' ? ' [META]' : s.type === 'correction' ? ' [CORRECTION]' : '';
175
180
  return `${i + 1}. "${s.text}"${tag}`;
176
181
  };
177
182
 
@@ -215,6 +220,7 @@ BIAS PREVENTION:
215
220
  - Single observation = STATE, not TRAIT. T3 cognition needs 3+ observations.
216
221
  - L1 Surface → needs 5+, L2 Behavior → needs 3, L3 Self-declaration → direct write.
217
222
  - Contradiction with existing value → do NOT output (needs accumulation).
223
+ - EXCEPTION: [CORRECTION] signals are explicit user corrections — they CAN and SHOULD overwrite contradicting existing values directly, with _confidence: high. The user is teaching us we were wrong.
218
224
 
219
225
  BEHAVIORAL ANALYSIS — _behavior block (always output, use null if insufficient signal):
220
226
  decision_pattern: premature_closure | exploratory | iterative | null
@@ -302,6 +308,7 @@ Do NOT repeat existing unchanged values.`;
302
308
 
303
309
  // Strategic merge: tier-aware upsert with pending traits
304
310
  const pendingTraits = loadPending();
311
+ expireStale(pendingTraits); // remove observations not seen for >30 days
305
312
  const confidenceMap = updates._confidence || {};
306
313
  const sourceMap = updates._source || {};
307
314
  const merged = strategicMerge(profile, filtered, lockedKeys, pendingTraits, confidenceMap, sourceMap);
@@ -343,12 +350,17 @@ Do NOT repeat existing unchanged values.`;
343
350
  tokens = estimateTokens(restored);
344
351
  }
345
352
  if (tokens > TOKEN_BUDGET) {
346
- // Step 3: Reject write entirely, keep previous version
353
+ // Step 3: Reject write entirely emit alert so daemon can notify user
354
+ const alertFile = path.join(HOME, '.metame', 'distill_alerts.jsonl');
355
+ try {
356
+ const alert = { ts: new Date().toISOString(), type: 'budget_exceeded', tokens, budget: TOKEN_BUDGET };
357
+ fs.appendFileSync(alertFile, JSON.stringify(alert) + '\n', 'utf8');
358
+ } catch { /* non-fatal */ }
347
359
  cleanup();
348
360
  return { updated: false, behavior, signalCount: signals.length, summary: `Profile too large (${tokens} tokens > ${TOKEN_BUDGET}). Write rejected to prevent bloat.` };
349
361
  }
350
362
 
351
- fs.writeFileSync(BRAIN_FILE, restored, 'utf8');
363
+ await writeBrainFileSafe(restored, BRAIN_FILE);
352
364
 
353
365
  // Mark session as analyzed after successful distill
354
366
  if (skeleton && sessionAnalytics) {
@@ -100,9 +100,12 @@ function createBot(config) {
100
100
  async editMessage(chatId, messageId, text) {
101
101
  if (this._editBroken) return false;
102
102
  try {
103
+ // Feishu patch API only works on card (interactive) messages
104
+ // Update card content with markdown element
105
+ const card = { schema: '2.0', body: { elements: [{ tag: 'markdown', content: text }] } };
103
106
  await withTimeout(client.im.message.patch({
104
107
  path: { message_id: messageId },
105
- data: { content: JSON.stringify({ text }) },
108
+ data: { content: JSON.stringify(card) },
106
109
  }));
107
110
  return true;
108
111
  } catch (e) {
@@ -181,8 +181,8 @@ async function run() {
181
181
  let sessionAnalytics;
182
182
  try {
183
183
  sessionAnalytics = require('./session-analytics');
184
- } catch {
185
- console.log('[memory-extract] session-analytics not available, exiting.');
184
+ } catch (e) {
185
+ console.log(`[memory-extract] session-analytics unavailable: ${e.message} — memory extraction disabled`);
186
186
  return { sessionsProcessed: 0, factsSaved: 0, factsSkipped: 0 };
187
187
  }
188
188
 
@@ -222,14 +222,15 @@ async function run() {
222
222
  const { facts, session_name } = await extractFacts(skeleton, null, distillEnv);
223
223
 
224
224
  if (facts.length > 0) {
225
- const { saved, skipped } = memory.saveFacts(
225
+ const { saved, skipped, superseded } = memory.saveFacts(
226
226
  skeleton.session_id,
227
227
  skeleton.project || 'unknown',
228
228
  facts
229
229
  );
230
230
  totalSaved += saved;
231
231
  totalSkipped += skipped;
232
- console.log(`[memory-extract] Session ${skeleton.session_id.slice(0, 8)}: ${saved} facts saved, ${skipped} skipped`);
232
+ const superMsg = superseded > 0 ? `, ${superseded} superseded` : '';
233
+ console.log(`[memory-extract] Session ${skeleton.session_id.slice(0, 8)}: ${saved} facts saved, ${skipped} skipped${superMsg}`);
233
234
  } else {
234
235
  console.log(`[memory-extract] Session ${skeleton.session_id.slice(0, 8)} (${session_name}): no facts extracted`);
235
236
  }
package/scripts/memory.js CHANGED
@@ -144,6 +144,15 @@ function getDb() {
144
144
  try { _db.exec('CREATE INDEX IF NOT EXISTS idx_facts_entity ON facts(entity)'); } catch {}
145
145
  try { _db.exec('CREATE INDEX IF NOT EXISTS idx_facts_project ON facts(project)'); } catch {}
146
146
 
147
+ // Search frequency tracking: counts how many times a fact appeared in search results.
148
+ // This is a RELEVANCE PROXY, not a usefulness score — "searched" ≠ "actually helpful".
149
+ // Renamed from recall_count (was ambiguous). Migration copies existing data forward.
150
+ try { _db.exec('ALTER TABLE facts ADD COLUMN recall_count INTEGER DEFAULT 0'); } catch {}
151
+ try { _db.exec('ALTER TABLE facts ADD COLUMN search_count INTEGER DEFAULT 0'); } catch {}
152
+ try { _db.exec('ALTER TABLE facts ADD COLUMN last_searched_at TEXT'); } catch {}
153
+ // One-time migration: copy recall_count → search_count for existing rows
154
+ try { _db.exec('UPDATE facts SET search_count = recall_count WHERE recall_count > 0 AND search_count = 0'); } catch {}
155
+
147
156
  return _db;
148
157
  }
149
158
 
@@ -177,16 +186,20 @@ function saveSession({ sessionId, project, summary, keywords = '', mood = '', to
177
186
  return { ok: true, id: sessionId };
178
187
  }
179
188
 
189
+ // Relations with "current state" semantics: new value replaces old.
190
+ // Historical relations (tech_decision, bug_lesson, arch_convention, project_milestone) keep all versions.
191
+ const STATEFUL_RELATIONS = new Set(['user_pref', 'config_fact', 'config_change', 'workflow_rule']);
192
+
180
193
  /**
181
194
  * Save atomic facts extracted from a session.
182
195
  *
183
196
  * @param {string} sessionId - Source session ID
184
197
  * @param {string} project - Project key ('metame', 'desktop', '*' for global)
185
198
  * @param {Array} facts - Array of { entity, relation, value, confidence, tags }
186
- * @returns {{ saved: number, skipped: number }}
199
+ * @returns {{ saved: number, skipped: number, superseded: number }}
187
200
  */
188
201
  function saveFacts(sessionId, project, facts) {
189
- if (!Array.isArray(facts) || facts.length === 0) return { saved: 0, skipped: 0 };
202
+ if (!Array.isArray(facts) || facts.length === 0) return { saved: 0, skipped: 0, superseded: 0 };
190
203
  const db = getDb();
191
204
 
192
205
  // Load existing facts for dedup check
@@ -200,8 +213,14 @@ function saveFacts(sessionId, project, facts) {
200
213
  ON CONFLICT(id) DO NOTHING
201
214
  `);
202
215
 
216
+ const supersede = db.prepare(`
217
+ UPDATE facts SET superseded_by = ?, updated_at = datetime('now')
218
+ WHERE entity = ? AND relation = ? AND id != ? AND superseded_by IS NULL
219
+ `);
220
+
203
221
  let saved = 0;
204
222
  let skipped = 0;
223
+ let superseded = 0;
205
224
  const savedFacts = [];
206
225
 
207
226
  for (const f of facts) {
@@ -225,6 +244,24 @@ function saveFacts(sessionId, project, facts) {
225
244
  savedFacts.push({ id, entity: f.entity, relation: f.relation, value: f.value,
226
245
  project: project === '*' ? '*' : project, tags: f.tags || [], created_at: new Date().toISOString() });
227
246
  saved++;
247
+
248
+ // For stateful relations, mark older active facts with same entity::relation as superseded
249
+ if (STATEFUL_RELATIONS.has(f.relation)) {
250
+ // Fetch the IDs being superseded before running the update (for audit log)
251
+ const db2 = getDb();
252
+ const toSupersede = db2.prepare(
253
+ 'SELECT id, value FROM facts WHERE entity = ? AND relation = ? AND id != ? AND superseded_by IS NULL'
254
+ ).all(f.entity, f.relation, id);
255
+
256
+ const result = supersede.run(id, f.entity, f.relation, id);
257
+ const changes = result.changes || 0;
258
+ superseded += changes;
259
+
260
+ // Audit log: append to ~/.metame/memory_supersede_log.jsonl (never mutates, only appends)
261
+ if (changes > 0) {
262
+ _logSupersede(toSupersede, id, f.entity, f.relation, f.value, sessionId);
263
+ }
264
+ }
228
265
  } catch { skipped++; }
229
266
  }
230
267
 
@@ -235,7 +272,48 @@ function saveFacts(sessionId, project, facts) {
235
272
  if (qmdClient) qmdClient.upsertFacts(savedFacts);
236
273
  }
237
274
 
238
- return { saved, skipped };
275
+ return { saved, skipped, superseded };
276
+ }
277
+
278
+ /**
279
+ * Increment search_count and last_searched_at for a list of fact IDs.
280
+ * Semantics: "this fact appeared in search results" — NOT "this fact was useful".
281
+ * High search_count = frequently retrieved. Low/zero = candidate for pruning.
282
+ * Non-fatal; called after each successful search.
283
+ */
284
+ function _trackSearch(ids) {
285
+ if (!ids || ids.length === 0) return;
286
+ try {
287
+ const db = getDb();
288
+ const placeholders = ids.map(() => '?').join(',');
289
+ db.prepare(
290
+ `UPDATE facts SET search_count = search_count + 1, last_searched_at = datetime('now')
291
+ WHERE id IN (${placeholders})`
292
+ ).run(...ids);
293
+ } catch { /* non-fatal */ }
294
+ }
295
+
296
+ const SUPERSEDE_LOG = path.join(os.homedir(), '.metame', 'memory_supersede_log.jsonl');
297
+
298
+ /**
299
+ * Append supersede operations to audit log (append-only, never mutated).
300
+ * Each line: { ts, new_id, new_value_prefix, entity, relation, superseded: [{id, value_prefix}], session_id }
301
+ * Use this to investigate accidental overwrites or replay if needed.
302
+ */
303
+ function _logSupersede(oldFacts, newId, entity, relation, newValue, sessionId) {
304
+ if (!oldFacts || oldFacts.length === 0) return;
305
+ try {
306
+ const entry = {
307
+ ts: new Date().toISOString(),
308
+ entity,
309
+ relation,
310
+ new_id: newId,
311
+ new_value: newValue.slice(0, 80),
312
+ session_id: sessionId,
313
+ superseded: oldFacts.map(f => ({ id: f.id, value: f.value.slice(0, 80) })),
314
+ };
315
+ fs.appendFileSync(SUPERSEDE_LOG, JSON.stringify(entry) + '\n', 'utf8');
316
+ } catch { /* non-fatal */ }
239
317
  }
240
318
 
241
319
  /**
@@ -272,7 +350,10 @@ async function searchFactsAsync(query, { limit = 5, project = null } = {}) {
272
350
  const idOrder = new Map(ids.map((id, i) => [id, i]));
273
351
  rows.sort((a, b) => (idOrder.get(a.id) ?? 999) - (idOrder.get(b.id) ?? 999));
274
352
 
275
- if (rows.length > 0) return rows.slice(0, limit);
353
+ if (rows.length > 0) {
354
+ _trackSearch(rows.map(r => r.id));
355
+ return rows.slice(0, limit);
356
+ }
276
357
  }
277
358
  } catch { /* QMD failed, fall through to FTS5 */ }
278
359
  }
@@ -317,7 +398,10 @@ function searchFacts(query, { limit = 5, project = null } = {}) {
317
398
  params = [sanitized, limit];
318
399
  }
319
400
  const ftsResults = db.prepare(sql).all(...params);
320
- if (ftsResults.length > 0) return ftsResults;
401
+ if (ftsResults.length > 0) {
402
+ _trackSearch(ftsResults.map(r => r.id));
403
+ return ftsResults;
404
+ }
321
405
  } catch { /* FTS error, fall through */ }
322
406
 
323
407
  // LIKE fallback
@@ -331,9 +415,11 @@ function searchFacts(query, { limit = 5, project = null } = {}) {
331
415
  FROM facts WHERE (entity LIKE ? OR value LIKE ? OR tags LIKE ?)
332
416
  AND superseded_by IS NULL
333
417
  ORDER BY created_at DESC LIMIT ?`;
334
- return project
418
+ const likeResults = project
335
419
  ? db.prepare(likeSql).all(like, like, like, project, limit)
336
420
  : db.prepare(likeSql).all(like, like, like, limit);
421
+ if (likeResults.length > 0) _trackSearch(likeResults.map(r => r.id));
422
+ return likeResults;
337
423
  }
338
424
 
339
425
  /**
@@ -90,13 +90,26 @@ function upsertPending(pending, key, value, confidence, sourceQuote) {
90
90
  }
91
91
 
92
92
  /**
93
- * Get traits ready for promotion (count >= threshold OR confidence === 'high').
93
+ * Get traits ready for promotion.
94
+ * high confidence → always promote immediately.
95
+ * normal confidence → time-weighted count must reach threshold.
96
+ * effective_count = count * max(0.3, 1 - age_days/60)
97
+ * So recent observations count fully; 30-day-old observations count 50%;
98
+ * 60-day-old observations count at 30% floor (not zero — prevents complete stall).
94
99
  * Returns array of { key, value, source_quote }
95
100
  */
96
101
  function getPromotable(pending) {
97
102
  const ready = [];
103
+ const now = Date.now();
98
104
  for (const [key, meta] of Object.entries(pending)) {
99
- if (meta.count >= PROMOTION_THRESHOLD || meta.confidence === 'high') {
105
+ if (meta.confidence === 'high') {
106
+ ready.push({ key, value: meta.value, source_quote: meta.source_quote });
107
+ continue;
108
+ }
109
+ const ageDays = Math.floor((now - new Date(meta.last_seen).getTime()) / (1000 * 60 * 60 * 24));
110
+ const ageWeight = Math.max(0.3, 1 - ageDays / 60);
111
+ const effectiveCount = meta.count * ageWeight;
112
+ if (effectiveCount >= PROMOTION_THRESHOLD) {
100
113
  ready.push({ key, value: meta.value, source_quote: meta.source_quote });
101
114
  }
102
115
  }
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * self-reflect.js — Weekly Self-Reflection Task
5
+ *
6
+ * Scans correction/metacognitive signals from the past 7 days,
7
+ * aggregates "where did the AI get it wrong", and writes a brief
8
+ * self-critique pattern into growth.patterns in ~/.claude_profile.yaml.
9
+ *
10
+ * Heartbeat: weekly, require_idle, non-blocking.
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const os = require('os');
18
+ const { callHaiku, buildDistillEnv } = require('./providers');
19
+
20
+ const HOME = os.homedir();
21
+ const SIGNAL_FILE = path.join(HOME, '.metame', 'raw_signals.jsonl');
22
+ const BRAIN_FILE = path.join(HOME, '.claude_profile.yaml');
23
+ const LOCK_FILE = path.join(HOME, '.metame', 'self-reflect.lock');
24
+ const WINDOW_DAYS = 7;
25
+
26
+ async function run() {
27
+ // Atomic lock
28
+ let lockFd;
29
+ try {
30
+ lockFd = fs.openSync(LOCK_FILE, 'wx');
31
+ fs.writeSync(lockFd, process.pid.toString());
32
+ fs.closeSync(lockFd);
33
+ } catch (e) {
34
+ if (e.code === 'EEXIST') {
35
+ const age = Date.now() - fs.statSync(LOCK_FILE).mtimeMs;
36
+ if (age < 300000) { console.log('[self-reflect] Already running.'); return; }
37
+ fs.unlinkSync(LOCK_FILE);
38
+ lockFd = fs.openSync(LOCK_FILE, 'wx');
39
+ fs.writeSync(lockFd, process.pid.toString());
40
+ fs.closeSync(lockFd);
41
+ } else throw e;
42
+ }
43
+
44
+ try {
45
+ // Read signals from last WINDOW_DAYS days
46
+ if (!fs.existsSync(SIGNAL_FILE)) {
47
+ console.log('[self-reflect] No signal file, skipping.');
48
+ return;
49
+ }
50
+
51
+ const cutoff = Date.now() - WINDOW_DAYS * 24 * 60 * 60 * 1000;
52
+ const lines = fs.readFileSync(SIGNAL_FILE, 'utf8').trim().split('\n').filter(Boolean);
53
+ const recentSignals = lines
54
+ .map(l => { try { return JSON.parse(l); } catch { return null; } })
55
+ .filter(s => s && s.ts && new Date(s.ts).getTime() > cutoff);
56
+
57
+ // Filter to correction + metacognitive signals only
58
+ const correctionSignals = recentSignals.filter(s =>
59
+ s.type === 'correction' || s.type === 'metacognitive'
60
+ );
61
+
62
+ if (correctionSignals.length < 2) {
63
+ console.log(`[self-reflect] Only ${correctionSignals.length} correction signals this week, skipping.`);
64
+ return;
65
+ }
66
+
67
+ // Read current profile for context
68
+ let currentPatterns = '';
69
+ try {
70
+ const yaml = require('js-yaml');
71
+ const profile = yaml.load(fs.readFileSync(BRAIN_FILE, 'utf8')) || {};
72
+ const existing = (profile.growth && profile.growth.patterns) || [];
73
+ if (existing.length > 0) {
74
+ currentPatterns = `Current growth.patterns (avoid repeating):\n${existing.map(p => `- ${p}`).join('\n')}\n\n`;
75
+ }
76
+ } catch { /* non-fatal */ }
77
+
78
+ const signalText = correctionSignals
79
+ .slice(-20) // cap at 20 signals
80
+ .map((s, i) => `${i + 1}. [${s.type}] "${s.prompt}"`)
81
+ .join('\n');
82
+
83
+ const prompt = `你是一个AI自我审视引擎。分析以下用户纠正/元认知信号,找出AI(即你)**系统性**犯错的模式。
84
+
85
+ ${currentPatterns}用户纠正信号(最近7天):
86
+ ${signalText}
87
+
88
+ 任务:找出1-2条AI的系统性问题(不是偶发错误),例如:
89
+ - "经常过度简化用户的技术问题,忽略背景细节"
90
+ - "倾向于在用户还没说完就开始行动,导致方向偏差"
91
+ - "在不确定时倾向于肯定用户,而非直接说不知道"
92
+
93
+ 输出格式(JSON数组,最多2条,每条≤40字中文):
94
+ ["模式1描述", "模式2描述"]
95
+
96
+ 注意:
97
+ - 只输出有充分证据支持的系统性模式
98
+ - 如果证据不足,输出 []
99
+ - 只输出JSON,不要解释`;
100
+
101
+ let distillEnv = {};
102
+ try { distillEnv = buildDistillEnv(); } catch {}
103
+
104
+ let result;
105
+ try {
106
+ result = await Promise.race([
107
+ callHaiku(prompt, distillEnv, 60000),
108
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 65000)),
109
+ ]);
110
+ } catch (e) {
111
+ console.log(`[self-reflect] Haiku call failed: ${e.message}`);
112
+ return;
113
+ }
114
+
115
+ // Parse result
116
+ let patterns = [];
117
+ try {
118
+ const cleaned = result.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
119
+ const parsed = JSON.parse(cleaned);
120
+ if (Array.isArray(parsed)) {
121
+ patterns = parsed.filter(p => typeof p === 'string' && p.length > 5 && p.length <= 80);
122
+ }
123
+ } catch {
124
+ console.log('[self-reflect] Failed to parse Haiku output.');
125
+ return;
126
+ }
127
+
128
+ if (patterns.length === 0) {
129
+ console.log('[self-reflect] No patterns found this week.');
130
+ return;
131
+ }
132
+
133
+ // Merge into growth.patterns (cap at 3, keep newest)
134
+ try {
135
+ const yaml = require('js-yaml');
136
+ const raw = fs.readFileSync(BRAIN_FILE, 'utf8');
137
+ const profile = yaml.load(raw) || {};
138
+ if (!profile.growth) profile.growth = {};
139
+ const existing = Array.isArray(profile.growth.patterns) ? profile.growth.patterns : [];
140
+ // Add new patterns, deduplicate, cap at 3 newest
141
+ const merged = [...existing, ...patterns]
142
+ .filter((p, i, arr) => arr.indexOf(p) === i)
143
+ .slice(-3);
144
+ profile.growth.patterns = merged;
145
+ profile.growth.last_reflection = new Date().toISOString().slice(0, 10);
146
+
147
+ // Preserve locked lines (simple approach: only update growth section)
148
+ const dumped = yaml.dump(profile, { lineWidth: -1 });
149
+ fs.writeFileSync(BRAIN_FILE, dumped, 'utf8');
150
+ console.log(`[self-reflect] ${patterns.length} pattern(s) written to growth.patterns: ${patterns.join(' | ')}`);
151
+ } catch (e) {
152
+ console.log(`[self-reflect] Failed to write profile: ${e.message}`);
153
+ }
154
+
155
+ } finally {
156
+ try { fs.unlinkSync(LOCK_FILE); } catch {}
157
+ }
158
+ }
159
+
160
+ if (require.main === module) {
161
+ run().then(() => {
162
+ console.log('✅ self-reflect complete');
163
+ }).catch(e => {
164
+ console.error(`[self-reflect] Fatal: ${e.message}`);
165
+ process.exit(1);
166
+ });
167
+ }
168
+
169
+ module.exports = { run };
@@ -286,6 +286,49 @@ function checkHotEvolution(signal) {
286
286
  }
287
287
 
288
288
  saveEvolutionQueue(yaml, queue);
289
+
290
+ // Rule 4: Track insight outcomes (success/failure per skill)
291
+ if (signal.skills_invoked && signal.skills_invoked.length > 0) {
292
+ const isSuccess = signal.outcome === 'success' && !signal.error && !signal.has_tool_failure;
293
+ const isFail = !isSuccess && (signal.error || signal.has_tool_failure ||
294
+ (signal.prompt && complaintRe.test(signal.prompt)));
295
+ if (isSuccess || isFail) {
296
+ for (const sk of signal.skills_invoked) {
297
+ const skillDir = findSkillDir(sk);
298
+ if (skillDir) trackInsightOutcome(skillDir, isSuccess);
299
+ }
300
+ }
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Update insight outcome stats in evolution.json for a skill.
306
+ * Tracks success_count, fail_count, last_applied_at per insight text.
307
+ */
308
+ function trackInsightOutcome(skillDir, isSuccess) {
309
+ const evoPath = path.join(skillDir, 'evolution.json');
310
+ let data = {};
311
+ try { data = JSON.parse(fs.readFileSync(evoPath, 'utf8')); } catch { return; }
312
+
313
+ if (!data.insights_stats) data.insights_stats = {};
314
+ const now = new Date().toISOString();
315
+ const allInsights = [
316
+ ...(data.preferences || []),
317
+ ...(data.fixes || []),
318
+ ...(data.contexts || []),
319
+ ];
320
+
321
+ for (const insight of allInsights) {
322
+ if (!data.insights_stats[insight]) {
323
+ data.insights_stats[insight] = { success_count: 0, fail_count: 0, last_applied_at: null };
324
+ }
325
+ const stat = data.insights_stats[insight];
326
+ if (isSuccess) stat.success_count++;
327
+ else stat.fail_count++;
328
+ stat.last_applied_at = now;
329
+ }
330
+
331
+ try { fs.writeFileSync(evoPath, JSON.stringify(data, null, 2), 'utf8'); } catch {}
289
332
  }
290
333
 
291
334
  // ─────────────────────────────────────────────
@@ -687,14 +730,24 @@ function smartStitch(skillDir) {
687
730
  sections.push('\n\n## User-Learned Best Practices & Constraints');
688
731
  sections.push('\n> **Auto-Generated Section**: Maintained by skill-evolution-manager. Do not edit manually.');
689
732
 
733
+ // Helper: get quality indicator for an insight based on stats
734
+ const getQualityTag = (insight) => {
735
+ const stat = data.insights_stats?.[insight];
736
+ if (!stat || stat.success_count + stat.fail_count < 3) return ''; // insufficient data
737
+ const total = stat.success_count + stat.fail_count;
738
+ const failRate = stat.fail_count / total;
739
+ if (failRate > 0.6) return ' ⚠️'; // >60% fail rate
740
+ return '';
741
+ };
742
+
690
743
  if (data.preferences?.length) {
691
744
  sections.push('\n### User Preferences');
692
- for (const item of data.preferences) sections.push(`- ${item}`);
745
+ for (const item of data.preferences) sections.push(`- ${item}${getQualityTag(item)}`);
693
746
  }
694
747
 
695
748
  if (data.fixes?.length) {
696
749
  sections.push('\n### Known Fixes & Workarounds');
697
- for (const item of data.fixes) sections.push(`- ${item}`);
750
+ for (const item of data.fixes) sections.push(`- ${item}${getQualityTag(item)}`);
698
751
  }
699
752
 
700
753
  if (data.custom_prompts) {
@@ -776,6 +829,7 @@ module.exports = {
776
829
  resolveQueueItem,
777
830
  mergeEvolution,
778
831
  smartStitch,
832
+ trackInsightOutcome,
779
833
  listInstalledSkills,
780
834
  };
781
835
 
package/scripts/utils.js CHANGED
@@ -1,9 +1,64 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
3
7
  /**
4
8
  * utils.js — Pure utility functions extracted for testability.
5
9
  */
6
10
 
11
+ // ---------------------------------------------------------
12
+ // BRAIN FILE SAFE WRITE
13
+ // Atomic write with exclusive file lock.
14
+ // Prevents race condition between distill.js and daemon.js /quiet command.
15
+ // ---------------------------------------------------------
16
+ const BRAIN_LOCK_FILE = path.join(os.homedir(), '.metame', 'brain.lock');
17
+ const BRAIN_FILE_DEFAULT = path.join(os.homedir(), '.claude_profile.yaml');
18
+
19
+ /**
20
+ * Write content to the brain profile file atomically and exclusively.
21
+ * Uses a .lock file to prevent concurrent writes, and write-then-rename
22
+ * for atomicity (process crash leaves .tmp, not a partial BRAIN_FILE).
23
+ *
24
+ * @param {string} content - YAML string to write
25
+ * @param {string} [brainFile] - Target path (defaults to ~/.claude_profile.yaml)
26
+ * @returns {Promise<void>}
27
+ */
28
+ async function writeBrainFileSafe(content, brainFile = BRAIN_FILE_DEFAULT) {
29
+ const maxRetries = 10;
30
+ const retryDelay = 150; // ms between retries
31
+ const staleTimeout = 30000; // 30s: lock older than this is stale
32
+
33
+ let acquired = false;
34
+ for (let i = 0; i < maxRetries; i++) {
35
+ try {
36
+ const fd = fs.openSync(BRAIN_LOCK_FILE, 'wx');
37
+ fs.writeSync(fd, process.pid.toString());
38
+ fs.closeSync(fd);
39
+ acquired = true;
40
+ break;
41
+ } catch (e) {
42
+ if (e.code !== 'EEXIST') throw e;
43
+ // Check for stale lock
44
+ try {
45
+ const age = Date.now() - fs.statSync(BRAIN_LOCK_FILE).mtimeMs;
46
+ if (age > staleTimeout) { fs.unlinkSync(BRAIN_LOCK_FILE); continue; }
47
+ } catch { /* lock removed by another process */ }
48
+ await new Promise(r => setTimeout(r, retryDelay));
49
+ }
50
+ }
51
+
52
+ const tmp = brainFile + '.tmp.' + process.pid;
53
+ try {
54
+ fs.writeFileSync(tmp, content, 'utf8');
55
+ fs.renameSync(tmp, brainFile); // atomic on POSIX
56
+ } finally {
57
+ try { fs.unlinkSync(tmp); } catch { } // clean up tmp if rename failed
58
+ if (acquired) try { fs.unlinkSync(BRAIN_LOCK_FILE); } catch { }
59
+ }
60
+ }
61
+
7
62
  // ---------------------------------------------------------
8
63
  // INTERVAL PARSING
9
64
  // ---------------------------------------------------------
@@ -67,4 +122,6 @@ module.exports = {
67
122
  parseInterval,
68
123
  formatRelativeTime,
69
124
  createPathMap,
125
+ writeBrainFileSafe,
126
+ BRAIN_LOCK_FILE,
70
127
  };