metame-cli 1.5.19 → 1.5.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.
Files changed (40) hide show
  1. package/index.js +157 -80
  2. package/package.json +2 -2
  3. package/scripts/bin/bootstrap-worktree.sh +20 -0
  4. package/scripts/core/audit.js +190 -0
  5. package/scripts/core/handoff.js +780 -0
  6. package/scripts/core/handoff.test.js +1074 -0
  7. package/scripts/core/memory-model.js +183 -0
  8. package/scripts/core/memory-model.test.js +486 -0
  9. package/scripts/core/reactive-paths.js +44 -0
  10. package/scripts/core/reactive-paths.test.js +35 -0
  11. package/scripts/core/reactive-prompt.js +51 -0
  12. package/scripts/core/reactive-prompt.test.js +88 -0
  13. package/scripts/core/reactive-signal.js +40 -0
  14. package/scripts/core/reactive-signal.test.js +88 -0
  15. package/scripts/core/thread-chat-id.js +52 -0
  16. package/scripts/core/thread-chat-id.test.js +113 -0
  17. package/scripts/daemon-bridges.js +92 -38
  18. package/scripts/daemon-claude-engine.js +373 -444
  19. package/scripts/daemon-command-router.js +82 -8
  20. package/scripts/daemon-engine-runtime.js +7 -10
  21. package/scripts/daemon-reactive-lifecycle.js +100 -33
  22. package/scripts/daemon-session-commands.js +133 -43
  23. package/scripts/daemon-session-store.js +300 -82
  24. package/scripts/daemon-team-dispatch.js +16 -16
  25. package/scripts/daemon.js +21 -175
  26. package/scripts/deploy-manifest.js +90 -0
  27. package/scripts/docs/maintenance-manual.md +14 -11
  28. package/scripts/docs/pointer-map.md +13 -4
  29. package/scripts/feishu-adapter.js +31 -27
  30. package/scripts/hooks/intent-engine.js +6 -3
  31. package/scripts/hooks/intent-memory-recall.js +1 -0
  32. package/scripts/hooks/intent-perpetual.js +1 -1
  33. package/scripts/memory-extract.js +5 -97
  34. package/scripts/memory-gc.js +35 -90
  35. package/scripts/memory-migrate-v2.js +304 -0
  36. package/scripts/memory-nightly-reflect.js +40 -41
  37. package/scripts/memory.js +340 -859
  38. package/scripts/migrate-reactive-paths.js +122 -0
  39. package/scripts/signal-capture.js +4 -0
  40. package/scripts/sync-plugin.js +56 -0
@@ -4,9 +4,12 @@ let userAcl = null;
4
4
  try { userAcl = require('./daemon-user-acl'); } catch { /* optional */ }
5
5
  const { findTeamMember: _findTeamMember } = require('./daemon-team-dispatch');
6
6
  const { isRemoteMember } = require('./daemon-remote-dispatch');
7
+ const { buildThreadChatId, isThreadChatId, rawChatId: _threadRawChatId } = require('./core/thread-chat-id');
7
8
  const imessageIO = (() => { try { return require('./daemon-siri-imessage'); } catch { return null; } })();
8
9
  const siriBridgeMod = (() => { try { return require('./daemon-siri-bridge'); } catch { return null; } })();
9
10
  const weixinBridgeMod = (() => { try { return require('./daemon-weixin-bridge'); } catch { return null; } })();
11
+ const MSG_SESSION_MAX_ENTRIES = 5000;
12
+ const MSG_SESSION_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
10
13
 
