agent-transcript.js 11.2 KB
/**
 * 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 };