agent-transcript.js 8.69 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
 * 而 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 };