agent-transcript.js
8.69 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
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
/**
* Agent 会话原文(Claude CLI transcript)读取。
*
* 为什么需要单独一份:引擎按设计**不存** LLM 的逐字输出 ——
* defaultAgentExecutor 的 onAcpEvent 只消费 session/result(累 usage)与 mock/files,
* 文本 chunk 一个字都不接;事件里的自由文本还被截断到 600 字(EVENT_TEXT_MAX,
* graph-engine.js 那段注释给了三条理由:完整输出无上限而 getRunState 每次轮询 SELECT *
* 全量回传 / 完整产物已有 output_ref 这个唯一真相源 / fail_reason 是选边匹配键不能掺自由文本)。
*
* 但 Claude CLI 自己把完整会话写在
* <projectsDir>/<agent cwd 的绝对路径,'/' 换成 '-'>/<sessionId>.jsonl
* 而 company agent 的 cwd 恒为 data/companies/<公司>/agents/<角色>,所以目录名可推。
* 本模块**只读**这些文件,不复制、不落第二份库 —— 那正是上面第二条理由要避免的。
*
* ⚠️ 这份 transcript 按 (公司, 角色) 分,**不按节点分**。同一个角色在多个节点跑过时,
* 只能按节点的 started_at / completed_at 时间窗口去截 —— 这是近似而非精确对齐,
* 调用方必须把这一点告诉用户(UI 上写明),不能让人以为是节点级的精确归属。
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const REPO_ROOT = path.resolve(__dirname, '../..');
// CLAUDE_PROJECTS_DIR 是测试用的落地缝(同 GRAPH_DB_PATH / GRAPH_SEED_ROOT 那一套):
// 真实位置在用户 home 下,单测不可能在那里造夹具。
function projectsDir() {
return process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects');
}
/** company agent 的 cwd(与 agent-manager 起 CLI 时用的那个一致) */
function agentCwd(companyId, role) {
return path.join(REPO_ROOT, 'data', 'companies', companyId, 'agents', role);
}
/**
* cwd 绝对路径 → Claude CLI 的 projects 子目录名。
* ponytail: 「'/' 全换成 '-'」是 CLI 现行的命名规则,规则变了这里就找不到目录。
* 找不到时一律显式返回 ok:false + 期望路径,绝不静默返回空列表
* (空列表会被读成「这个 agent 没说过话」,而真相是「我们没找到文件」)。
*/
function transcriptDirFor(companyId, role) {
return path.join(projectsDir(), agentCwd(companyId, role).replace(/\//g, '-'));
}
/** 这家公司有哪些角色留下过 transcript */
function listRoles(companyId) {
// 末尾那个 '-' 要自己补:path.join 会把空的最后一段丢掉,
// agentCwd(cid, '') 得到的是 `.../agents`(没有尾斜杠),
// 不补的话切出来的角色名都带一个前导 '-'(实测 '-sdd-planning')。
const prefix = `${agentCwd(companyId, '').replace(/\//g, '-')}-`;
const dir = projectsDir();
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir)
.filter((d) => d.startsWith(prefix) && d.length > prefix.length)
.map((d) => {
const role = d.slice(prefix.length);
const full = path.join(dir, d);
const files = fs.readdirSync(full).filter((f) => f.endsWith('.jsonl'));
const newest = files
.map((f) => ({ f, m: fs.statSync(path.join(full, f)).mtimeMs }))
.sort((a, b) => b.m - a.m)[0];
return { role, sessions: files.length, updatedAt: newest ? new Date(newest.m).toISOString() : null };
})
.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
}
// 单条文本的上限。整份 transcript 动辄几百 KB,直接吐给浏览器没必要;
// 这里截的是**单个片段**,不是丢掉条目 —— 条目数少了会让人以为 agent 没做那一步。
const TEXT_MAX = 8000;
const AUX_MAX = 400;
const clip = (s, n) => {
const str = typeof s === 'string' ? s : String(s == null ? '' : s);
return str.length <= n ? { text: str, clipped: false } : { text: str.slice(0, n), clipped: true };
};
// harness 每轮注入给 agent 的环境块。不是提示词、也不是 agent 说的话,纯机器噪音,
// 而且一份能有几 KB。剥掉,但**剥了几处要如实回给调用方** ——
// 静默删内容会让人以为原文就是这样,那比噪音更坏。
const ENV_STATUS_RE = /<env_status>[\s\S]*?<\/env_status>\s*/g;
/** 一行 jsonl → 规范化条目;不是消息行(system / 无 content)返回 null */
function normalize(line) {
let d;
try { d = JSON.parse(line); } catch { return null; }
const msg = d.message;
if (!msg || !Array.isArray(msg.content)) return null;
const parts = [];
let strippedEnvStatus = 0;
for (const c of msg.content) {
if (!c || typeof c !== 'object') continue;
if (c.type === 'text' && c.text && c.text.trim()) {
const hits = c.text.match(ENV_STATUS_RE);
const body = hits ? c.text.replace(ENV_STATUS_RE, '') : c.text;
if (hits) strippedEnvStatus += hits.length;
if (!body.trim()) continue; // 整条就是一个 env_status → 这条不留
const { text, clipped } = clip(body, TEXT_MAX);
parts.push({ kind: 'text', text, clipped });
} else if (c.type === 'thinking' && c.thinking && c.thinking.trim()) {
const { text, clipped } = clip(c.thinking, AUX_MAX);
parts.push({ kind: 'thinking', text, clipped });
} else if (c.type === 'tool_use') {
const i = c.input || {};
const arg = i.file_path || i.command || i.pattern || i.path || i.url || '';
parts.push({ kind: 'tool_use', name: c.name || '?', text: clip(arg, AUX_MAX).text });
} else if (c.type === 'tool_result') {
const raw = typeof c.content === 'string'
? c.content
: (c.content || []).map((x) => (x && x.text) || '').join(' ');
if (raw.trim()) {
const { text, clipped } = clip(raw, AUX_MAX);
parts.push({ kind: 'tool_result', text, clipped });
}
}
}
// parts 空但剥过东西时**仍然返回**:整条正文就是一个 env_status 的条目会被剥空,
// 若在这里 return null,它剥掉的那一处就统计不到 —— 那等于「少报了删掉的量」,
// 而这个计数存在的唯一理由就是别静默删内容。真正什么都没有时才 null。
if (!parts.length && !strippedEnvStatus) return null;
return { ts: d.timestamp || null, role: msg.role || d.type || 'unknown', parts, strippedEnvStatus };
}
/**
* 读某个 (公司, 角色) 的会话原文。
*
* @param {object} o
* @param {string} o.companyId
* @param {string} o.role
* @param {string} [o.since] ISO;给了就只留 ts >= since 的条目(节点 started_at)
* @param {string} [o.until] ISO;给了就只留 ts <= until 的条目(节点 completed_at)
* @param {number} [o.maxSessions] 最多看几个最近的会话文件(默认 3)
* @returns {{ok:boolean, reason?:string, dir:string, sessions:string[], entries:Array, windowed:boolean, droppedNoTs:number}}
*/
function readEntries({ companyId, role, since = null, until = null, maxSessions = 3 }) {
const dir = transcriptDirFor(companyId, role);
if (!fs.existsSync(dir)) {
return { ok: false, reason: 'no-transcript-dir', dir, sessions: [], entries: [], windowed: false, droppedNoTs: 0 };
}
const files = fs
.readdirSync(dir)
.filter((f) => f.endsWith('.jsonl'))
.map((f) => ({ f, m: fs.statSync(path.join(dir, f)).mtimeMs }))
.sort((a, b) => b.m - a.m)
.slice(0, maxSessions);
if (!files.length) {
return { ok: false, reason: 'no-session-file', dir, sessions: [], entries: [], windowed: false, droppedNoTs: 0 };
}
const windowed = Boolean(since || until);
let droppedNoTs = 0;
let strippedEnvStatus = 0;
const entries = [];
for (const { f } of files) {
const lines = fs.readFileSync(path.join(dir, f), 'utf-8').split('\n');
for (const line of lines) {
if (!line.trim()) continue;
const e = normalize(line);
if (!e) continue;
if (windowed) {
// 没有时间戳的条目在有窗口时**丢掉并计数**:留着会让人以为它属于本次调用,
// 而我们其实不知道它属于哪次。计数如实回给调用方,由 UI 说明。
if (!e.ts) { droppedNoTs += 1; continue; }
if (since && e.ts < since) continue;
if (until && e.ts > until) continue;
}
// 先记账再决定要不要入列表:被剥空的条目不该出现在原文里(它没内容),
// 但它剥掉的量必须算进总数
strippedEnvStatus += e.strippedEnvStatus || 0;
if (!e.parts.length) continue;
entries.push({ ...e, session: f });
}
}
entries.sort((a, b) => String(a.ts).localeCompare(String(b.ts)));
return { ok: true, dir, sessions: files.map((x) => x.f), entries, windowed, droppedNoTs, strippedEnvStatus };
}
module.exports = { transcriptDirFor, listRoles, readEntries, agentCwd, projectsDir };