ask-agent.mjs 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. #!/usr/bin/env node
  2. /**
  3. * 向容器提问并收集完整回答(取证用)
  4. *
  5. * 用法:
  6. * node scripts/ask-agent.mjs --question-file docs/xxx问题清单.md
  7. * node scripts/ask-agent.mjs --question-file q.md --agent agent-node-lj-test
  8. * node scripts/ask-agent.mjs --question-file q.md --out docs/xxx-问答原文.md
  9. *
  10. * ## 与 ask-ljtest.mjs 的区别(2026-11-10 重新设计)
  11. *
  12. * ask-ljtest.mjs 有两个硬伤,导致拿不到详细答案:
  13. * ① 问题写死在代码里,不能传
  14. * ② 收到**第一条正文就停** —— 容器会把长回答拆成多条消息,
  15. * 中间还夹 💻 工具过程行,所以它只能读到第一段
  16. *
  17. * 本脚本:
  18. * · 从文件读问题(整份清单一次发,编号 Q1/Q2/…)
  19. * · **累积全部容器消息**,工具过程行只标记不终止
  20. * · 结束条件:收到 ✅ reaction(容器自述的完成信号)或静默 N 秒,取先到
  21. * · 完整问答原文落盘
  22. *
  23. * ## 为什么不能"收到第一条正文就停"
  24. *
  25. * 实测(docs/00-tracking/2026-11-09-修改留痕-五项缺陷修复第二轮.md:18-30):
  26. * 一轮 9 条事件 = 5 段正文 + 4 段过程,正文被过程反复打断。
  27. * 且容器自述"一轮结束在 Matrix 里没有信号,只有 status=idle,延迟 1~3 秒"。
  28. */
  29. import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
  30. import { homedir } from 'node:os';
  31. import { dirname, join } from 'node:path';
  32. const FUNCTIONS = 'https://server.fmode.cn/api/functions';
  33. const FN_PROVISION = 'RP5Ht55NeX';
  34. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  35. /** 工具过程行的识别(与 verify-upload.mjs:160 同口径) */
  36. const TOOL_LINE = /^[*·•]?\s*(🐍|💻|🔎|📖|✍️|🧠|💾|🔧)/;
  37. function arg(name, fallback) {
  38. const i = process.argv.indexOf(`--${name}`);
  39. return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
  40. }
  41. const AGENT = arg('agent', 'agent-node-lj-test');
  42. const QUESTION_FILE = arg('question-file', null);
  43. /** 静默多少毫秒算答完。容器一轮结束无信号,返回 0 我们也不指望它;只能靠静默。 */
  44. const SILENCE_MS = Number(arg('silence', '120000'));
  45. /** 总超时:底盘,防止无限等 */
  46. const MAX_MS = Number(arg('max', '1800000'));
  47. const OUT = arg('out', null);
  48. function loadToken() {
  49. const p = join(homedir(), '.fmode', 'config', 'user.json');
  50. const t = JSON.parse(readFileSync(p, 'utf8'))?.sessionToken;
  51. if (typeof t !== 'string' || !t.startsWith('r:')) throw new Error(`读不到会话凭据:${p}`);
  52. return t;
  53. }
  54. const TOKEN = loadToken();
  55. async function provision(agentId) {
  56. const r = await fetch(FUNCTIONS, {
  57. method: 'POST',
  58. headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': 'ncloudmaster' },
  59. body: JSON.stringify({ id: FN_PROVISION, token: TOKEN, params: { agentId } }),
  60. });
  61. const j = await r.json();
  62. const d = j.result ?? j;
  63. if (!d?.accessToken) throw new Error(`provision 失败:${JSON.stringify(j).slice(0, 200)}`);
  64. return d;
  65. }
  66. async function main() {
  67. if (!QUESTION_FILE) {
  68. console.error('用法:node scripts/ask-agent.mjs --question-file <路径> [--agent id] [--out 路径]');
  69. process.exit(2);
  70. }
  71. const QUESTION = readFileSync(QUESTION_FILE, 'utf8').trim();
  72. if (!QUESTION) throw new Error('问题文件为空');
  73. console.log(`\n=== 向容器取证 ===`);
  74. console.log(`容器 : ${AGENT}`);
  75. console.log(`问题文件: ${QUESTION_FILE}(${QUESTION.length} 字)`);
  76. console.log(`静默阈值: ${SILENCE_MS / 1000}s\n`);
  77. const prov = await provision(AGENT);
  78. const agent = await provision(AGENT);
  79. console.log('用户:', prov.userMxId, '| 容器:', agent.agentMxId);
  80. const mx = await import('matrix-js-sdk');
  81. const client = mx.createClient({
  82. baseUrl: prov.homeserver,
  83. accessToken: prov.accessToken,
  84. userId: prov.userMxId,
  85. });
  86. const room = (
  87. await client.createRoom({
  88. invite: [agent.agentMxId],
  89. name: `ask-${Date.now()}`,
  90. preset: mx.Preset.TrustedPrivateChat,
  91. })
  92. ).room_id;
  93. console.log('房间:', room);
  94. const hs = prov.homeserver.replace(/\/$/, '');
  95. const auth = { Authorization: `Bearer ${prov.accessToken}` };
  96. let joined = false;
  97. for (let i = 0; i < 30; i++) {
  98. await sleep(3000);
  99. const r = await fetch(
  100. `${hs}/_matrix/client/v3/rooms/${encodeURIComponent(room)}/joined_members`,
  101. { headers: auth },
  102. ).catch(() => null);
  103. const j = r ? await r.json().catch(() => ({})) : {};
  104. if ((j.joined ?? {})[agent.agentMxId]) {
  105. joined = true;
  106. console.log('容器已进房');
  107. break;
  108. }
  109. }
  110. if (!joined) throw new Error('容器 90 秒内未进房');
  111. const sent = await client.sendEvent(room, 'm.room.message', {
  112. msgtype: 'm.text',
  113. body: QUESTION,
  114. 'm.mentions': { user_ids: [agent.agentMxId] },
  115. });
  116. console.log('问题已发送:', sent.event_id, '\n');
  117. /* ---- 收集 ---- */
  118. const seen = new Set([sent.event_id]);
  119. const events = []; // { ts, body, kind }
  120. let lastActivity = Date.now();
  121. let acked = false;
  122. const deadline = Date.now() + MAX_MS;
  123. while (Date.now() < deadline) {
  124. await sleep(5000);
  125. try {
  126. const r = await fetch(
  127. `${hs}/_matrix/client/v3/rooms/${encodeURIComponent(room)}/messages?dir=b&limit=100`,
  128. { headers: auth },
  129. );
  130. const j = await r.json();
  131. const fresh = [];
  132. for (const ev of (j.chunk ?? []).reverse()) {
  133. if (seen.has(ev.event_id)) continue;
  134. seen.add(ev.event_id);
  135. /* ✅ reaction 是容器自述的"完成"信号(直连手册 :62) */
  136. if (ev.type === 'm.reaction') {
  137. const rel = ev.content?.['m.relates_to'];
  138. if (rel?.event_id === sent.event_id && String(rel.key ?? '').includes('✅')) {
  139. acked = true;
  140. fresh.push({ ts: Date.now(), kind: 'ack', body: '✅ 容器已标记完成' });
  141. }
  142. continue;
  143. }
  144. if (ev.type !== 'm.room.message' || ev.sender === prov.userMxId) continue;
  145. const body = String(ev.content?.body ?? '');
  146. const kind = TOOL_LINE.test(body.trim()) ? 'tool' : 'prose';
  147. const media = ev.content?.msgtype === 'm.file' || ev.content?.msgtype === 'm.image';
  148. fresh.push({ ts: Date.now(), kind: media ? 'file' : kind, body });
  149. }
  150. if (fresh.length > 0) {
  151. for (const f of fresh) {
  152. events.push(f);
  153. const tag = f.kind === 'tool' ? '工具' : f.kind === 'file' ? '文件' : f.kind === 'ack' ? '完成' : '正文';
  154. console.log(`[${tag}] ${f.body.slice(0, 120).replace(/\n/g, ' ')}${f.body.length > 120 ? '…' : ''}`);
  155. }
  156. lastActivity = Date.now();
  157. }
  158. } catch {
  159. /* 轮询抖动忽略 */
  160. }
  161. if (acked) {
  162. console.log('\n收到 ✅,容器标记完成');
  163. break;
  164. }
  165. if (Date.now() - lastActivity >= SILENCE_MS) {
  166. console.log(`\n静默 ${SILENCE_MS / 1000}s,判定答完`);
  167. break;
  168. }
  169. }
  170. /* ---- 落盘 ---- */
  171. const prose = events.filter((e) => e.kind === 'prose');
  172. const report = [
  173. `# 容器取证问答:${AGENT}`,
  174. ``,
  175. `- 时间:${new Date().toISOString()}`,
  176. `- 房间:\`${room}\`(临时房间,可保留查阅)`,
  177. `- 消息统计:正文 ${prose.length} 段 / 工具过程 ${events.filter((e) => e.kind === 'tool').length} 段 / 文件 ${events.filter((e) => e.kind === 'file').length} 个`,
  178. `- 结束原因:${acked ? '收到 ✅ reaction' : '静默超时'}`,
  179. ``,
  180. `## 我发出的问题`,
  181. ``,
  182. '```text',
  183. QUESTION,
  184. '```',
  185. ``,
  186. `## 容器回答(原文,按时间顺序,未删改)`,
  187. ``,
  188. ...events
  189. .filter((e) => e.kind !== 'tool')
  190. .map((e, i) => `### ${i + 1}【${e.kind}】\n\n\`\`\`text\n${e.body}\n\`\`\`\n`),
  191. `---`,
  192. ``,
  193. `## 工具过程(仅供核对,非回答内容)`,
  194. ``,
  195. ...(events.filter((e) => e.kind === 'tool').length
  196. ? events.filter((e) => e.kind === 'tool').map((e) => `- \`${e.body.slice(0, 200).replace(/\n/g, ' ')}\``)
  197. : ['(无)']),
  198. ].join('\n');
  199. const outPath = OUT ?? `docs/03-interface/容器取证-${AGENT}-${Date.now()}.md`;
  200. mkdirSync(dirname(outPath), { recursive: true });
  201. writeFileSync(outPath, report, 'utf8');
  202. console.log(`\n=== 汇总 ===`);
  203. console.log(`正文段数: ${prose.length}`);
  204. console.log(`落盘 : ${outPath}`);
  205. console.log(`房间保留: ${room}`);
  206. client.stopClient();
  207. process.exit(prose.length > 0 ? 0 : 1);
  208. }
  209. main().catch((e) => {
  210. console.error('失败:', e instanceof Error ? e.message : e);
  211. process.exit(2);
  212. });