metame-cli 1.5.25 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,6 +24,7 @@ function createBridgeStarter(deps) {
24
24
  saveState,
25
25
  getSession,
26
26
  restoreSessionFromReply,
27
+ releaseWarmPool,
27
28
  handleCommand,
28
29
  pipeline, // message pipeline for per-chatId serial execution
29
30
  pendingActivations, // optional — used to show smart activation hint
@@ -224,6 +225,21 @@ function createBridgeStarter(deps) {
224
225
  },
225
226
  });
226
227
  }
228
+
229
+ function _createPipelineTarget({ pipelineChatId, effectiveChatId, bot }) {
230
+ const replyChatId = String(pipelineChatId || '').trim();
231
+ const processChatId = String(effectiveChatId || pipelineChatId || '').trim();
232
+ if (!replyChatId || !processChatId) {
233
+ return { processChatId: replyChatId || processChatId, bot };
234
+ }
235
+ if (replyChatId === processChatId) {
236
+ return { processChatId, bot };
237
+ }
238
+ return {
239
+ processChatId,
240
+ bot: _createTeamProxyBot(bot, replyChatId),
241
+ };
242
+ }
227
243
  // Get team member's working directory inside the source tree, never under ~/.metame.
228
244
  // Creates agents/<key>/ directory by default, or ensures an explicit member.cwd exists.
