| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235 |
- #!/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);
- });
|