diff --git a/scripts/tail-agent.js b/scripts/tail-agent.js new file mode 100644 index 0000000..4eab399 --- /dev/null +++ b/scripts/tail-agent.js @@ -0,0 +1,139 @@ +#!/usr/bin/env node +/** + * 跟看某个 company agent 的 LLM 实时输出。 + * + * 为什么需要它:控制台的「运行日志」读的是 `stream.log` / `timeline.ndjson`, + * 那里只有结构化事件(node.status / agent.call / node.output),而且事件里的自由文本 + * 被刻意截断到 600 字(graph-engine.js 的 EVENT_TEXT_MAX,注释里写了三条理由)。 + * 引擎更是**根本不消费**流式 chunk —— onAcpEvent 只取 session/result 的 usage。 + * 所以 LLM 的逐字输出不在引擎的任何轨道上。 + * + * 但 Claude CLI 自己把完整会话写在 + * ~/.claude/projects/<把 cwd 路径里的 / 换成 - >/.jsonl + * agent 的 cwd 是 `data/companies/<公司>/agents/<角色>`,所以目录名可推。 + * + * 用法: + * node scripts/tail-agent.js <公司> <角色> # 跟看最新会话(-f) + * node scripts/tail-agent.js <公司> <角色> --last 40 # 只看最后 40 条,不跟 + * node scripts/tail-agent.js <公司> # 列出这家公司有哪些角色有 transcript + * + * ponytail: 目录名按「绝对路径 / → -」推导,这是 Claude CLI 现行的命名规则; + * 规则变了这个脚本会找不到目录(会明说找不到,不会静默空跑)。 + */ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const REPO = path.resolve(__dirname, '..'); +const PROJECTS = path.join(os.homedir(), '.claude', 'projects'); + +const [company, role, ...rest] = process.argv.slice(2); +const lastN = (() => { + const i = rest.indexOf('--last'); + return i >= 0 ? Number(rest[i + 1] || 30) : null; +})(); + +if (!company) { + console.error('用法: node scripts/tail-agent.js <公司> [<角色>] [--last N]'); + process.exit(1); +} + +/** cwd 绝对路径 → Claude CLI 的 projects 目录名 */ +function projectDirFor(cid, roleName) { + const cwd = path.join(REPO, 'data', 'companies', cid, 'agents', roleName); + return path.join(PROJECTS, cwd.replace(/\//g, '-')); +} + +if (!role) { + const prefix = `-${REPO.replace(/^\//, '').replace(/\//g, '-')}-data-companies-${company}-agents-`; + const dirs = fs.existsSync(PROJECTS) + ? fs.readdirSync(PROJECTS).filter((d) => d.startsWith(prefix)) + : []; + if (!dirs.length) { + console.error(`没找到 ${company} 的任何 agent transcript(目录前缀 ${prefix})`); + process.exit(2); + } + console.log(`${company} 有 transcript 的角色:`); + for (const d of dirs) { + const roleName = d.slice(prefix.length); + const files = fs.readdirSync(path.join(PROJECTS, d)).filter((f) => f.endsWith('.jsonl')); + const newest = files + .map((f) => ({ f, m: fs.statSync(path.join(PROJECTS, d, f)).mtime })) + .sort((a, b) => b.m - a.m)[0]; + console.log(` ${roleName.padEnd(22)} ${files.length} 个会话` + (newest ? `,最近 ${newest.m.toLocaleString()}` : '')); + } + process.exit(0); +} + +const dir = projectDirFor(company, role); +if (!fs.existsSync(dir)) { + console.error(`没有这个 agent 的 transcript 目录:\n ${dir}\n(角色名写对了吗?先跑 node scripts/tail-agent.js ${company} 看有哪些)`); + process.exit(2); +} +const sessions = fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl')) + .map((f) => ({ p: path.join(dir, f), m: fs.statSync(path.join(dir, f)).mtime })) + .sort((a, b) => b.m - a.m); +if (!sessions.length) { + console.error(`目录在但没有会话文件:${dir}`); + process.exit(2); +} +const file = sessions[0].p; + +// ── 渲染 ─────────────────────────────────────────────────────────── +const C = { dim: '\x1b[2m', reset: '\x1b[0m', cyan: '\x1b[36m', green: '\x1b[32m', yellow: '\x1b[33m', mag: '\x1b[35m' }; +const clip = (s, n) => (s.length <= n ? s : `${s.slice(0, n)}…`); + +function render(line) { + let d; + try { d = JSON.parse(line); } catch { return; } + const msg = d.message; + if (!msg || !Array.isArray(msg.content)) return; + + // 只把 assistant 的 text 当「LLM 输出」。user 侧的 text 是喂进去的东西 —— + // 提示词全文、以及每轮注入的 (时间戳/目录状态),跟看时纯噪音。 + // user 侧只留 tool_result(那是工具的真实返回,看 agent 读到了什么很有用)。 + const isAssistant = msg.role === 'assistant'; + + for (const c of msg.content) { + if (c.type === 'text' && isAssistant && c.text && c.text.trim()) { + console.log(`${C.green}▌${C.reset} ${c.text.trim()}`); + } else if (c.type === 'thinking' && c.thinking && c.thinking.trim()) { + console.log(`${C.dim}◌ ${clip(c.thinking.trim().replace(/\n+/g, ' '), 300)}${C.reset}`); + } else if (c.type === 'tool_use') { + const i = c.input || {}; + const arg = i.file_path || i.command || i.pattern || i.path || i.url || ''; + console.log(`${C.cyan}→ ${c.name}${C.reset} ${C.dim}${clip(String(arg), 160)}${C.reset}`); + } else if (c.type === 'tool_result') { + const t = typeof c.content === 'string' + ? c.content + : (c.content || []).map((x) => (x && x.text) || '').join(' '); + if (t.trim()) console.log(`${C.dim}← ${clip(t.trim().replace(/\n+/g, ' '), 200)}${C.reset}`); + } + } +} + +console.log(`${C.mag}会话${C.reset} ${file}`); +console.log(`${C.dim}${company} / ${role}${lastN ? ` 最后 ${lastN} 条` : ' 跟看中(Ctrl+C 退出)'}${C.reset}\n`); + +const all = fs.readFileSync(file, 'utf-8').split('\n').filter(Boolean); +for (const l of (lastN ? all.slice(-lastN) : all)) render(l); + +if (lastN) process.exit(0); + +// 跟看:只从当前长度往后读增量 +let offset = fs.statSync(file).size; +let carry = ''; +setInterval(() => { + let size; + try { size = fs.statSync(file).size; } catch { return; } + if (size <= offset) return; + const fd = fs.openSync(file, 'r'); + const buf = Buffer.alloc(size - offset); + fs.readSync(fd, buf, 0, buf.length, offset); + fs.closeSync(fd); + offset = size; + const chunk = carry + buf.toString('utf-8'); + const lines = chunk.split('\n'); + carry = lines.pop() || ''; // 最后一段可能是半行,留到下一轮 + for (const l of lines) if (l.trim()) render(l); +}, 700);