tail-agent.js
5.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
#!/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 路径里的 / 换成 - >/<sessionId>.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 path = require('path');
// 目录推导与条目规范化共用服务模块 —— 控制台的「LLM 原文」tab 走的是同一份实现。
// 各写一份的话,两边对「哪个目录、剥不剥 env_status」的理解迟早分叉。
const transcript = require('../server/services/agent-transcript');
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);
}
if (!role) {
const roles = transcript.listRoles(company);
if (!roles.length) {
console.error(`没找到 ${company} 的任何 agent transcript(找的是 ${transcript.projectsDir()} 下的目录)`);
process.exit(2);
}
console.log(`${company} 有 transcript 的角色:`);
for (const r of roles) {
console.log(` ${r.role.padEnd(22)} ${r.sessions} 个会话` + (r.updatedAt ? `,最近 ${new Date(r.updatedAt).toLocaleString()}` : ''));
}
process.exit(0);
}
const dir = transcript.transcriptDirFor(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 是喂进去的东西 ——
// 提示词全文、以及每轮注入的 <env_status>(时间戳/目录状态),跟看时纯噪音。
// 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);