/** * 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 * 本模块**只读**这些文件,不复制、不落第二份库 —— 那正是上面第二条理由要避免的。 * * ⚠️ 一个 (公司, 角色) 现在对应**一组**目录,不是一个(28 批次 3 起 cwd 下沉): * 新布局 system/agents/<角色>/<公司>// —— 每个 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 = /[\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 };