229
245
  function _getMemberCwd(parentCwd, key, explicitCwd = null) {
@@ -862,6 +878,11 @@ function createBridgeStarter(deps) {
862
878
  }
863
879
  if (mapped.id) {
864
880
  log('INFO', `Session restored via reply: ${mapped.id.slice(0, 8)} (${path.basename(mapped.cwd)})`);
881
+ // Evict warm process so next spawn uses --resume with the restored session
882
+ if (typeof releaseWarmPool === 'function') {
883
+ const _logicalKey = String(mapped.logicalChatId || '').trim();
884
+ if (_logicalKey) releaseWarmPool(_logicalKey);
885
+ }
865
886
  }
866
887
  _replyAgentKey = mapped.agentKey || null;
867
888
  } else {
@@ -871,12 +892,29 @@ function createBridgeStarter(deps) {
871
892
  _replyMappingFound = true;
872
893
  _replyAgentKey = _parentMapping.agentKey || null;
873
894
  log('INFO', `Feishu topic inherited root mapping agentKey=${_replyAgentKey || 'main'} parentId=${parentId}`);
895
+ // Restore session from topic root (same as quoted reply) so 指定回复 resumes context
896
+ if (_parentMapping.id && typeof restoreSessionFromReply === 'function') {
897
+ restoreSessionFromReply(chatId, _parentMapping);
898
+ log('INFO', `Session restored via topic root: ${_parentMapping.id.slice(0, 8)} (${path.basename(_parentMapping.cwd || '~')})`);
899
+ // Evict warm process so next spawn uses --resume with the restored session
900
+ if (typeof releaseWarmPool === 'function') {
901
+ const _logicalKey = String(_parentMapping.logicalChatId || '').trim();
902
+ if (_logicalKey) releaseWarmPool(_logicalKey);
903
+ }
904
+ }
874
905
  }
875
906
 
876
907
  // Helper: set/clear sticky on shared state object and persist
877
908
  // Use pipelineChatId so each topic gets independent sticky state
878
909
  const _chatKey = String(pipelineChatId);
879
910
  const _rawChatKey = _threadRawChatId(_chatKey);
911
+ const _topicMainRoute = !!(
912
+ threadRootId
913
+ && _parentMapping
914
+ && !_isQuotedReply
915
+ && _parentMapping.logicalChatId
916
+ && !String(_parentMapping.logicalChatId).startsWith('_agent_')
917
+ );
880
918
  const _setSticky = (key) => {
881
919
  if (!_st.team_sticky) _st.team_sticky = {};
882
920
  _st.team_sticky[_chatKey] = key;
@@ -899,6 +937,11 @@ function createBridgeStarter(deps) {
899
937
  saveState(_st);
900
938
  };
901
939
  let _stickyKey = (_st.team_sticky || {})[_chatKey] || (_st.team_sticky || {})[_rawChatKey] || null;
940
+ const _pipelineTarget = _createPipelineTarget({
941
+ pipelineChatId,
942
+ effectiveChatId: _topicMainRoute ? chatId : pipelineChatId,
943
+ bot,
944
+ });
902
945
 
903
946
  // Team group routing: if bound project has a team array, check message for member nickname
904
947
  // Non-/stop slash commands bypass team routing → handled by main project
@@ -978,7 +1021,13 @@ function createBridgeStarter(deps) {
978
1021
  // Cases b & c: no agentKey (main agent) or stale/unknown agentKey
979
1022
  _clearSticky();
980
1023
  log('INFO', `Quoted reply → route to main (agentKey=${_replyAgentKey} mappingFound=${_replyMappingFound})`);
981
- await pipeline.processMessage(pipelineChatId, trimmedText, { bot, config: liveCfg, executeTaskByName, senderId: acl.senderId, readOnly: acl.readOnly });
1024
+ await pipeline.processMessage(_pipelineTarget.processChatId, trimmedText, {
1025
+ bot: _pipelineTarget.bot,
1026
+ config: liveCfg,
1027
+ executeTaskByName,
1028
+ senderId: acl.senderId,
1029
+ readOnly: acl.readOnly,
1030
+ });
982
1031
  return;
983
1032
  }
984
1033
  // 1. Explicit nickname → route + set sticky
@@ -1034,7 +1083,13 @@ function createBridgeStarter(deps) {
1034
1083
  return;
1035
1084
  }
1036
1085
  try {
1037
- await pipeline.processMessage(pipelineChatId, rest, { bot, config: liveCfg, executeTaskByName, senderId: acl.senderId, readOnly: acl.readOnly });
1086
+ await pipeline.processMessage(_pipelineTarget.processChatId, rest, {
1087
+ bot: _pipelineTarget.bot,
1088
+ config: liveCfg,
1089
+ executeTaskByName,
1090
+ senderId: acl.senderId,
1091
+ readOnly: acl.readOnly,
1092
+ });
1038
1093
  } catch (e) {
1039
1094
  log('ERROR', `Team main-route handleCommand failed: ${e.message}`);
1040
1095
  bot.sendMessage(pipelineChatId, `❌ 执行失败: ${e.message}`).catch(() => {});
@@ -1058,7 +1113,13 @@ function createBridgeStarter(deps) {
1058
1113
  }
1059
1114
 
1060
1115
  try {
1061
- await pipeline.processMessage(pipelineChatId, text, { bot, config: liveCfg, executeTaskByName, senderId: acl.senderId, readOnly: acl.readOnly });
1116
+ await pipeline.processMessage(_pipelineTarget.processChatId, text, {
1117
+ bot: _pipelineTarget.bot,
1118
+ config: liveCfg,
1119
+ executeTaskByName,
1120
+ senderId: acl.senderId,
1121
+ readOnly: acl.readOnly,
1122
+ });
1062
1123
  } catch (e) {
1063
1124
  log('ERROR', `Feishu handleCommand failed for ${chatId}: ${e.message}`);
1064
1125
  bot.sendMessage(pipelineChatId, `❌ 命令执行失败: ${e.message}`).catch(() => {});
@@ -10,7 +10,7 @@ const {
10
10
  _private: { resolveCodexPermissionProfile },
11
11
  } = require('./daemon-engine-runtime');
12
12
  const { rawChatId } = require('./core/thread-chat-id');
13
- const { buildAgentContextForEngine, buildMemorySnapshotContent, refreshMemorySnapshot } = require('./agent-layer');
13
+ const { buildAgentContextForEngine, buildMemorySnapshotContent, selectSnapshotContext, refreshMemorySnapshot } = require('./agent-layer');
14
14
  const {
15
15
  adaptDaemonHintForEngine,
16
16
  buildAgentHint,
@@ -84,6 +84,7 @@ function createClaudeEngine(deps) {
84
84
  isContentFile,
85
85
  sendFileButtons,
86
86
  findSessionFile,
87
+ listRecentSessions,
87
88
  getSession,
88
89
  getSessionForEngine,
89
90
  createSession,
@@ -102,6 +103,7 @@ function createClaudeEngine(deps) {
102
103
  touchInteraction,
103
104
  statusThrottleMs = 3000,
104
105
  fallbackThrottleMs = 8000,
106
+ autoSyncMinGapMs = 60_000,
105
107
  getEngineRuntime: injectedGetEngineRuntime,
106
108
  getDefaultEngine: _getDefaultEngine,
107
109
  warmPool,
@@ -1362,6 +1364,36 @@ function createClaudeEngine(deps) {
1362
1364
  await patchSessionSerialized(sessionChatId, (cur) => ({ ...cur, cwd: effectiveCwd }));
1363
1365
  }
1364
1366
 
1367
+ // Auto-sync: when daemon is idle (no warm process), check if a newer Claude session exists
1368
+ // in the project directory (e.g., created from Claude Code CLI on the same computer).
1369
+ // This keeps phone (Feishu/mobile) and computer (Claude Code CLI) sessions in sync.
1370
+ const _hasWarm = !!(warmPool && typeof warmPool.hasWarm === 'function' && warmPool.hasWarm(sessionChatId));
1371
+ if (runtime.name === 'claude' && !String(sessionChatId).startsWith('_agent_') &&
1372
+ !_hasWarm && session.started && session.id && session.id !== '__continue__' && session.cwd) {
1373
+ const _recentInProject = typeof listRecentSessions === 'function'
1374
+ ? listRecentSessions(2, session.cwd, 'claude') : [];
1375
+ const _currentMtime = (() => {
1376
+ try {
1377
+ const _f = typeof findSessionFile === 'function' ? findSessionFile(session.id) : null;
1378
+ return _f ? fs.statSync(_f).mtimeMs : 0;
1379
+ } catch { return 0; }
1380
+ })();
1381
+ const _newerSession = _recentInProject.find(
1382
+ s => s.sessionId !== session.id && (s.fileMtime || 0) - _currentMtime > autoSyncMinGapMs
1383
+ );
1384
+ if (_newerSession) {
1385
+ const _gapSec = Math.round(((_newerSession.fileMtime || 0) - _currentMtime) / 1000);
1386
+ log('INFO', `[AutoSync] ${String(sessionChatId).slice(-8)}: switching ${session.id.slice(0, 8)} -> ${_newerSession.sessionId.slice(0, 8)} (newer by ${_gapSec}s)`);
1387
+ await patchSessionSerialized(sessionChatId, (cur) => {
1388
+ const _engines = { ...(cur.engines || {}) };
1389
+ _engines.claude = { ...(_engines.claude || {}), id: _newerSession.sessionId, started: true };
1390
+ return { ...cur, engines: _engines };
1391
+ });
1392
+ session = { ...session, id: _newerSession.sessionId };
1393
+ bot.sendMessage(chatId, `🔄 已自动同步到最新 session:\`${_newerSession.sessionId.slice(0, 8)}\``).catch(() => { });
1394
+ }
1395
+ }
1396
+
1365
1397
  // Warm pool: check if a persistent process is available for this session (Claude only).
1366
1398
  // Declared early so downstream logic can skip expensive operations when reusing warm process.
1367
1399
  const _warmSessionKey = sessionChatId;
@@ -2148,14 +2180,30 @@ function createClaudeEngine(deps) {
2148
2180
  if (dispatchedTargets.length > 0) {
2149
2181
  const allProjects = (config && config.projects) || {};
2150
2182
  const names = dispatchedTargets.map(k => (allProjects[k] && allProjects[k].name) || k).join('、');
2151
- const doneMsg = await bot.sendMessage(chatId, `✉️ 已转达给 ${names},处理中…`);
2183
+ const fwdText = `✉️ 已转达给 ${names},处理中…`;
2184
+ let doneMsg;
2185
+ if (statusMsgId && bot.editMessage) {
2186
+ await bot.editMessage(chatId, statusMsgId, fwdText, _ackCardHeader).catch(() => {});
2187
+ doneMsg = { message_id: statusMsgId };
2188
+ } else {
2189
+ if (statusMsgId && bot.deleteMessage) bot.deleteMessage(chatId, statusMsgId).catch(() => {});
2190
+ doneMsg = await bot.sendMessage(chatId, fwdText);
2191
+ }
2152
2192
  if (doneMsg && doneMsg.message_id && session) trackMsgSession(doneMsg.message_id, session, projectKeyFromVirtualChatId(chatId));
2153
2193
  const wasNew = !session.started;
2154
2194
  if (wasNew) markSessionStarted(sessionChatId, engineName);
2155
2195
  return { ok: true };
2156
2196
  }
2157
2197
  const filesDesc = files && files.length > 0 ? `\n修改了 ${files.length} 个文件` : '';
2158
- const doneMsg = await bot.sendMessage(chatId, `✅ 完成${filesDesc}`);
2198
+ const doneText = `✅ 完成${filesDesc}`;
2199
+ let doneMsg;
2200
+ if (statusMsgId && bot.editMessage) {
2201
+ await bot.editMessage(chatId, statusMsgId, doneText, _ackCardHeader).catch(() => {});
2202
+ doneMsg = { message_id: statusMsgId };
2203
+ } else {
2204
+ if (statusMsgId && bot.deleteMessage) bot.deleteMessage(chatId, statusMsgId).catch(() => {});
2205
+ doneMsg = await bot.sendMessage(chatId, doneText);
2206
+ }
2159
2207
  if (doneMsg && doneMsg.message_id && session) trackMsgSession(doneMsg.message_id, session, projectKeyFromVirtualChatId(chatId));
2160
2208
  const wasNew = !session.started;
2161
2209
  if (wasNew) markSessionStarted(sessionChatId, engineName);
@@ -2338,12 +2386,18 @@ function createClaudeEngine(deps) {
2338
2386
  setImmediate(async () => {
2339
2387
  try {
2340
2388
  const memory = require('./memory');
2341
- const pKey = boundProjectKey || '';
2342
- const sessions = memory.recentSessions({ limit: 5, project: pKey });
2343
- const factsRaw = memory.searchFacts('', { limit: 10, project: pKey });
2344
- const facts = Array.isArray(factsRaw) ? factsRaw : [];
2389
+ const snapshotCtx = selectSnapshotContext(memory, {
2390
+ projectHints: [
2391
+ boundProjectKey,
2392
+ boundProject.project_key,
2393
+ boundProject.name,
2394
+ boundProject.agent_id,
2395
+ ],
2396
+ sessionLimit: 5,
2397
+ factLimit: 10,
2398
+ });
2345
2399
  memory.close();
2346
- const snapshotContent = buildMemorySnapshotContent(sessions, facts);
2400
+ const snapshotContent = buildMemorySnapshotContent(snapshotCtx.sessions, snapshotCtx.facts);
2347
2401
  const agentId = boundProject.agent_id;
2348
2402
  if (refreshMemorySnapshot(agentId, snapshotContent, HOME)) {
2349
2403
  log('DEBUG', `[AGENT] Memory snapshot refreshed for ${agentId}`);
@@ -3,6 +3,7 @@
3
3
  const { resolveEngineModel } = require('./daemon-engine-runtime');
4
4
  const { createAgentIntentHandler } = require('./daemon-agent-intent');
5
5
  const { rawChatId: extractOriginalChatId, isThreadChatId } = require('./core/thread-chat-id');
6
+ const { createWikiCommandHandler } = require('./daemon-wiki');
6
7
 
7
8
  function createCommandRouter(deps) {
8
9
  const {
@@ -30,6 +31,7 @@ function createCommandRouter(deps) {
30
31
  pendingActivations,
31
32
  agentFlowTtlMs,
32
33
  getDefaultEngine,
34
+ getDb, // optional — () → DatabaseSync (for wiki commands)
33
35
  } = deps;
34
36
 
35
37
 
@@ -462,6 +464,19 @@ function createCommandRouter(deps) {
462
464
  return;
463
465
  }
464
466
 
467
+ // /wiki — knowledge wiki commands
468
+ if (text.startsWith('/wiki') && getDb) {
469
+ const wikiProviders = providerMod ? {
470
+ callHaiku: (...args) => providerMod.callHaiku(...args),
471
+ buildDistillEnv: (...args) => providerMod.buildDistillEnv(...args),
472
+ } : null;
473
+ const wikiOutputDir = config && config.daemon && config.daemon.wiki_output_dir
474
+ ? config.daemon.wiki_output_dir.replace(/^~/, process.env.HOME || '')
475
+ : null;
476
+ const { handleWikiCommand } = createWikiCommandHandler({ getDb, providers: wikiProviders, wikiOutputDir, log });
477
+ if (await handleWikiCommand({ bot, chatId, text })) return;
478
+ }
479
+
465
480
  // /btw — quick side question (read-only, concise, bypasses cooldown)
466
481
  if (/^\/btw(\s|$)/i.test(text)) {
467
482
  const btwQuestion = text.replace(/^\/btw\s*/i, '').trim();
@@ -55,7 +55,6 @@ heartbeat:
55
55
  type: script
56
56
  command: node ~/.metame/distill.js
57
57
  interval: 4h
58
- timeout: 5m
59
58
  precondition: "test -s ~/.metame/raw_signals.jsonl"
60
59
  require_idle: true
61
60
  notify: false
@@ -85,7 +84,6 @@ heartbeat:
85
84
  type: script
86
85
  command: node ~/.metame/skill-evolution.js
87
86
  interval: 12h
88
- timeout: 5m
89
87
  precondition: "test -s ~/.metame/skill_signals.jsonl"
90
88
  require_idle: true
91
89
  notify: false
@@ -116,7 +114,7 @@ heartbeat:
116
114
  at: "01:30"
117
115
  require_idle: true
118
116
  notify: false
119
- enabled: false
117
+ enabled: true
120
118
 
121
119
  # Legacy flat tasks (no project isolation). New tasks should go under projects: above.
122
120
  # Examples — uncomment or add your own:
@@ -166,6 +164,7 @@ budget:
166
164
  daemon:
167
165
  model: sonnet # sonnet, opus, haiku — model for mobile sessions
168
166
  log_max_size: 1048576
167
+ wiki_output_dir: ~/Documents/ObsidianVault/MetaMe/wiki # Obsidian vault wiki subdirectory
169
168
  heartbeat_check_interval: 60
170
169
  # Skip all permission prompts for mobile sessions (full access)
171
170
  # Mobile users can't click "allow" — so we pre-authorize everything.
@@ -87,30 +87,22 @@ function createOpsCommandHandler(deps) {
87
87
  }
88
88
  try {
89
89
  let diffFiles = '';
90
- let diffFailed = false;
91
90
  const _wh = process.platform === 'win32' ? { windowsHide: true } : {};
92
- try { diffFiles = execSync(`git diff --name-only HEAD ${match.hash}`, { cwd, encoding: 'utf8', timeout: 5000, ..._wh }).trim(); } catch { diffFailed = true; }
91
+ try { diffFiles = execSync(`git diff --name-only HEAD ${match.hash}`, { cwd, encoding: 'utf8', timeout: 5000, ..._wh }).trim(); } catch { }
93
92
  const changedFiles = diffFiles ? diffFiles.split('\n').filter(Boolean) : [];
94
- if (changedFiles.length > 0 || diffFailed) {
95
- // Save current state before rollback (safety net)
96
- gitCheckpoint(cwd, '[metame-safety] before rollback');
97
- // Reset HEAD to checkpoint's parent (removes any commits Claude made)
98
- if (match.parentHash) {
99
- execSync(`git reset --hard ${match.parentHash}`, { cwd, stdio: 'ignore', timeout: 10000, ..._wh });
100
- }
101
- // Restore only changed files (not entire worktree) to preserve user's manual edits
102
- if (changedFiles.length > 0) {
103
- execFileSync('git', ['checkout', match.hash, '--', ...changedFiles], { cwd, stdio: 'ignore', timeout: 10000 });
104
- } else {
105
- // diff failed but we still reset — fallback to full restore
106
- execFileSync('git', ['checkout', match.hash, '--', '.'], { cwd, stdio: 'ignore', timeout: 10000 });
107
- }
93
+ // Reset HEAD to checkpoint's parent (removes any commits Claude made)
94
+ if (match.parentHash) {
95
+ execSync(`git reset --hard ${match.parentHash}`, { cwd, stdio: 'ignore', timeout: 10000, ..._wh });
96
+ }
97
+ // Restore only files touched between the checkpoint and HEAD; unrelated user edits must survive.
98
+ if (changedFiles.length > 0) {
99
+ execFileSync('git', ['checkout', match.hash, '--', ...changedFiles], { cwd, stdio: 'ignore', timeout: 10000 });
108
100
  }
109
101
  // Truncate context to checkpoint time (covers multi-turn rollback)
110
102
  truncateSessionToCheckpoint(session.id, match.message);
103
+ const fileList = changedFiles.map(f => path.basename(f)).join(', ');
111
104
  const fileCount = changedFiles.length;
112
105
  let msg = `⏪ 已回退到 ${cpDisplayLabel(match.message)}`;
113
- const fileList = changedFiles.map(f => path.basename(f)).join(', ');
114
106
  if (fileCount > 0) msg += `\n📁 ${fileCount} 个文件恢复: ${fileList}`;
115
107
  log('INFO', `/undo <hash> executed for ${chatId}: reset to ${match.hash.slice(0, 8)}, files=${fileCount}`);
116
108
  await bot.sendMessage(chatId, msg);
@@ -251,7 +243,6 @@ function createOpsCommandHandler(deps) {
251
243
  if (cpMatch.parentHash) {
252
244
  execSync(`git reset --hard ${cpMatch.parentHash}`, { cwd: cwd2, stdio: 'ignore', timeout: 10000, ..._wh2 });
253
245
  }
254
- // Restore only changed files (not entire worktree) to preserve user's manual edits
255
246
  execFileSync('git', ['checkout', cpMatch.hash, '--', ...changedFiles2], { cwd: cwd2, stdio: 'ignore', timeout: 10000 });
256
247
  gitMsg2 = `\n📁 ${changedFiles2.length} 个文件已恢复`;
257
248
  cleanupCheckpoints(cwd2);
@@ -36,6 +36,7 @@ function createSessionCommandHandler(deps) {
36
36
  getSessionRecentContext,
37
37
  getSessionRecentDialogue,
38
38
  getDefaultEngine = () => 'claude',
39
+ releaseWarmPool,
39
40
  } = deps;
40
41
 
41
42
  function normalizeEngineName(name) {
@@ -412,10 +413,13 @@ function createSessionCommandHandler(deps) {
412
413
  return true;
413
414
  }
414
415
 
416
+ const sessionKeyForWarm = getSessionRoute(chatId).sessionChatId;
415
417
  const state2 = loadState();
416
418
  const targetEngine = normalizeEngineName(target.engine) || getCurrentEngine(chatId);
417
419
  const attached = attachResolvedTarget(state2, chatId, targetEngine, target, target.projectPath || HOME);
418
420
  saveState(state2);
421
+ // Evict warm process so next spawn uses --resume <new-session-id>
422
+ if (typeof releaseWarmPool === 'function') releaseWarmPool(sessionKeyForWarm);
419
423
 
420
424
  const recentCtx = typeof getSessionRecentContext === 'function'
421
425
  ? getSessionRecentContext(target.sessionId)
@@ -161,6 +161,20 @@ function createWarmPool(deps) {
161
161
  }
162
162
  }
163
163
 
164
+ /**
165
+ * Non-destructive validity check. Returns true if a live warm process exists for the key.
166
+ * Mirrors acquireWarm's dead-process checks without consuming the entry.
167
+ */
168
+ function hasWarm(sessionKey) {
169
+ const entry = pool.get(sessionKey);
170
+ if (!entry) return false;
171
+ if (entry.child.killed || entry.child.exitCode !== null) {
172
+ _cleanup(sessionKey);
173
+ return false;
174
+ }
175
+ return true;
176
+ }
177
+
164
178
  /**
165
179
  * Build the stream-json user message for stdin.
166
180
  */
@@ -179,6 +193,7 @@ function createWarmPool(deps) {
179
193
  releaseWarm,
180
194
  releaseAll,
181
195
  buildStreamMessage,
196
+ hasWarm,
182
197
  _pool: pool,
183
198
  };
184
199
  }