metame-cli 1.4.19 → 1.4.21

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.
@@ -12,8 +12,29 @@ function createBridgeStarter(deps) {
12
12
  saveState,
13
13
  getSession,
14
14
  handleCommand,
15
+ pendingActivations, // optional — used to show smart activation hint
15
16
  } = deps;
16
17
 
18
+ // Returns the best pending activation for a given chatId (excludes self-created)
19
+ function getPendingActivationForChat(chatId) {
20
+ if (!pendingActivations || pendingActivations.size === 0) return null;
21
+ const cid = String(chatId);
22
+ let latest = null;
23
+ for (const rec of pendingActivations.values()) {
24
+ if (rec.createdByChatId === cid) continue;
25
+ if (!latest || rec.createdAt > latest.createdAt) latest = rec;
26
+ }
27
+ return latest;
28
+ }
29
+
30
+ function unauthorizedMsg(chatId, useSend) {
31
+ const pending = getPendingActivationForChat(chatId);
32
+ if (pending) {
33
+ return `⚠️ 此群未授权\n\n发送以下命令激活 Agent「${pending.agentName}」:\n\`/activate\``;
34
+ }
35
+ return '⚠️ 此群未授权\n\n如已创建 Agent,发送 `/activate` 完成绑定。\n否则请先在主群创建 Agent。';
36
+ }
37
+
17
38
  async function startTelegramBridge(config, executeTaskByName) {
18
39
  if (!config.telegram || !config.telegram.enabled) return null;
19
40
  if (!config.telegram.bot_token) {
@@ -68,13 +89,13 @@ function createBridgeStarter(deps) {
68
89
  const trimmedText = msg.text && msg.text.trim();
69
90
  const isBindCmd = trimmedText && (
70
91
  trimmedText.startsWith('/agent bind')
71
- || trimmedText.startsWith('/agent new')
72
92
  || trimmedText.startsWith('/agent-bind-dir')
73
93
  || trimmedText.startsWith('/browse bind')
94
+ || trimmedText === '/activate'
74
95
  );
75
96
  if (!allowedIds.includes(chatId) && !isBindCmd) {
76
97
  log('WARN', `Rejected message from unauthorized chat: ${chatId}`);
77
- bot.sendMessage(chatId, '⚠️ This chat is not authorized.\n\nCopy and send this command to register:\n\n/agent bind personal').catch(() => {});
98
+ bot.sendMessage(chatId, unauthorizedMsg(chatId)).catch(() => {});
78
99
  continue;
79
100
  }
80
101
 
@@ -157,15 +178,14 @@ function createBridgeStarter(deps) {
157
178
  const trimmedText = text && text.trim();
158
179
  const isBindCmd = trimmedText && (
159
180
  trimmedText.startsWith('/agent bind')
160
- || trimmedText.startsWith('/agent new')
161
181
  || trimmedText.startsWith('/agent-bind-dir')
162
182
  || trimmedText.startsWith('/browse bind')
183
+ || trimmedText === '/activate'
163
184
  );
164
185
  if (!allowedIds.includes(chatId) && !isBindCmd) {
165
186
  log('WARN', `Feishu: rejected message from ${chatId}`);
166
- (bot.sendMarkdown
167
- ? bot.sendMarkdown(chatId, '⚠️ 此会话未授权\n\n复制发送以下命令注册:\n\n/agent bind personal')
168
- : bot.sendMessage(chatId, '⚠️ 此会话未授权\n\n复制发送以下命令注册:\n\n/agent bind personal')).catch(() => {});
187
+ const msg = unauthorizedMsg(chatId);
188
+ (bot.sendMarkdown ? bot.sendMarkdown(chatId, msg) : bot.sendMessage(chatId, msg)).catch(() => {});
169
189
  return;
170
190
  }
171
191
 
@@ -103,7 +103,7 @@ function createClaudeEngine(deps) {
103
103
  const existsInCwd = recentInCwd.some(s => s.sessionId === safeSessionId);
104
104
  return cacheSessionCwdValidation(cacheKey, existsInCwd);
105
105
  } catch {
106
- // Conservative fallback: if validation infra fails, avoid false positives by preserving current session.
106
+ // Conservative fallback: if validation infra fails, avoid false negatives by preserving current session.
107
107
  return cacheSessionCwdValidation(cacheKey, true);
108
108
  }
109
109
  }
@@ -189,6 +189,16 @@ function createClaudeEngine(deps) {
189
189
  return /(邮件|邮箱|收件箱|mail|email|calendar|日历|日程|会议|提醒|remind|草稿|发送邮件|打开|关闭|启动|切到|前台|音量|静音|睡眠|锁屏|Finder|Safari|微信|WeChat|Terminal|iTerm|System Events)/i.test(text);
190
190
  }
191
191
 
192
+ // Returns true when the message is a task/technical request that warrants full memory hints (rules 3-5).
193
+ // Errs on the side of over-inclusion: false negatives (missing hints) are worse than false positives.
194
+ function isTaskIntent(prompt) {
195
+ const text = String(prompt || '').trim();
196
+ if (!text) return false;
197
+ // Errs on the side of over-inclusion: false negatives (missing hints) are worse than false positives.
198
+ if (/^\/\w+/.test(text)) return true; // slash command / dispatch prefix
199
+ return text.length > 30 || /(node|git|npm|daemon|script|debug|fix|bug|error|api|sql|review|实现|修改|排查|架构|配置|代码|函数|部署|测试|调试|重构|优化|回滚|日志|迁移|升级|接口|监控|错误|修复|异常|警告|单测|崩|死锁|内存)/i.test(text);
200
+ }
201
+
192
202
  /**
193
203
  * Auto-generate a session name using Haiku (async, non-blocking).
194
204
  * Writes to Claude's session file (unified with /rename).
@@ -227,7 +237,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
227
237
  * Spawn claude as async child process (non-blocking).
228
238
  * Returns { output, error } after process exits.
229
239
  */
230
- function spawnClaudeAsync(args, input, cwd, timeoutMs = 300000) {
240
+ function spawnClaudeAsync(args, input, cwd, timeoutMs = 300000, metameProject = '') {
231
241
  return new Promise((resolve) => {
232
242
  const child = spawn(CLAUDE_BIN, args, {
233
243
  cwd,
@@ -237,6 +247,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
237
247
  ...getActiveProviderEnv(),
238
248
  CLAUDECODE: undefined,
239
249
  METAME_INTERNAL_PROMPT: '1',
250
+ METAME_PROJECT: metameProject || ''
240
251
  },
241
252
  });
242
253
 
@@ -301,7 +312,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
301
312
  * Calls onStatus callback when tool usage is detected.
302
313
  * Returns { output, error } after process exits.
303
314
  */
304
- function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000, chatId = null) {
315
+ function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000, chatId = null, metameProject = '') {
305
316
  return new Promise((resolve) => {
306
317
  // Add stream-json output format (requires --verbose)
307
318
  const streamArgs = [...args, '--output-format', 'stream-json', '--verbose'];
@@ -310,12 +321,17 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
310
321
  cwd,
311
322
  stdio: ['pipe', 'pipe', 'pipe'],
312
323
  detached: true, // Create new process group so killing -pid kills all sub-agents too
313
- env: { ...process.env, ...getActiveProviderEnv(), CLAUDECODE: undefined },
324
+ env: {
325
+ ...process.env,
326
+ ...getActiveProviderEnv(),
327
+ CLAUDECODE: undefined,
328
+ METAME_PROJECT: metameProject || ''
329
+ },
314
330
  });
315
331
 
316
332
  // Track active process for /stop
317
333
  if (chatId) {
318
- activeProcesses.set(chatId, { child, aborted: false });
334
+ activeProcesses.set(chatId, { child, aborted: false, startedAt: Date.now() });
319
335
  saveActivePids(); // Fix3: persist PID to disk
320
336
  }
321
337
 
@@ -512,6 +528,23 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
512
528
  * Shared ask logic — full Claude Code session (stateful, with tools)
513
529
  * Now uses spawn (async) instead of execSync to allow parallel requests.
514
530
  */
531
+
532
+ /**
533
+ * Reset active provider back to anthropic/opus and reload config.
534
+ * Returns the freshly loaded config so callers can reassign their local variable.
535
+ */
536
+ function fallbackToDefaultProvider(reason) {
537
+ log('WARN', `Falling back to anthropic/opus — reason: ${reason}`);
538
+ if (providerMod && providerMod.getActiveName() !== 'anthropic') {
539
+ providerMod.setActive('anthropic');
540
+ }
541
+ const cfg = yaml.load(fs.readFileSync(CONFIG_FILE, 'utf8')) || {};
542
+ if (!cfg.daemon) cfg.daemon = {};
543
+ cfg.daemon.model = 'opus';
544
+ writeConfigSafe(cfg);
545
+ return loadConfig();
546
+ }
547
+
515
548
  async function askClaude(bot, chatId, prompt, config, readOnly = false) {
516
549
  log('INFO', `askClaude for ${chatId}: ${prompt.slice(0, 50)}`);
517
550
  // Track interaction time for idle/sleep detection
@@ -670,12 +703,25 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
670
703
  const _agentMap = { ...(_cfg.telegram ? _cfg.telegram.chat_agent_map : {}), ...(_cfg.feishu ? _cfg.feishu.chat_agent_map : {}) };
671
704
  const projectKey = _agentMap[_cid] || projectKeyFromVirtualChatId(_cid);
672
705
 
706
+ // L1: NOW.md shared whiteboard injection
707
+ if (!session.started) {
708
+ try {
709
+ const nowPath = path.join(HOME, '.metame', 'memory', 'NOW.md');
710
+ if (fs.existsSync(nowPath)) {
711
+ const nowContent = fs.readFileSync(nowPath, 'utf8').trim();
712
+ if (nowContent) {
713
+ memoryHint += `\n\n[Current task context:\n${nowContent}]`;
714
+ }
715
+ }
716
+ } catch { /* non-critical */ }
717
+ }
718
+
673
719
  // 1. Inject recent session memories ONLY on first message of a session
674
720
  if (!session.started) {
675
- const recent = memory.recentSessions({ limit: 3, project: projectKey || undefined });
721
+ const recent = memory.recentSessions({ limit: 1, project: projectKey || undefined });
676
722
  if (recent.length > 0) {
677
723
  const items = recent.map(r => `- [${r.created_at}] ${r.summary}${r.keywords ? ' (keywords: ' + r.keywords + ')' : ''}`).join('\n');
678
- memoryHint += `\n\n<!-- MEMORY:START -->\n[Session memory - recent context from past sessions, use to inform your responses:\n${items}]\n<!-- MEMORY:END -->`;
724
+ memoryHint += `\n\n[Past session memory:\n${items}]`;
679
725
  }
680
726
  }
681
727
 
@@ -685,10 +731,10 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
685
731
  if (!session.started) {
686
732
  const searchFn = memory.searchFactsAsync || memory.searchFacts;
687
733
  const factQuery = buildFactSearchQuery(prompt, projectKey);
688
- const facts = await Promise.resolve(searchFn(factQuery, { limit: 5, project: projectKey || undefined }));
734
+ const facts = await Promise.resolve(searchFn(factQuery, { limit: 3, project: projectKey || undefined }));
689
735
  if (facts.length > 0) {
690
736
  const factItems = facts.map(f => `- [${f.relation}] ${f.value}`).join('\n');
691
- memoryHint += `\n\n<!-- FACTS:START -->\n[Relevant knowledge and user preferences retrieved for this query. Follow these constraints implicitly:\n${factItems}]\n<!-- FACTS:END -->`;
737
+ memoryHint += `\n\n[Relevant facts:\n${factItems}]`;
692
738
  log('INFO', `[MEMORY] Injected ${facts.length} facts (query_len=${factQuery.length})`);
693
739
  }
694
740
  }
@@ -698,16 +744,50 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
698
744
  if (e.code !== 'MODULE_NOT_FOUND') log('WARN', `Memory injection failed: ${e.message}`);
699
745
  }
700
746
 
747
+ // ZPD: build competence hint from brain profile
748
+ let zdpHint = '';
749
+ if (!session.started) {
750
+ try {
751
+ const brainPath = path.join(HOME, '.claude_profile.yaml');
752
+ if (fs.existsSync(brainPath)) {
753
+ const brain = yaml.load(fs.readFileSync(brainPath, 'utf8'));
754
+ const cmap = brain && brain.user_competence_map;
755
+ if (cmap && typeof cmap === 'object' && Object.keys(cmap).length > 0) {
756
+ const lines = Object.entries(cmap)
757
+ .map(([domain, level]) => ` ${domain}: ${level}`)
758
+ .join('\n');
759
+ zdpHint = `\n- User competence map (adjust explanation depth accordingly):\n${lines}\n Rule: expert→skip basics; intermediate→brief rationale; beginner→one-line analogy.`;
760
+ }
761
+ }
762
+ } catch { /* non-critical */ }
763
+ }
764
+
701
765
  // Inject daemon hints only on first message of a session
702
- const daemonHint = !session.started ? `\n\n[System hints - DO NOT mention these to user:
703
- 1. Language: ALWAYS respond in Simplified Chinese (简体中文). NEVER switch to Korean, Japanese, or other languages regardless of tool output or context language.
704
- 2. Daemon config: The ONLY config is ~/.metame/daemon.yaml (never edit daemon-default.yaml). Auto-reloads on change.
705
- 3. File sending: User is on MOBILE. When they ask to see/download a file:
766
+ // Task-specific rules (3-5) are injected only when isTaskIntent() returns true (~250 token saving for casual chat)
767
+ let daemonHint = '';
768
+ if (!session.started) {
769
+ const taskRules = isTaskIntent(prompt) ? `
770
+ 3. Knowledge retrieval: When you need context about a specific topic, past decisions, or lessons, call:
771
+ node ~/.metame/memory-search.js "关键词1" "keyword2"
772
+ Also read ~/.metame/memory/INDEX.md to discover available long-form lesson/decision docs, then read specific files as needed.
773
+ Use these before answering complex questions about MetaMe architecture or past decisions.
774
+ 4. Active memory: After confirming a new insight, bug root cause, or user preference, persist it with:
775
+ node ~/.metame/memory-write.js "Entity.sub" "relation_type" "value (20-300 chars)"
776
+ Valid relations: tech_decision, bug_lesson, arch_convention, config_fact, config_change, user_pref, workflow_rule, project_milestone
777
+ Only write verified facts. Do not write speculative or process-description entries.
778
+ When you observe the user is clearly expert or beginner in a domain, note it in your response and suggest: "要不要把你的 {domain} 水平 ({level}) 记录到能力雷达?"
779
+ 5. Task handoff: When suspending a multi-step task or handing off to another agent, write current status to ~/.metame/memory/NOW.md using:
780
+ \`printf '%s\\n' "## Current Task" "{task}" "" "## Progress" "{progress}" "" "## Next Step" "{next}" > ~/.metame/memory/NOW.md\`
781
+ Keep it under 200 words. Clear it when the task is fully complete by running: \`> ~/.metame/memory/NOW.md\`` : '';
782
+ daemonHint = `\n\n[System hints - DO NOT mention these to user:
783
+ 1. Daemon config: The ONLY config is ~/.metame/daemon.yaml (never edit daemon-default.yaml). Auto-reloads on change.
784
+ 2. File sending: User is on MOBILE. When they ask to see/download a file:
706
785
  - Just FIND the file path (use Glob/ls if needed)
707
786
  - Do NOT read or summarize the file content (wastes tokens)
708
787
  - Add at END of response: [[FILE:/absolute/path/to/file]]
709
788
  - Keep response brief: "请查收~! [[FILE:/path/to/file]]"
710
- - Multiple files: use multiple [[FILE:...]] tags]` : '';
789
+ - Multiple files: use multiple [[FILE:...]] tags${zdpHint ? '\n Explanation depth (ZPD):\n' + zdpHint : ''}${taskRules}]`;
790
+ }
711
791
 
712
792
  const routedPrompt = skill ? `/${skill} ${prompt}` : prompt;
713
793
 
@@ -720,7 +800,8 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
720
800
  2. Read/query actions can execute directly.
721
801
  3. Before any side-effect action (send email, create/delete/modify calendar event, delete/move files, app quit, system sleep), first show a short execution preview and require explicit user confirmation.
722
802
  4. Keep output concise: success/failure + key result only.
723
- 5. If permission is missing, guide user to run /mac perms open then retry.]`;
803
+ 5. If permission is missing, guide user to run /mac perms open then retry.
804
+ 6. Before executing high-risk or non-obvious Bash commands (rm, kill, git reset, overwrite configs), prepend a single-line [Why] explanation. Skip for routine commands (ls, cat, grep).]`;
724
805
  }
725
806
 
726
807
  // P2-B: inject session summary when resuming after a 2h+ gap
@@ -743,7 +824,7 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
743
824
  }
744
825
 
745
826
  // Always append a compact language guard to prevent accidental Korean/Japanese responses
746
- const langGuard = '\n\n[Respond in Simplified Chinese (简体中文) only.]';
827
+ const langGuard = '\n\n[Respond in Simplified Chinese (简体中文) only. NEVER switch to Korean, Japanese, or other languages regardless of tool output or context language.]';
747
828
  const fullPrompt = routedPrompt + daemonHint + macAutomationHint + summaryHint + memoryHint + langGuard;
748
829
 
749
830
  // Git checkpoint before Claude modifies files (for /undo)
@@ -770,7 +851,16 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
770
851
  } catch { /* ignore status update failures */ }
771
852
  };
772
853
 
773
- const { output, error, files, toolUsageLog } = await spawnClaudeStreaming(args, fullPrompt, session.cwd, onStatus, 600000, chatId);
854
+ let output, error, files, toolUsageLog;
855
+ try {
856
+ ({ output, error, files, toolUsageLog } = await spawnClaudeStreaming(args, fullPrompt, session.cwd, onStatus, 600000, chatId, boundProjectKey || ''));
857
+ } catch (spawnErr) {
858
+ clearInterval(typingTimer);
859
+ if (statusMsgId && bot.deleteMessage) bot.deleteMessage(chatId, statusMsgId).catch(() => { });
860
+ log('ERROR', `spawnClaudeStreaming crashed for ${chatId}: ${spawnErr.message}`);
861
+ await bot.sendMessage(chatId, `❌ 内部错误: ${spawnErr.message}`).catch(() => { });
862
+ return { ok: false, error: spawnErr.message };
863
+ }
774
864
  clearInterval(typingTimer);
775
865
 
776
866
  // Skill evolution: capture signal + hot path heuristic check
@@ -819,14 +909,8 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
819
909
  const builtinModelsCheck = ['sonnet', 'opus', 'haiku'];
820
910
  const looksLikeError = output.length < 300 && /\b(not found|invalid model|unauthorized|401|403|404|error|failed)\b/i.test(output);
821
911
  if (looksLikeError && (activeProvCheck !== 'anthropic' || !builtinModelsCheck.includes(model))) {
822
- log('WARN', `Custom provider/model may have failed (${activeProvCheck}/${model}), output: ${output.slice(0, 200)}`);
823
912
  try {
824
- if (providerMod && activeProvCheck !== 'anthropic') providerMod.setActive('anthropic');
825
- const cfg = yaml.load(fs.readFileSync(CONFIG_FILE, 'utf8')) || {};
826
- if (!cfg.daemon) cfg.daemon = {};
827
- cfg.daemon.model = 'opus';
828
- writeConfigSafe(cfg);
829
- config = loadConfig();
913
+ config = fallbackToDefaultProvider(`output looks like error for ${activeProvCheck}/${model}`);
830
914
  await bot.sendMessage(chatId, `⚠️ ${activeProvCheck}/${model} 疑似失败,已回退到 anthropic/opus\n输出: ${output.slice(0, 150)}`);
831
915
  } catch (fbErr) {
832
916
  log('ERROR', `Fallback failed: ${fbErr.message}`);
@@ -892,8 +976,8 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
892
976
  log('ERROR', `askClaude failed for ${chatId}: ${errMsg.slice(0, 300)}`);
893
977
 
894
978
  // If session not found (expired/deleted), create new and retry once
895
- if (errMsg.includes('not found') || errMsg.includes('No session')) {
896
- log('WARN', `Session ${session.id} not found, creating new`);
979
+ if (errMsg.includes('not found') || errMsg.includes('No session') || errMsg.includes('already in use')) {
980
+ log('WARN', `Session ${session.id} unusable (${errMsg.includes('already in use') ? 'locked' : 'not found'}), creating new`);
897
981
  session = createSession(chatId, session.cwd);
898
982
 
899
983
  const retryArgs = ['-p', '--session-id', session.id];
@@ -916,23 +1000,13 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
916
1000
  try { await bot.sendMessage(chatId, `Error: ${(retry.error || '').slice(0, 200)}`); } catch { /* */ }
917
1001
  return { ok: false, error: retry.error || errMsg };
918
1002
  }
919
- } else if (errMsg === 'Stopped by user' && messageQueue.has(chatId)) {
920
- // Interrupted by message queue — suppress error, queue timer will handle it
921
- log('INFO', `Task interrupted by new message for ${chatId}`);
922
- return { ok: false, error: errMsg, interrupted: true };
923
1003
  } else {
924
1004
  // Auto-fallback: if custom provider/model fails, revert to anthropic + opus
925
1005
  const activeProv = providerMod ? providerMod.getActiveName() : 'anthropic';
926
1006
  const builtinModels = ['sonnet', 'opus', 'haiku'];
927
1007
  if (activeProv !== 'anthropic' || !builtinModels.includes(model)) {
928
- log('WARN', `Custom provider/model failed (${activeProv}/${model}), falling back to anthropic/opus`);
929
1008
  try {
930
- if (providerMod && activeProv !== 'anthropic') providerMod.setActive('anthropic');
931
- const cfg = yaml.load(fs.readFileSync(CONFIG_FILE, 'utf8')) || {};
932
- if (!cfg.daemon) cfg.daemon = {};
933
- cfg.daemon.model = 'opus';
934
- writeConfigSafe(cfg);
935
- config = loadConfig();
1009
+ config = fallbackToDefaultProvider(`${activeProv}/${model} error: ${errMsg.slice(0, 100)}`);
936
1010
  await bot.sendMessage(chatId, `⚠️ ${activeProv}/${model} 失败,已回退到 anthropic/opus\n原因: ${errMsg.slice(0, 100)}`);
937
1011
  } catch (fallbackErr) {
938
1012
  log('ERROR', `Fallback failed: ${fallbackErr.message}`);
@@ -944,8 +1018,6 @@ Reply with ONLY the name, nothing else. Examples: 插件开发, API重构, Bug
944
1018
  return { ok: false, error: errMsg };
945
1019
  }
946
1020
  }
947
-
948
- return { ok: true };
949
1021
  }
950
1022
 
951
1023
  return {
@@ -24,6 +24,7 @@ function createCommandRouter(deps) {
24
24
  log,
25
25
  agentTools,
26
26
  pendingAgentFlows,
27
+ pendingActivations,
27
28
  agentFlowTtlMs,
28
29
  } = deps;
29
30
 
@@ -426,7 +427,7 @@ function createCommandRouter(deps) {
426
427
  if (wantsCreate) {
427
428
  if (!workspaceDir) {
428
429
  await bot.sendMessage(chatId, [
429
- '我可以帮你创建并绑定 Agent,还差一个工作目录。',
430
+ '我可以帮你创建 Agent,还差一个工作目录。',
430
431
  '例如:`给这个群创建一个 Agent,目录是 ~/projects/foo`',
431
432
  '也可以直接回我一个路径(`~/`、`/`、`./`、`../` 开头都行)。',
432
433
  ].join('\n'));
@@ -434,15 +435,24 @@ function createCommandRouter(deps) {
434
435
  }
435
436
  const agentName = deriveAgentName(input, workspaceDir);
436
437
  const roleDelta = deriveCreateRoleDelta(input);
437
- const res = await agentTools.createNewWorkspaceAgent(agentName, workspaceDir, roleDelta, chatId);
438
+ // Always skip binding creating chat new group activates via /activate
439
+ const res = await agentTools.createNewWorkspaceAgent(agentName, workspaceDir, roleDelta, chatId, { skipChatBinding: true });
438
440
  if (!res.ok) {
439
441
  await bot.sendMessage(chatId, `❌ 创建 Agent 失败: ${res.error}`);
440
442
  return true;
441
443
  }
442
444
  const data = res.data || {};
443
445
  const projName = projectNameFromResult(data, agentName);
444
- if (data.cwd) attachOrCreateSession(chatId, normalizeCwd(data.cwd), projName);
445
- await bot.sendMessage(chatId, `✅ Agent 已创建并绑定\n名称: ${projName}\n目录: ${data.cwd || '(未知)'}`);
446
+ if (data.projectKey && pendingActivations) {
447
+ pendingActivations.set(data.projectKey, {
448
+ agentKey: data.projectKey, agentName: projName, cwd: data.cwd,
449
+ createdByChatId: String(chatId), createdAt: Date.now(),
450
+ });
451
+ }
452
+ await bot.sendMessage(chatId,
453
+ `✅ Agent「${projName}」已创建\n目录: ${data.cwd || '(未知)'}\n\n` +
454
+ `**下一步**: 在新群里发送 \`/activate\` 完成绑定(30分钟内有效)`
455
+ );
446
456
  return true;
447
457
  }
448
458
 
@@ -529,6 +539,7 @@ function createCommandRouter(deps) {
529
539
  '📱 手机端 Claude Code',
530
540
  '',
531
541
  '⚡ 快速同步电脑工作:',
542
+ '/continue — 接续电脑正在做的工作',
532
543
  '/last — 继续电脑上最近的对话',
533
544
  '/cd last — 切到电脑最近的项目目录',
534
545
  '',
@@ -565,43 +576,21 @@ function createCommandRouter(deps) {
565
576
  }
566
577
 
567
578
  // --- Natural language → Claude Code session ---
568
- // If a task is running: interrupt + collect + merge
579
+ // If a task is running: queue message, DON'T kill — will be sent as follow-up after completion
569
580
  if (activeProcesses.has(chatId)) {
570
581
  const isFirst = !messageQueue.has(chatId);
571
582
  if (isFirst) {
572
- messageQueue.set(chatId, { messages: [], timer: null });
583
+ messageQueue.set(chatId, { messages: [] });
573
584
  }
574
585
  const q = messageQueue.get(chatId);
586
+ if (q.messages.length >= 10) {
587
+ await bot.sendMessage(chatId, '⚠️ 排队已满(10条),请等当前任务完成');
588
+ return;
589
+ }
575
590
  q.messages.push(text);
576
- // Only notify once (first message), subsequent ones silently queue
577
591
  if (isFirst) {
578
- await bot.sendMessage(chatId, '📝 收到,稍后一起处理');
592
+ await bot.sendMessage(chatId, '📝 收到,完成后继续处理');
579
593
  }
580
- // Interrupt the running Claude process
581
- const proc = activeProcesses.get(chatId);
582
- if (proc && proc.child && !proc.aborted) {
583
- proc.aborted = true;
584
- try { process.kill(-proc.child.pid, 'SIGINT'); } catch { proc.child.kill('SIGINT'); }
585
- }
586
- // Debounce: wait 5s for more messages before processing
587
- if (q.timer) clearTimeout(q.timer);
588
- q.timer = setTimeout(async () => {
589
- // Wait for active process to fully exit (up to 10s)
590
- for (let i = 0; i < 20 && activeProcesses.has(chatId); i++) {
591
- await sleep(500);
592
- }
593
- const msgs = q.messages.splice(0);
594
- messageQueue.delete(chatId);
595
- if (msgs.length === 0) return;
596
- const combined = msgs.join('\n');
597
- log('INFO', `Processing ${msgs.length} queued message(s) for ${chatId}`);
598
- resetCooldown(chatId); // queued msgs already waited, skip cooldown
599
- try {
600
- await handleCommand(bot, chatId, combined, config, executeTaskByName);
601
- } catch (e) {
602
- log('ERROR', `Queue dispatch failed: ${e.message}`);
603
- }
604
- }, 5000);
605
594
  return;
606
595
  }
607
596
  // Strict mode: chats with a fixed agent in chat_agent_map must not cross-dispatch
@@ -643,8 +632,8 @@ function createCommandRouter(deps) {
643
632
  }
644
633
  const claudeResult = await askClaude(bot, chatId, text, config, readOnly);
645
634
  const claudeFailed = !!(claudeResult && claudeResult.ok === false);
646
- const claudeInterrupted = !!(claudeResult && claudeResult.interrupted);
647
- if (claudeFailed && !claudeInterrupted && !macLocalFirst && macFallbackEnabled && allowLocalMacControl) {
635
+ const claudeAborted = !!(claudeResult && claudeResult.error === 'Stopped by user');
636
+ if (claudeFailed && !claudeAborted && !macLocalFirst && macFallbackEnabled && allowLocalMacControl) {
648
637
  const fallbackHandled = await tryHandleMacNaturalLanguageIntent(bot, chatId, text, config, {
649
638
  source: 'claude-fallback',
650
639
  safeOnly: true,
@@ -654,6 +643,20 @@ function createCommandRouter(deps) {
654
643
  log('WARN', `Claude-first mac fallback handled for ${String(chatId).slice(-8)} (mode=${macControlMode})`);
655
644
  }
656
645
  }
646
+
647
+ // Process queued messages as follow-up in the same session (no kill, no context loss)
648
+ // Use while-loop instead of recursion to avoid unbounded stack growth
649
+ while (messageQueue.has(chatId)) {
650
+ const q = messageQueue.get(chatId);
651
+ const msgs = q.messages.splice(0);
652
+ messageQueue.delete(chatId);
653
+ if (msgs.length === 0) break;
654
+ const combined = msgs.join('\n');
655
+ log('INFO', `Follow-up: processing ${msgs.length} queued message(s) for ${chatId}`);
656
+ resetCooldown(chatId);
657
+ const followUp = await askClaude(bot, chatId, combined, config, readOnly);
658
+ if (followUp && followUp.error === 'Stopped by user') break;
659
+ }
657
660
  }
658
661
 
659
662
  return { handleCommand };
@@ -63,6 +63,24 @@ heartbeat:
63
63
  notify: false
64
64
  enabled: true
65
65
 
66
+ # 夜间记忆蒸馏:每天 01:00 提炼热区事实为决策与经验文档
67
+ - name: nightly-reflect
68
+ type: script
69
+ command: node ~/.metame/memory-nightly-reflect.js
70
+ at: "01:00"
71
+ require_idle: true
72
+ notify: false
73
+ enabled: true
74
+
75
+ # 记忆索引:每天 01:30 更新 ~/.metame/memory/INDEX.md
76
+ - name: memory-index
77
+ type: script
78
+ command: node ~/.metame/memory-index.js
79
+ at: "01:30"
80
+ require_idle: true
81
+ notify: false
82
+ enabled: true
83
+
66
84
  # Legacy flat tasks (no project isolation). New tasks should go under projects: above.
67
85
  # Examples — uncomment or add your own:
68
86
  #
@@ -192,8 +192,8 @@ function createExecCommandHandler(deps) {
192
192
  const st = loadState();
193
193
  st.tasks[taskName] = { last_run: new Date().toISOString(), status: 'success', output_preview: (output || '').slice(0, 200) };
194
194
  saveState(st);
195
- let reply = output || '(no output)';
196
- if (reply.length > 4000) reply = reply.slice(0, 4000) + '\n... (truncated)';
195
+ const truncated = truncateOutput(output, 4000);
196
+ const reply = truncated || '(no output)';
197
197
  await bot.sendMessage(chatId, `${taskName}\n\n${reply}`);
198
198
  }
199
199
  return true;
@@ -348,16 +348,10 @@ function createExecCommandHandler(deps) {
348
348
  const cwd = session?.cwd || HOME;
349
349
  await bot.sendMessage(chatId, `📦 npm publish --otp=${otp} ...`);
350
350
  try {
351
- const child = spawn('npm', ['publish', `--otp=${otp}`], { cwd, timeout: 60000 });
352
- let stdout = '';
353
- let stderr = '';
354
- child.stdout.on('data', d => { stdout += d; });
355
- child.stderr.on('data', d => { stderr += d; });
356
- const exitCode = await new Promise((resolve) => {
357
- child.on('close', (code) => resolve(code));
358
- child.on('error', () => resolve(1));
359
- });
360
- const output = (stdout + stderr).trim();
351
+ const result = await runCommand('npm', ['publish', `--otp=${otp}`], { cwd, timeout: 60000 });
352
+ const exitCode = result.code;
353
+ // Merge stdout+stderr: npm may print the success line to either stream depending on version
354
+ const output = [result.stdout, result.stderr].filter(Boolean).join('\n');
361
355
  if (exitCode === 0 && output.includes('+ metame-cli@')) {
362
356
  const ver = output.match(/metame-cli@([\d.]+)/);
363
357
  await bot.sendMessage(chatId, `✅ Published${ver ? ' v' + ver[1] : ''}!`);
@@ -89,12 +89,13 @@ function createFileBrowser(deps) {
89
89
  : mode === 'agent-new' ? '/agent-dir'
90
90
  : '/cd';
91
91
 
92
+ const PAGE_SIZE = 10;
93
+ const totalPages = Math.max(1, Math.ceil(entries.length / PAGE_SIZE));
94
+ const safePage = Math.max(0, Math.min(page, totalPages - 1));
95
+ const start = safePage * PAGE_SIZE;
96
+ const pageSubdirs = entries.slice(start, start + PAGE_SIZE);
97
+
92
98
  if (bot.sendButtons) {
93
- const PAGE_SIZE = 10;
94
- const totalPages = Math.max(1, Math.ceil(entries.length / PAGE_SIZE));
95
- const safePage = Math.max(0, Math.min(page, totalPages - 1));
96
- const start = safePage * PAGE_SIZE;
97
- const pageSubdirs = entries.slice(start, start + PAGE_SIZE);
98
99
  const buttons = [];
99
100
  buttons.push([{ text: `✓ 选择「${displayPath}」`, callback_data: `${cmd} ${shortenPath(dirPath)}` }]);
100
101
  for (const name of pageSubdirs) {
@@ -93,6 +93,7 @@ function setupRuntimeWatchers(deps) {
93
93
  const startTime = Date.now();
94
94
  let restartDebounce = null;
95
95
  let pendingRestart = false;
96
+ let deferredRestartTimer = null; // guard: prevent duplicate deferred restart timers
96
97
 
97
98
  fs.watchFile(daemonScript, { interval: 3000 }, (curr, prev) => {
98
99
  if (curr.mtimeMs === prev.mtimeMs) return;
@@ -103,8 +104,20 @@ function setupRuntimeWatchers(deps) {
103
104
  log('INFO', `daemon.js changed on disk — deferring restart (${activeProcesses.size} active task(s))`);
104
105
  pendingRestart = true;
105
106
  } else {
106
- log('INFO', 'daemon.js changed on disk exiting for restart...');
107
- onRestartRequested();
107
+ // Even with no active processes, wait 5s for any in-flight cleanup
108
+ // (sendCard/sendMarkdown may still be running after activeProcesses.delete)
109
+ log('INFO', 'daemon.js changed on disk — no active tasks, restarting in 5s...');
110
+ if (deferredRestartTimer) clearTimeout(deferredRestartTimer);
111
+ deferredRestartTimer = setTimeout(() => {
112
+ if (activeProcesses.size > 0) {
113
+ log('INFO', `Deferred restart cancelled — ${activeProcesses.size} task(s) started during grace period`);
114
+ deferredRestartTimer = null;
115
+ pendingRestart = true;
116
+ return;
117
+ }
118
+ log('INFO', 'daemon.js changed on disk — exiting for restart...');
119
+ onRestartRequested();
120
+ }, 5000);
108
121
  }
109
122
  }, 2000);
110
123
  });
@@ -112,9 +125,9 @@ function setupRuntimeWatchers(deps) {
112
125
  const origDelete = activeProcesses.delete.bind(activeProcesses);
113
126
  activeProcesses.delete = function (key) {
114
127
  const result = origDelete(key);
115
- if (pendingRestart && activeProcesses.size === 0) {
116
- log('INFO', 'All tasks completed — executing deferred restart...');
117
- setTimeout(onRestartRequested, 500);
128
+ if (pendingRestart && activeProcesses.size === 0 && !deferredRestartTimer) {
129
+ log('INFO', 'All tasks completed — executing deferred restart in 8s...');
130
+ deferredRestartTimer = setTimeout(onRestartRequested, 8000); // 给 sendMessage/deleteMessage 等 cleanup 留出足够时间
118
131
  }
119
132
  return result;
120
133
  };
@@ -124,6 +137,7 @@ function setupRuntimeWatchers(deps) {
124
137
  fs.unwatchFile(daemonScript);
125
138
  if (reloadDebounce) clearTimeout(reloadDebounce);
126
139
  if (restartDebounce) clearTimeout(restartDebounce);
140
+ if (deferredRestartTimer) clearTimeout(deferredRestartTimer);
127
141
  activeProcesses.delete = origDelete;
128
142
  }
129
143
 
@@ -308,8 +308,14 @@ function createSessionCommandHandler(deps) {
308
308
  return true;
309
309
  }
310
310
 
311
- if (text === '/cd' || text.startsWith('/cd ')) {
312
- let newCwd = expandPath(text.slice(3).trim());
311
+ // /continue alias for /cd last (sync to computer's latest session)
312
+ if (text === '/continue') {
313
+ // Reuse /cd last logic below
314
+ // fall through with newCwd = 'last'
315
+ }
316
+
317
+ if (text === '/continue' || text === '/cd' || text.startsWith('/cd ')) {
318
+ let newCwd = text === '/continue' ? 'last' : expandPath(text.slice(3).trim());
313
319
  if (!newCwd) {
314
320
  await sendDirPicker(bot, chatId, 'cd', 'Switch workdir:');
315
321
  return true;
@@ -333,7 +339,6 @@ function createSessionCommandHandler(deps) {
333
339
  const name = target.customTitle || target.summary || '';
334
340
  const label = name ? name.slice(0, 40) : target.sessionId.slice(0, 8);
335
341
  await bot.sendMessage(chatId, `🔄 Synced to: ${label}\n📁 ${path.basename(target.projectPath)}`);
336
- await sendDirListing(bot, chatId, target.projectPath, null);
337
342
  return true;
338
343
  }
339
344
  await bot.sendMessage(chatId, 'No recent session found.');