#!/usr/bin/env node /** * 向容器提问并收集完整回答(取证用) * * 用法: * node scripts/ask-agent.mjs --question-file docs/xxx问题清单.md * node scripts/ask-agent.mjs --question-file q.md --agent agent-node-lj-test * node scripts/ask-agent.mjs --question-file q.md --out docs/xxx-问答原文.md * * ## 与 ask-ljtest.mjs 的区别(2026-11-10 重新设计) * * ask-ljtest.mjs 有两个硬伤,导致拿不到详细答案: * ① 问题写死在代码里,不能传 * ② 收到**第一条正文就停** —— 容器会把长回答拆成多条消息, * 中间还夹 💻 工具过程行,所以它只能读到第一段 * * 本脚本: * · 从文件读问题(整份清单一次发,编号 Q1/Q2/…) * · **累积全部容器消息**,工具过程行只标记不终止 * · 结束条件:收到 ✅ reaction(容器自述的完成信号)或静默 N 秒,取先到 * · 完整问答原文落盘 * * ## 为什么不能"收到第一条正文就停" * * 实测(docs/00-tracking/2026-11-09-修改留痕-五项缺陷修复第二轮.md:18-30): * 一轮 9 条事件 = 5 段正文 + 4 段过程,正文被过程反复打断。 * 且容器自述"一轮结束在 Matrix 里没有信号,只有 status=idle,延迟 1~3 秒"。 */ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; const FUNCTIONS = 'https://server.fmode.cn/api/functions'; const FN_PROVISION = 'RP5Ht55NeX'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** 工具过程行的识别(与 verify-upload.mjs:160 同口径) */ const TOOL_LINE = /^[*·•]?\s*(🐍|💻|🔎|📖|✍️|🧠|💾|🔧)/; function arg(name, fallback) { const i = process.argv.indexOf(`--${name}`); return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback; } const AGENT = arg('agent', 'agent-node-lj-test'); const QUESTION_FILE = arg('question-file', null); /** 静默多少毫秒算答完。容器一轮结束无信号,返回 0 我们也不指望它;只能靠静默。 */ const SILENCE_MS = Number(arg('silence', '120000')); /** 总超时:底盘,防止无限等 */ const MAX_MS = Number(arg('max', '1800000')); const OUT = arg('out', null); function loadToken() { const p = join(homedir(), '.fmode', 'config', 'user.json'); const t = JSON.parse(readFileSync(p, 'utf8'))?.sessionToken; if (typeof t !== 'string' || !t.startsWith('r:')) throw new Error(`读不到会话凭据:${p}`); return t; } const TOKEN = loadToken(); async function provision(agentId) { const r = await fetch(FUNCTIONS, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': 'ncloudmaster' }, body: JSON.stringify({ id: FN_PROVISION, token: TOKEN, params: { agentId } }), }); const j = await r.json(); const d = j.result ?? j; if (!d?.accessToken) throw new Error(`provision 失败:${JSON.stringify(j).slice(0, 200)}`); return d; } async function main() { if (!QUESTION_FILE) { console.error('用法:node scripts/ask-agent.mjs --question-file <路径> [--agent id] [--out 路径]'); process.exit(2); } const QUESTION = readFileSync(QUESTION_FILE, 'utf8').trim(); if (!QUESTION) throw new Error('问题文件为空'); console.log(`\n=== 向容器取证 ===`); console.log(`容器 : ${AGENT}`); console.log(`问题文件: ${QUESTION_FILE}(${QUESTION.length} 字)`); console.log(`静默阈值: ${SILENCE_MS / 1000}s\n`); const prov = await provision(AGENT); const agent = await provision(AGENT); console.log('用户:', prov.userMxId, '| 容器:', agent.agentMxId); const mx = await import('matrix-js-sdk'); const client = mx.createClient({ baseUrl: prov.homeserver, accessToken: prov.accessToken, userId: prov.userMxId, }); const room = ( await client.createRoom({ invite: [agent.agentMxId], name: `ask-${Date.now()}`, preset: mx.Preset.TrustedPrivateChat, }) ).room_id; console.log('房间:', room); const hs = prov.homeserver.replace(/\/$/, ''); const auth = { Authorization: `Bearer ${prov.accessToken}` }; let joined = false; for (let i = 0; i < 30; i++) { await sleep(3000); const r = await fetch( `${hs}/_matrix/client/v3/rooms/${encodeURIComponent(room)}/joined_members`, { headers: auth }, ).catch(() => null); const j = r ? await r.json().catch(() => ({})) : {}; if ((j.joined ?? {})[agent.agentMxId]) { joined = true; console.log('容器已进房'); break; } } if (!joined) throw new Error('容器 90 秒内未进房'); const sent = await client.sendEvent(room, 'm.room.message', { msgtype: 'm.text', body: QUESTION, 'm.mentions': { user_ids: [agent.agentMxId] }, }); console.log('问题已发送:', sent.event_id, '\n'); /* ---- 收集 ---- */ const seen = new Set([sent.event_id]); const events = []; // { ts, body, kind } let lastActivity = Date.now(); let acked = false; const deadline = Date.now() + MAX_MS; while (Date.now() < deadline) { await sleep(5000); try { const r = await fetch( `${hs}/_matrix/client/v3/rooms/${encodeURIComponent(room)}/messages?dir=b&limit=100`, { headers: auth }, ); const j = await r.json(); const fresh = []; for (const ev of (j.chunk ?? []).reverse()) { if (seen.has(ev.event_id)) continue; seen.add(ev.event_id); /* ✅ reaction 是容器自述的"完成"信号(直连手册 :62) */ if (ev.type === 'm.reaction') { const rel = ev.content?.['m.relates_to']; if (rel?.event_id === sent.event_id && String(rel.key ?? '').includes('✅')) { acked = true; fresh.push({ ts: Date.now(), kind: 'ack', body: '✅ 容器已标记完成' }); } continue; } if (ev.type !== 'm.room.message' || ev.sender === prov.userMxId) continue; const body = String(ev.content?.body ?? ''); const kind = TOOL_LINE.test(body.trim()) ? 'tool' : 'prose'; const media = ev.content?.msgtype === 'm.file' || ev.content?.msgtype === 'm.image'; fresh.push({ ts: Date.now(), kind: media ? 'file' : kind, body }); } if (fresh.length > 0) { for (const f of fresh) { events.push(f); const tag = f.kind === 'tool' ? '工具' : f.kind === 'file' ? '文件' : f.kind === 'ack' ? '完成' : '正文'; console.log(`[${tag}] ${f.body.slice(0, 120).replace(/\n/g, ' ')}${f.body.length > 120 ? '…' : ''}`); } lastActivity = Date.now(); } } catch { /* 轮询抖动忽略 */ } if (acked) { console.log('\n收到 ✅,容器标记完成'); break; } if (Date.now() - lastActivity >= SILENCE_MS) { console.log(`\n静默 ${SILENCE_MS / 1000}s,判定答完`); break; } } /* ---- 落盘 ---- */ const prose = events.filter((e) => e.kind === 'prose'); const report = [ `# 容器取证问答:${AGENT}`, ``, `- 时间:${new Date().toISOString()}`, `- 房间:\`${room}\`(临时房间,可保留查阅)`, `- 消息统计:正文 ${prose.length} 段 / 工具过程 ${events.filter((e) => e.kind === 'tool').length} 段 / 文件 ${events.filter((e) => e.kind === 'file').length} 个`, `- 结束原因:${acked ? '收到 ✅ reaction' : '静默超时'}`, ``, `## 我发出的问题`, ``, '```text', QUESTION, '```', ``, `## 容器回答(原文,按时间顺序,未删改)`, ``, ...events .filter((e) => e.kind !== 'tool') .map((e, i) => `### ${i + 1}【${e.kind}】\n\n\`\`\`text\n${e.body}\n\`\`\`\n`), `---`, ``, `## 工具过程(仅供核对,非回答内容)`, ``, ...(events.filter((e) => e.kind === 'tool').length ? events.filter((e) => e.kind === 'tool').map((e) => `- \`${e.body.slice(0, 200).replace(/\n/g, ' ')}\``) : ['(无)']), ].join('\n'); const outPath = OUT ?? `docs/03-interface/容器取证-${AGENT}-${Date.now()}.md`; mkdirSync(dirname(outPath), { recursive: true }); writeFileSync(outPath, report, 'utf8'); console.log(`\n=== 汇总 ===`); console.log(`正文段数: ${prose.length}`); console.log(`落盘 : ${outPath}`); console.log(`房间保留: ${room}`); client.stopClient(); process.exit(prose.length > 0 ? 0 : 1); } main().catch((e) => { console.error('失败:', e instanceof Error ? e.message : e); process.exit(2); });