agent-transcript.js
11.2 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
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
/**
* 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
* 本模块**只读**这些文件,不复制、不落第二份库 —— 那正是上面第二条理由要避免的。
*
* ⚠️ 一个 (公司, 角色) 现在对应**一组**目录,不是一个(28 批次 3 起 cwd 下沉):
* 新布局 system/agents/<角色>/<公司>/<taskId>/ —— 每个 taskId 是一次独立的 cwd
* 旧布局 data/companies/<公司>/agents/<角色>/ —— 批次 4 之前落的盘,目录早已删掉,
* 但 ~/.claude/projects 下那批 jsonl 还在
* 所以推导从「拼出唯一那个目录名」变成「按公司态目录的扁平化前缀去匹配一批目录,
* 外加旧布局那一个」。旧布局这一支不能省:省掉等于改造当天所有历史 run 的原文全部消失,
* 而症状是「ok:false / 没说过话」——分不清是没跑过还是我们不去看了。
*
* ⚠️ 这份 transcript 按 (公司, 角色) 分,**不按节点分**。同一个角色在多个节点跑过时,
* 只能按节点的 started_at / completed_at 时间窗口去截 —— 这是近似而非精确对齐,
* 调用方必须把这一点告诉用户(UI 上写明),不能让人以为是节点级的精确归属。
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const locator = require('./agent-locator');
// CLAUDE_PROJECTS_DIR 是测试用的落地缝(同 GRAPH_DB_PATH / GRAPH_SEED_ROOT 那一套):
// 真实位置在用户 home 下,单测不可能在那里造夹具。
function projectsDir() {
return process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects');
}
/**
* 一个 agent 在一家公司下的**公司态**目录(与 agent-manager 起 CLI 时那条 taskDir 的父目录
* 一致 —— 这句「一致」由 agent-locator 保证,不靠两处手抄同一段 path.join,见 28 §2.5)。
* 任务态目录(真正的 cwd)是它下面的 `{taskId}/`,所以它同时也是匹配前缀。
*/
function agentCwd(companyId, role) {
return locator.companyDir(role, companyId);
}
/**
* cwd 绝对路径 → Claude CLI 的 projects 子目录名。
* ponytail: 「'/' 全换成 '-'」是 CLI 现行的命名规则,规则变了这里就找不到目录。
*/
const flatten = (abs) => abs.replace(/\//g, '-');
/**
* 我们**期望**去哪找 —— 公司态目录的扁平化形式。真实的会话目录是它加上 `-{taskId}`。
* 找不到时一律显式把它回给调用方(ok:false + 这个路径),绝不静默返回空列表
* (空列表会被读成「这个 agent 没说过话」,而真相是「我们没找到文件」)。
*/
function transcriptDirFor(companyId, role) {
return path.join(projectsDir(), flatten(agentCwd(companyId, role)));
}
/**
* 这个 (公司, 角色) 实际存在的所有会话目录:新布局的每个 taskId 一个,外加旧布局那一个。
* 按修改时间倒序 —— 调用方 slice(0, maxSessions) 时拿到的才是最近的几次。
*/
function transcriptDirsFor(companyId, role) {
const root = projectsDir();
if (!fs.existsSync(root)) return [];
// 前缀末尾那个 '-' 必须自己补:不补的话 `{cid}` 会顺带匹配到 `{cid}-suffix` 这种
// 另一家公司的目录(扁平化之后目录边界只剩这个分隔符了)。
const prefix = `${flatten(agentCwd(companyId, role))}-`;
const legacyName = flatten(locator.legacyCompanyAgentDir(companyId, role));
return fs
.readdirSync(root)
.filter((d) => (d.startsWith(prefix) && d.length > prefix.length) || d === legacyName)
.map((d) => path.join(root, d))
.filter((full) => fs.statSync(full).isDirectory())
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
}
/**
* 这家公司有哪些角色留下过 transcript。
*
* 角色清单来自 `company_agents`(28 批次 4-19),**不再从目录名反推**:新布局扁平化之后
* 目录名长这样 `-repo-system-agents-{role}-{cid}-{taskId}`,role / cid / taskId 三段全都
* 可能自带 '-'(实测 `sdd-planning` / `co-tx` / `T-tx`),从中切出角色名没有唯一解。
*/
function listRoles(companyId) {
const { listCompanyAgents } = require('./company-service');
return listCompanyAgents(companyId)
.map((r) => {
let sessions = 0;
let newest = 0;
for (const dir of transcriptDirsFor(companyId, r.system_agent_id)) {
for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.jsonl'))) {
sessions += 1;
newest = Math.max(newest, fs.statSync(path.join(dir, f)).mtimeMs);
}
}
return { role: r.role_name, sessions, updatedAt: newest ? new Date(newest).toISOString() : null };
})
.filter((r) => r.sessions > 0)
.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 }) {
// dir 只用于回给调用方「我们期望在哪找」;真正读的是 dirs 里的每一个(一个 taskId 一个)
const dir = transcriptDirFor(companyId, role);
const dirs = transcriptDirsFor(companyId, role);
if (!dirs.length) {
return { ok: false, reason: 'no-transcript-dir', dir, sessions: [], entries: [], windowed: false, droppedNoTs: 0 };
}
// maxSessions 按**会话文件**算而不是按目录算:一个 taskId 目录里可能有多个 session,
// 按目录截会让「最近 3 次会话」在多任务时变成「最近 3 个任务的全部会话」。
const files = dirs
.flatMap((d) => fs.readdirSync(d).filter((f) => f.endsWith('.jsonl')).map((f) => path.join(d, f)))
.map((p) => ({ p, m: fs.statSync(p).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 { p } of files) {
const f = path.basename(p);
const lines = fs.readFileSync(p, '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) => path.basename(x.p)), entries, windowed, droppedNoTs, strippedEnvStatus };
}
module.exports = { transcriptDirFor, transcriptDirsFor, listRoles, readEntries, agentCwd, projectsDir };