11
14
  function createBridgeStarter(deps) {
12
15
  const {
@@ -114,6 +117,20 @@ function createBridgeStarter(deps) {
114
117
  return null;
115
118
  }
116
119
 
120
+ /**
121
+ * Extract the topic root message ID (root_id) from a Feishu event.
122
+ * Returns non-null ONLY for messages inside a Feishu "话题" thread,
123
+ * NOT for plain quoted replies in conversation mode.
124
+ */
125
+ function extractFeishuThreadRootId(event) {
126
+ const msg = (event && event.message) || (event && event.event && event.event.message);
127
+ if (!msg) return null;
128
+ // root_id is set when the message belongs to a topic thread.
129
+ // In conversation mode, a simple "指定回复" sets parent_id but NOT root_id.
130
+ const rootId = String(msg.root_id || '').trim();
131
+ return rootId || null;
132
+ }
133
+
117
134
  function trackBridgeReplyMapping(messageId, payload = {}) {
118
135
  const safeMessageId = String(messageId || '').trim();
119
136
  if (!safeMessageId) return;
@@ -122,7 +139,20 @@ function createBridgeStarter(deps) {
122
139
  state.msg_sessions[safeMessageId] = {
123
140
  ...(state.msg_sessions[safeMessageId] || {}),
124
141
  ...payload,
142
+ touchedAt: Date.now(),
125
143
  };
144
+ const now = Date.now();
145
+ const entries = Object.entries(state.msg_sessions).filter(([, value]) => {
146
+ const touchedAt = Number(value && value.touchedAt || 0);
147
+ return !touchedAt || (now - touchedAt) <= MSG_SESSION_MAX_AGE_MS;
148
+ });
149
+ state.msg_sessions = Object.fromEntries(
150
+ (entries.length > MSG_SESSION_MAX_ENTRIES
151
+ ? entries
152
+ .sort((a, b) => Number((a[1] && a[1].touchedAt) || 0) - Number((b[1] && b[1].touchedAt) || 0))
153
+ .slice(entries.length - MSG_SESSION_MAX_ENTRIES)
154
+ : entries)
155
+ );
126
156
  saveState(state);
127
157
  }
128
158
 
@@ -160,10 +190,9 @@ function createBridgeStarter(deps) {
160
190
  const map = {
161
191
  ...(cfg.telegram ? cfg.telegram.chat_agent_map || {} : {}),
162
192
  ...(cfg.feishu ? cfg.feishu.chat_agent_map || {} : {}),
163
- ...(cfg.weixin ? cfg.weixin.chat_agent_map || {} : {}),
164
193
  ...(cfg.imessage ? cfg.imessage.chat_agent_map || {} : {}),
165
194
  };
166
- const key = map[String(chatId)];
195
+ const key = map[String(chatId)] || map[_threadRawChatId(chatId)];
167
196
  const proj = key && cfg.projects ? cfg.projects[key] : null;
168
197
  return { key: key || null, project: proj || null };
169
198
  }
@@ -181,9 +210,9 @@ function createBridgeStarter(deps) {
181
210
  },
182
211
  });
183
212
  }
