/** * 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 自己把完整会话写在 * //.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 = /[\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 };