tail-agent.js
6.07 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
125
126
127
128
129
130
131
132
133
134
135
136
137
138
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 路径里的 / 换成 - >/<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 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 是喂进去的东西 ——
// 提示词全文、以及每轮注入的 <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);