metame-cli 1.5.19 → 1.5.20

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 +79 -35
  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, 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
 
@@ -282,9 +313,11 @@ function createBridgeStarter(deps) {
282
313
  const virtualChatId = `_agent_${member.key}`;
283
314
  const parentCwd = member.cwd || boundProj.cwd;
284
315
  const resolvedParentCwd = parentCwd.replace(/^~/, require('os').homedir());
285
- const memberCwd = typeof getOrCreateWorktree === 'function'
286
- ? getOrCreateWorktree(resolvedParentCwd, member.key)
287
- : _getMemberCwd(resolvedParentCwd, member.key);
316
+ const memberCwd = _getMemberCwd(
317
+ resolvedParentCwd,
318
+ member.key,
319
+ member.cwd || null,
320
+ );
288
321
  if (!memberCwd) {
289
322
  log('ERROR', `Team [${member.key}] cannot start: directory unavailable`);
290
323
  bot.sendMessage(realChatId, `❌ ${member.icon || '🤖'} ${member.name} 启动失败:工作目录创建失败`).catch(() => {});
@@ -710,6 +743,13 @@ function createBridgeStarter(deps) {
710
743
  return;
711
744
  }
712
745
 
746
+ // ── Topic mode detection (before file/text split) ──
747
+ const threadRootId = extractFeishuThreadRootId(event);
748
+ const pipelineChatId = threadRootId ? buildThreadChatId(chatId, threadRootId) : chatId;
749
+ if (threadRootId) {
750
+ log('INFO', `Feishu topic detected: root=${threadRootId} → pipelineChatId=${pipelineChatId}`);
751
+ }
752
+
713
753
  if (fileInfo && fileInfo.fileKey) {
714
754
  const acl = await applyUserAcl({
715
755
  bot,
@@ -721,7 +761,7 @@ function createBridgeStarter(deps) {
721
761
  });
722
762
  if (acl.blocked) return;
723
763
  log('INFO', `Feishu file from ${chatId}: ${fileInfo.fileName} (key: ${fileInfo.fileKey}, msgId: ${fileInfo.messageId}, type: ${fileInfo.msgType})`);
724
- const session = getSession(chatId);
764
+ const session = getSession(pipelineChatId) || getSession(chatId);
725
765
  const cwd = session?.cwd || HOME;
726
766
  const uploadDir = path.join(cwd, 'upload');
727
767
  if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
@@ -729,7 +769,7 @@ function createBridgeStarter(deps) {
729
769
 
730
770
  try {
731
771
  await bot.downloadFile(fileInfo.messageId, fileInfo.fileKey, destPath, fileInfo.msgType);
732
- await bot.sendMessage(chatId, `📥 Saved: ${fileInfo.fileName}`);
772
+ await bot.sendMessage(pipelineChatId, `📥 Saved: ${fileInfo.fileName}`);
733
773
 
734
774
  const prompt = text
735
775
  ? `User uploaded a file to the project: ${destPath}\nUser says: "${text}"`
@@ -737,21 +777,21 @@ function createBridgeStarter(deps) {
737
777
 
738
778
  // Respect team_sticky: route to active agent same as text messages
739
779
  const _stFile = loadState();
740
- const _chatKeyFile = String(chatId);
780
+ const _chatKeyFile = String(pipelineChatId);
741
781
  const { project: _boundProjFile } = _getBoundProject(chatId, liveCfg);
742
782
  const _stickyKeyFile = (_stFile.team_sticky || {})[_chatKeyFile];
743
783
  if (_boundProjFile && Array.isArray(_boundProjFile.team) && _boundProjFile.team.length > 0 && _stickyKeyFile) {
744
784
  const _stickyMember = _boundProjFile.team.find(m => m.key === _stickyKeyFile);
745
785
  if (_stickyMember) {
746
786
  log('INFO', `Feishu file → sticky route to ${_stickyKeyFile}`);
747
- _dispatchToTeamMember(_stickyMember, _boundProjFile, prompt, liveCfg, bot, chatId, executeTaskByName, acl);
787
+ _dispatchToTeamMember(_stickyMember, _boundProjFile, prompt, liveCfg, bot, pipelineChatId, executeTaskByName, acl);
748
788
  return;
749
789
  }
750
790
  }
751
- await pipeline.processMessage(chatId, prompt, { bot, config: liveCfg, executeTaskByName, senderId: acl.senderId, readOnly: acl.readOnly });
791
+ await pipeline.processMessage(pipelineChatId, prompt, { bot, config: liveCfg, executeTaskByName, senderId: acl.senderId, readOnly: acl.readOnly });
752
792
  } catch (err) {
753
793
  log('ERROR', `Feishu file download failed: ${err.message}`);
754
- await bot.sendMessage(chatId, `❌ Download failed: ${err.message}`);
794
+ await bot.sendMessage(pipelineChatId, `❌ Download failed: ${err.message}`);
755
795
  }
756
796
  return;
757
797
  }
@@ -772,10 +812,13 @@ function createBridgeStarter(deps) {
772
812
  let _replyMappingFound = false; // true = mapping exists (agentKey may be null = main)
773
813
  // Load state once for the entire routing block
774
814
  const _st = loadState();
775
- if (parentId) {
815
+ // Quoted reply = explicit parentId but NOT a topic thread (topics always carry parentId=root_id)
816
+ const _isQuotedReply = !!(parentId && !threadRootId);
817
+ if (_isQuotedReply) {
776
818
  log('INFO', `Feishu reply metadata detected chat=${chatId} parentId=${parentId}`);
777
819
  }
778
- if (parentId) {
820
+ // In topic mode, session continuity is handled by pipelineChatId — skip msg_sessions lookup
821
+ if (_isQuotedReply) {
779
822
  const mapped = _st.msg_sessions && _st.msg_sessions[parentId];
780
823
  if (mapped) {
781
824
  _replyMappingFound = true;
@@ -798,7 +841,8 @@ function createBridgeStarter(deps) {
798
841
  }
799
842
 
800
843
  // Helper: set/clear sticky on shared state object and persist
801
- const _chatKey = String(chatId);
844
+ // Use pipelineChatId so each topic gets independent sticky state
845
+ const _chatKey = String(pipelineChatId);
802
846
  const _setSticky = (key) => {
803
847
  if (!_st.team_sticky) _st.team_sticky = {};
804
848
  _st.team_sticky[_chatKey] = key;
@@ -842,15 +886,15 @@ function createBridgeStarter(deps) {
842
886
  pipeline.clearQueue(vid);
843
887
  const stopped = pipeline.interruptActive(vid);
844
888
  if (stopped) {
845
- await bot.sendMessage(chatId, `⏹ Stopping ${label}...`);
889
+ await bot.sendMessage(pipelineChatId, `⏹ Stopping ${label}...`);
846
890
  } else {
847
- await bot.sendMessage(chatId, `${label} 当前没有活跃任务`);
891
+ await bot.sendMessage(pipelineChatId, `${label} 当前没有活跃任务`);
848
892
  }
849
893
  return;
850
894
  }
851
895
  // /stop <bad-nickname> → no match, report error instead of falling through
852
896
  if (_stopArg) {
853
- await bot.sendMessage(chatId, `❌ 未找到团队成员: ${_stopArg}`);
897
+ await bot.sendMessage(pipelineChatId, `❌ 未找到团队成员: ${_stopArg}`);
854
898
  return;
855
899
  }
856
900
  // Bare /stop, no sticky set → fall through to handleCommand
@@ -861,13 +905,13 @@ function createBridgeStarter(deps) {
861
905
  // a) agentKey = known team member → route to that member (set sticky)
862
906
  // b) agentKey = null, mapping found → user replied to main; clear sticky, route to main
863
907
  // c) parentId present, no mapping → intent is explicit, avoid sticky; clear sticky, route to main
864
- if (parentId) {
908
+ if (_isQuotedReply) {
865
909
  if (_replyAgentKey) {
866
910
  const member = _boundProj.team.find(m => m.key === _replyAgentKey);
867
911
  if (member) {
868
912
  _setSticky(member.key);
869
913
  log('INFO', `Quoted reply → force route to ${_replyAgentKey} (sticky set)`);
870
- _dispatchToTeamMember(member, _boundProj, trimmedText, liveCfg, bot, chatId, executeTaskByName, acl);
914
+ _dispatchToTeamMember(member, _boundProj, trimmedText, liveCfg, bot, pipelineChatId, executeTaskByName, acl);
871
915
  return;
872
916
  }
873
917
  // agentKey set but not a current team member → fall through to main
@@ -876,7 +920,7 @@ function createBridgeStarter(deps) {
876
920
  // Cases b & c: no agentKey (main agent) or stale/unknown agentKey
877
921
  _clearSticky();
878
922
  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 });
923
+ await pipeline.processMessage(pipelineChatId, trimmedText, { bot, config: liveCfg, executeTaskByName, senderId: acl.senderId, readOnly: acl.readOnly });
880
924
  return;
881
925
  }
882
926
  // 1. Explicit nickname → route + set sticky
@@ -887,7 +931,7 @@ function createBridgeStarter(deps) {
887
931
  if (!rest) {
888
932
  // Pure nickname, no task — confirm member is online
889
933
  log('INFO', `Sticky set (pure nickname): ${_chatKey.slice(-8)} → ${member.key}`);
890
- bot.sendMarkdown(chatId, `${member.icon || '🤖'} **${member.name}** 在线`)
934
+ bot.sendMarkdown(pipelineChatId, `${member.icon || '🤖'} **${member.name}** 在线`)
891
935
  .then((msg) => {
892
936
  if (msg && msg.message_id) {
893
937
  trackBridgeReplyMapping(msg.message_id, inferSessionMapping(`_agent_${member.key}`, {
@@ -901,7 +945,7 @@ function createBridgeStarter(deps) {
901
945
  return;
902
946
  }
903
947
  log('INFO', `Sticky set: ${_chatKey.slice(-8)} → ${member.key}`);
904
- _dispatchToTeamMember(member, _boundProj, rest, liveCfg, bot, chatId, executeTaskByName, acl);
948
+ _dispatchToTeamMember(member, _boundProj, rest, liveCfg, bot, pipelineChatId, executeTaskByName, acl);
905
949
  return;
906
950
  }
907
951
 
@@ -914,7 +958,7 @@ function createBridgeStarter(deps) {
914
958
  const rest = trimmedText.slice(_mainMatch.length).replace(/^[\s,,::]+/, '');
915
959
  log('INFO', `Main nickname → cleared sticky, routing to main${rest ? ` (task: ${rest.slice(0, 30)})` : ''}`);
916
960
  if (!rest) {
917
- bot.sendMarkdown(chatId, `${_boundProj.icon || '🤖'} **${_boundProj.name}** 在线`)
961
+ bot.sendMarkdown(pipelineChatId, `${_boundProj.icon || '🤖'} **${_boundProj.name}** 在线`)
918
962
  .then((msg) => {
919
963
  if (msg && msg.message_id) {
920
964
  trackBridgeReplyMapping(msg.message_id, inferSessionMapping(String(chatId), {
@@ -929,10 +973,10 @@ function createBridgeStarter(deps) {
929
973
  return;
930
974
  }
931
975
  try {
932
- await pipeline.processMessage(chatId, rest, { bot, config: liveCfg, executeTaskByName, senderId: acl.senderId, readOnly: acl.readOnly });
976
+ await pipeline.processMessage(pipelineChatId, rest, { bot, config: liveCfg, executeTaskByName, senderId: acl.senderId, readOnly: acl.readOnly });
933
977
  } catch (e) {
934
978
  log('ERROR', `Team main-route handleCommand failed: ${e.message}`);
935
- bot.sendMessage(chatId, `❌ 执行失败: ${e.message}`).catch(() => {});
979
+ bot.sendMessage(pipelineChatId, `❌ 执行失败: ${e.message}`).catch(() => {});
936
980
  }
937
981
  return;
938
982
  }
@@ -942,7 +986,7 @@ function createBridgeStarter(deps) {
942
986
  const member = _boundProj.team.find(m => m.key === _stickyKey);
943
987
  if (member) {
944
988
  log('INFO', `Sticky route: → ${_stickyKey}`);
945
- _dispatchToTeamMember(member, _boundProj, trimmedText, liveCfg, bot, chatId, executeTaskByName, acl);
989
+ _dispatchToTeamMember(member, _boundProj, trimmedText, liveCfg, bot, pipelineChatId, executeTaskByName, acl);
946
990
  return;
947
991
  }
948
992
  }
@@ -950,10 +994,10 @@ function createBridgeStarter(deps) {
950
994
  }
951
995
 
952
996
  try {
953
- await pipeline.processMessage(chatId, text, { bot, config: liveCfg, executeTaskByName, senderId: acl.senderId, readOnly: acl.readOnly });
997
+ await pipeline.processMessage(pipelineChatId, text, { bot, config: liveCfg, executeTaskByName, senderId: acl.senderId, readOnly: acl.readOnly });
954
998
  } catch (e) {
955
999
  log('ERROR', `Feishu handleCommand failed for ${chatId}: ${e.message}`);
956
- bot.sendMessage(chatId, `❌ 命令执行失败: ${e.message}`).catch(() => {});
1000
+ bot.sendMessage(pipelineChatId, `❌ 命令执行失败: ${e.message}`).catch(() => {});
957
1001
  }
958
1002
  }
959
1003
  }, { log: (lvl, msg) => log(lvl, msg) });