184
- // Get team member's working directory using subdir (not worktree).
185
- // Creates agents/<key>/ directory and symlinks CLAUDE.md from parent.
186
- function _getMemberCwd(parentCwd, key) {
213
+ // Get team member's working directory inside the source tree, never under ~/.metame.
214
+ // Creates agents/<key>/ directory by default, or ensures an explicit member.cwd exists.
215
+ function _getMemberCwd(parentCwd, key, explicitCwd = null) {
187
216
  const { existsSync, mkdirSync, symlinkSync, readFileSync, writeFileSync } = require('fs');
188
217
  const { execFileSync } = require('child_process');
189
218
  const WIN_HIDE = process.platform === 'win32' ? { windowsHide: true } : {};
@@ -194,12 +223,14 @@ function createBridgeStarter(deps) {
194
223
  log('WARN', `Sanitized team member key: ${key} -> ${safeKey}`);
195
224
  }
196
225
 
197
- // Use agents/<key>/ as the working directory
226
+ // Use explicit member cwd when provided, otherwise default to agents/<key>/.
198
227
  const agentsDir = path.join(parentCwd, 'agents');
199
- const memberDir = path.join(agentsDir, safeKey);
228
+ const memberDir = explicitCwd
229
+ ? path.resolve(String(explicitCwd).replace(/^~/, require('os').homedir()))
230
+ : path.join(agentsDir, safeKey);
200
231
 
201
- // Create agents directory if not exists
202
- if (!existsSync(agentsDir)) {
232
+ // Create agents directory if using the default layout.
233
+ if (!explicitCwd && !existsSync(agentsDir)) {
203
234
  mkdirSync(agentsDir, { recursive: true });
204
235
  }
205
236
 
@@ -279,12 +310,19 @@ function createBridgeStarter(deps) {
279
310
  return;
280
311
  }
281
312
 
282
- const virtualChatId = `_agent_${member.key}`;
313
+ // When dispatching from a topic thread, include the thread ID in the
314
+ // virtual session key so each topic gets its own independent session.
315
+ const realChatIdStr = String(realChatId || '');
316
+ const virtualChatId = isThreadChatId(realChatIdStr)
317
+ ? `_agent_${member.key}::${realChatIdStr}`
318
+ : `_agent_${member.key}`;
283
319
  const parentCwd = member.cwd || boundProj.cwd;
284
320
  const resolvedParentCwd = parentCwd.replace(/^~/, require('os').homedir());
285
- const memberCwd = typeof getOrCreateWorktree === 'function'
286
- ? getOrCreateWorktree(resolvedParentCwd, member.key)
287
- : _getMemberCwd(resolvedParentCwd, member.key);
321
+ const memberCwd = _getMemberCwd(
322
+ resolvedParentCwd,
323
+ member.key,
324
+ member.cwd || null,
325
+ );
288
326
  if (!memberCwd) {
289
327
  log('ERROR', `Team [${member.key}] cannot start: directory unavailable`);
290
328
  bot.sendMessage(realChatId, `❌ ${member.icon || '🤖'} ${member.name} 启动失败:工作目录创建失败`).catch(() => {});
@@ -710,6 +748,13 @@ function createBridgeStarter(deps) {
710
748
  return;
711
749
  }
712
750
 
751
+ // ── Topic mode detection (before file/text split) ──
752
+ const threadRootId = extractFeishuThreadRootId(event);
753
+ const pipelineChatId = threadRootId ? buildThreadChatId(chatId, threadRootId) : chatId;
754
+ if (threadRootId) {
755
+ log('INFO', `Feishu topic detected: root=${threadRootId} → pipelineChatId=${pipelineChatId}`);
756
+ }
757
+
713
758
  if (fileInfo && fileInfo.fileKey) {
714
759
  const acl = await applyUserAcl({
715
760
  bot,
@@ -721,7 +766,7 @@ function createBridgeStarter(deps) {
721
766
  });
722
767
  if (acl.blocked) return;
723
768
  log('INFO', `Feishu file from ${chatId}: ${fileInfo.fileName} (key: ${fileInfo.fileKey}, msgId: ${fileInfo.messageId}, type: ${fileInfo.msgType})`);
724
- const session = getSession(chatId);
769
+ const session = getSession(pipelineChatId) || getSession(chatId);
725
770
  const cwd = session?.cwd || HOME;
726
771
  const uploadDir = path.join(cwd, 'upload');
727
772
  if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
@@ -729,7 +774,7 @@ function createBridgeStarter(deps) {
729
774
 
730
775
  try {
731
776
  await bot.downloadFile(fileInfo.messageId, fileInfo.fileKey, destPath, fileInfo.msgType);
732
- await bot.sendMessage(chatId, `📥 Saved: ${fileInfo.fileName}`);
777
+ await bot.sendMessage(pipelineChatId, `📥 Saved: ${fileInfo.fileName}`);
733
778
 
734
779
  const prompt = text
735
780
  ? `User uploaded a file to the project: ${destPath}\nUser says: "${text}"`
@@ -737,21 +782,21 @@ function createBridgeStarter(deps) {
737
782
 
738
783
  // Respect team_sticky: route to active agent same as text messages
739
784
  const _stFile = loadState();
740
- const _chatKeyFile = String(chatId);
785
+ const _chatKeyFile = String(pipelineChatId);
741
786
  const { project: _boundProjFile } = _getBoundProject(chatId, liveCfg);
742
787
  const _stickyKeyFile = (_stFile.team_sticky || {})[_chatKeyFile];
743
788
  if (_boundProjFile && Array.isArray(_boundProjFile.team) && _boundProjFile.team.length > 0 && _stickyKeyFile) {
744
789
  const _stickyMember = _boundProjFile.team.find(m => m.key === _stickyKeyFile);
745
790
  if (_stickyMember) {
746
791
  log('INFO', `Feishu file → sticky route to ${_stickyKeyFile}`);
747
- _dispatchToTeamMember(_stickyMember, _boundProjFile, prompt, liveCfg, bot, chatId, executeTaskByName, acl);
792
+ _dispatchToTeamMember(_stickyMember, _boundProjFile, prompt, liveCfg, bot, pipelineChatId, executeTaskByName, acl);
748
793
  return;
749
794
  }
750
795
  }
751
- await pipeline.processMessage(chatId, prompt, { bot, config: liveCfg, executeTaskByName, senderId: acl.senderId, readOnly: acl.readOnly });
796
+ await pipeline.processMessage(pipelineChatId, prompt, { bot, config: liveCfg, executeTaskByName, senderId: acl.senderId, readOnly: acl.readOnly });
752
797
  } catch (err) {
753
798
  log('ERROR', `Feishu file download failed: ${err.message}`);
754
- await bot.sendMessage(chatId, `❌ Download failed: ${err.message}`);
799
+ await bot.sendMessage(pipelineChatId, `❌ Download failed: ${err.message}`);
755
800
  }
756
801
  return;
757
802
  }
@@ -772,10 +817,13 @@ function createBridgeStarter(deps) {
772
817
  let _replyMappingFound = false; // true = mapping exists (agentKey may be null = main)
773
818
  // Load state once for the entire routing block
774
819
  const _st = loadState();
775
- if (parentId) {
820
+ // Quoted reply = explicit parentId but NOT a topic thread (topics always carry parentId=root_id)
821
+ const _isQuotedReply = !!(parentId && !threadRootId);
822
+ if (_isQuotedReply) {
776
823
  log('INFO', `Feishu reply metadata detected chat=${chatId} parentId=${parentId}`);
777
824
  }
778
- if (parentId) {
825
+ // In topic mode, session continuity is handled by pipelineChatId — skip msg_sessions lookup
826
+ if (_isQuotedReply) {
779
827
  const mapped = _st.msg_sessions && _st.msg_sessions[parentId];
780
828
  if (mapped) {
781
829
  _replyMappingFound = true;
@@ -798,7 +846,8 @@ function createBridgeStarter(deps) {
798
846
  }
799
847
 
800
848
  // Helper: set/clear sticky on shared state object and persist
801
- const _chatKey = String(chatId);
849
+ // Use pipelineChatId so each topic gets independent sticky state
850
+ const _chatKey = String(pipelineChatId);
802
851
  const _setSticky = (key) => {
803
852
  if (!_st.team_sticky) _st.team_sticky = {};
804
853
  _st.team_sticky[_chatKey] = key;
@@ -836,21 +885,23 @@ function createBridgeStarter(deps) {
836
885
  // Priority 3: bare /stop → sticky
837
886
  if (!_targetKey && !_stopArg) _targetKey = _stickyKey;
838
887
  if (_targetKey) {
839
- const vid = `_agent_${_targetKey}`;
888
+ const vid = isThreadChatId(String(pipelineChatId))
889
+ ? `_agent_${_targetKey}::${pipelineChatId}`
890
+ : `_agent_${_targetKey}`;
840
891
  const member = _boundProj.team.find(t => t.key === _targetKey);
841
892
  const label = member ? `${member.icon || '🤖'} ${member.name}` : _targetKey;
842
893
  pipeline.clearQueue(vid);
843
894
  const stopped = pipeline.interruptActive(vid);
844
895
  if (stopped) {
845
- await bot.sendMessage(chatId, `⏹ Stopping ${label}...`);
896
+ await bot.sendMessage(pipelineChatId, `⏹ Stopping ${label}...`);
846
897
  } else {
847
- await bot.sendMessage(chatId, `${label} 当前没有活跃任务`);
898
+ await bot.sendMessage(pipelineChatId, `${label} 当前没有活跃任务`);
848
899
  }
849
900
  return;
850
901
  }
851
902
  // /stop <bad-nickname> → no match, report error instead of falling through
852
903
  if (_stopArg) {
853
- await bot.sendMessage(chatId, `❌ 未找到团队成员: ${_stopArg}`);
904
+ await bot.sendMessage(pipelineChatId, `❌ 未找到团队成员: ${_stopArg}`);
854
905
  return;
855
906
  }
856
907
  // Bare /stop, no sticky set → fall through to handleCommand
@@ -861,13 +912,13 @@ function createBridgeStarter(deps) {
861
912
  // a) agentKey = known team member → route to that member (set sticky)
862
913
  // b) agentKey = null, mapping found → user replied to main; clear sticky, route to main
863
914
  // c) parentId present, no mapping → intent is explicit, avoid sticky; clear sticky, route to main
864
- if (parentId) {
915
+ if (_isQuotedReply) {
865
916
  if (_replyAgentKey) {
866
917
  const member = _boundProj.team.find(m => m.key === _replyAgentKey);
867
918
  if (member) {
868
919
  _setSticky(member.key);
869
920
  log('INFO', `Quoted reply → force route to ${_replyAgentKey} (sticky set)`);
870
- _dispatchToTeamMember(member, _boundProj, trimmedText, liveCfg, bot, chatId, executeTaskByName, acl);
921
+ _dispatchToTeamMember(member, _boundProj, trimmedText, liveCfg, bot, pipelineChatId, executeTaskByName, acl);
871
922
  return;
872
923
  }
873
924
  // agentKey set but not a current team member → fall through to main
@@ -876,7 +927,7 @@ function createBridgeStarter(deps) {
876
927
  // Cases b & c: no agentKey (main agent) or stale/unknown agentKey
877
928
  _clearSticky();
878
929
  log('INFO', `Quoted reply → route to main (agentKey=${_replyAgentKey} mappingFound=${_replyMappingFound})`);
879
- await pipeline.processMessage(chatId, trimmedText, { bot, config: liveCfg, executeTaskByName, senderId: acl.senderId, readOnly: acl.readOnly });
930
+ await pipeline.processMessage(pipelineChatId, trimmedText, { bot, config: liveCfg, executeTaskByName, senderId: acl.senderId, readOnly: acl.readOnly });
880
931
  return;
881
932
  }
882
933
  // 1. Explicit nickname → route + set sticky
@@ -887,10 +938,13 @@ function createBridgeStarter(deps) {
887
938
  if (!rest) {
888
939
  // Pure nickname, no task — confirm member is online
889
940
  log('INFO', `Sticky set (pure nickname): ${_chatKey.slice(-8)} → ${member.key}`);
890
- bot.sendMarkdown(chatId, `${member.icon || '🤖'} **${member.name}** 在线`)
941
+ bot.sendMarkdown(pipelineChatId, `${member.icon || '🤖'} **${member.name}** 在线`)
891
942
  .then((msg) => {
892
943
  if (msg && msg.message_id) {
893
- trackBridgeReplyMapping(msg.message_id, inferSessionMapping(`_agent_${member.key}`, {
944
+ const _vidNick = isThreadChatId(String(pipelineChatId))
945
+ ? `_agent_${member.key}::${pipelineChatId}`
946
+ : `_agent_${member.key}`;
947
+ trackBridgeReplyMapping(msg.message_id, inferSessionMapping(_vidNick, {
894
948
  agentKey: member.key,
895
949
  cwd: member.cwd || _boundProj.cwd,
896
950
  engine: member.engine || _boundProj.engine || 'claude',
@@ -901,7 +955,7 @@ function createBridgeStarter(deps) {
901
955
  return;
902
956
  }
903
957
  log('INFO', `Sticky set: ${_chatKey.slice(-8)} → ${member.key}`);
904
- _dispatchToTeamMember(member, _boundProj, rest, liveCfg, bot, chatId, executeTaskByName, acl);
958
+ _dispatchToTeamMember(member, _boundProj, rest, liveCfg, bot, pipelineChatId, executeTaskByName, acl);
905
959
  return;
906
960
  }
907
961
 
@@ -914,7 +968,7 @@ function createBridgeStarter(deps) {
914
968
  const rest = trimmedText.slice(_mainMatch.length).replace(/^[\s,,::]+/, '');
915
969
  log('INFO', `Main nickname → cleared sticky, routing to main${rest ? ` (task: ${rest.slice(0, 30)})` : ''}`);
916
970
  if (!rest) {
917
- bot.sendMarkdown(chatId, `${_boundProj.icon || '🤖'} **${_boundProj.name}** 在线`)
971
+ bot.sendMarkdown(pipelineChatId, `${_boundProj.icon || '🤖'} **${_boundProj.name}** 在线`)
918
972
  .then((msg) => {
919
973
  if (msg && msg.message_id) {
920
974
  trackBridgeReplyMapping(msg.message_id, inferSessionMapping(String(chatId), {
@@ -929,10 +983,10 @@ function createBridgeStarter(deps) {
929
983
  return;
930
984
  }
931
985
  try {
932
- await pipeline.processMessage(chatId, rest, { bot, config: liveCfg, executeTaskByName, senderId: acl.senderId, readOnly: acl.readOnly });
986
+ await pipeline.processMessage(pipelineChatId, rest, { bot, config: liveCfg, executeTaskByName, senderId: acl.senderId, readOnly: acl.readOnly });
933
987
  } catch (e) {
934
988
  log('ERROR', `Team main-route handleCommand failed: ${e.message}`);
935
- bot.sendMessage(chatId, `❌ 执行失败: ${e.message}`).catch(() => {});
989
+ bot.sendMessage(pipelineChatId, `❌ 执行失败: ${e.message}`).catch(() => {});
936
990
  }
937
991
  return;
938
992
  }
@@ -942,7 +996,7 @@ function createBridgeStarter(deps) {
942
996
  const member = _boundProj.team.find(m => m.key === _stickyKey);
943
997
  if (member) {
944
998
  log('INFO', `Sticky route: → ${_stickyKey}`);
945
- _dispatchToTeamMember(member, _boundProj, trimmedText, liveCfg, bot, chatId, executeTaskByName, acl);
999
+ _dispatchToTeamMember(member, _boundProj, trimmedText, liveCfg, bot, pipelineChatId, executeTaskByName, acl);
946
1000
  return;
947
1001
  }
948
1002
  }
@@ -950,10 +1004,10 @@ function createBridgeStarter(deps) {
950
1004
  }
951
1005
 
952
1006
  try {
953
- await pipeline.processMessage(chatId, text, { bot, config: liveCfg, executeTaskByName, senderId: acl.senderId, readOnly: acl.readOnly });
1007
+ await pipeline.processMessage(pipelineChatId, text, { bot, config: liveCfg, executeTaskByName, senderId: acl.senderId, readOnly: acl.readOnly });
954
1008
  } catch (e) {
955
1009
  log('ERROR', `Feishu handleCommand failed for ${chatId}: ${e.message}`);
956
- bot.sendMessage(chatId, `❌ 命令执行失败: ${e.message}`).catch(() => {});
1010
+ bot.sendMessage(pipelineChatId, `❌ 命令执行失败: ${e.message}`).catch(() => {});
957
1011
  }
958
1012
  }
959
1013
  }, { log: (lvl, msg) => log(lvl, msg) });