lvwerra's picture
lvwerra HF Staff
Faster warm paths and honest numbers for Usage and Overview
0dd286d
Raw
History Blame Contribute Delete
32.3 kB
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import * as store from './sessions.js';
import { WORKSPACES_DIR } from './config.js';
// Workspace-wide trace analytics: parse every Claude transcript and Codex
// rollout on the Space into per-conversation stats (turns, tool calls, web
// searches, tokens), attribute them to Agent Manager sessions where possible
// (Claude: transcript filename == sessionUuid; Codex: pinned codexSessionId),
// and aggregate the rest as "other".
//
// Parsing is memoized per file by (mtime, size), so repeat calls only re-read
// files that actually changed — important on the bucket mount.
const fileCache = new Map(); // path -> { key, parsed: { stats, digest } }
let resultMemo = { ts: 0, val: null };
const TTL = 400; // Overview polls at ~1Hz; per-file mtime caching keeps re-scans cheap
function emptyStats() {
return { turns: 0, prompts: 0, toolCalls: 0, tools: {}, web: 0, tokensIn: 0, tokensOut: 0, cacheRead: 0, firstTs: 0, lastTs: 0, files: 0 };
}
function addTs(st, iso) {
const t = Date.parse(iso);
if (!t) return;
if (!st.firstTs || t < st.firstTs) st.firstTs = t;
if (t > st.lastTs) st.lastTs = t;
}
function mergeInto(a, b) {
a.turns += b.turns; a.prompts += b.prompts; a.toolCalls += b.toolCalls; a.web += b.web;
a.tokensIn += b.tokensIn; a.tokensOut += b.tokensOut; a.cacheRead += b.cacheRead; a.files += b.files;
for (const [k, v] of Object.entries(b.tools)) a.tools[k] = (a.tools[k] || 0) + v;
if (b.firstTs && (!a.firstTs || b.firstTs < a.firstTs)) a.firstTs = b.firstTs;
if (b.lastTs > a.lastTs) a.lastTs = b.lastTs;
}
// ---------- "since your last prompt" digest (for the Overview cards) ----------
// Built in the same parse pass: every real user prompt resets the segment, so
// whatever accumulated by EOF is the activity since the last thing you said.
function emptyDigest() {
return { lastPromptText: '', lastPromptRaw: '', lastPromptTs: 0, lastAssistantText: '', lastAssistantMd: '', lastAssistantTs: 0, sinceTurns: 0, sinceToolCalls: 0, sinceTools: {}, sinceFiles: [], sinceTokens: 0, running: false, turnsLog: [] };
}
const clip = (s, n = 280) => { const t = (s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
// Markdown-preserving variant (keeps newlines) for the expandable card view.
const clipRaw = (s, n = 6000) => { const t = (s || '').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
// turnsLog: the model's intermediate turns WITHIN the current request (newest
// first) so the Overview can page through them. Each new assistant text pushes
// the previous one into the log; a new user prompt clears it.
const MAX_TURNS_LOG = 24;
function digestPrompt(d, text, ts) {
d.lastPromptText = clip(text); d.lastPromptRaw = clipRaw(text); d.lastPromptTs = Date.parse(ts) || 0;
d.sinceTurns = 0; d.sinceToolCalls = 0; d.sinceTools = {}; d.sinceFiles = []; d.sinceTokens = 0;
d.turnsLog = []; // arrows only walk the current request's turns
// The previous answer belongs to the previous prompt — never show it as "LAST".
d.lastAssistantText = ''; d.lastAssistantMd = ''; d.lastAssistantTs = 0;
}
function digestAssistant(d, text, ts) {
const clipped = clip(text);
// Same text again (codex mirrors agent_message/response_item/task_complete):
// refresh metadata only, don't log a phantom turn.
if (clipped !== d.lastAssistantText) {
if (d.lastAssistantText) {
d.turnsLog.unshift({ answer: d.lastAssistantText, answerMd: d.lastAssistantMd, ts: d.lastAssistantTs });
if (d.turnsLog.length > MAX_TURNS_LOG) d.turnsLog.pop();
}
d.lastAssistantText = clipped;
}
d.lastAssistantMd = clipRaw(text);
d.lastAssistantTs = Date.parse(ts) || d.lastAssistantTs;
}
function digestTool(d, name, file) {
d.sinceToolCalls++;
d.sinceTools[name] = (d.sinceTools[name] || 0) + 1;
if (file && !d.sinceFiles.includes(file) && d.sinceFiles.length < 12) d.sinceFiles.push(file);
}
// ---------- Claude transcripts (CLAUDE_CONFIG_DIR/projects/**/<uuid>.jsonl) ----------
// Multi-block assistant messages repeat the same message.id AND usage across
// several lines — dedupe both turns/usage (by message id) and tool_use blocks
// (by block id) or everything double-counts.
function parseClaude(txt) {
const st = emptyStats();
const dg = emptyDigest();
st.files = 1;
const seenMsg = new Set();
const seenTool = new Set();
for (const line of txt.split('\n')) {
if (!line) continue;
let j; try { j = JSON.parse(line); } catch { continue; }
if (j.timestamp) addTs(st, j.timestamp);
if (j.type === 'assistant' && j.message) {
const m = j.message;
const id = m.id || `${j.uuid || Math.random()}`;
if (!seenMsg.has(id)) {
seenMsg.add(id);
st.turns++;
dg.sinceTurns++;
const u = m.usage;
if (u) {
st.tokensIn += (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0);
st.cacheRead += u.cache_read_input_tokens || 0;
st.tokensOut += u.output_tokens || 0;
dg.sinceTokens += (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0) + (u.output_tokens || 0);
const w = u.server_tool_use;
if (w) st.web += (w.web_search_requests || 0) + (w.web_fetch_requests || 0);
}
}
if (Array.isArray(m.content)) {
for (const c of m.content) {
if (!c) continue;
if (c.type === 'tool_use' && !seenTool.has(c.id)) {
seenTool.add(c.id);
st.toolCalls++;
const name = c.name || 'tool';
st.tools[name] = (st.tools[name] || 0) + 1;
if (/^web(search|fetch)$/i.test(name)) st.web++;
const file = /^(Edit|Write|MultiEdit|NotebookEdit)$/.test(name) && c.input && c.input.file_path;
digestTool(dg, name, file || null);
} else if (c.type === 'text' && c.text && c.text.trim()) {
digestAssistant(dg, c.text, j.timestamp);
}
}
}
} else if (j.type === 'user' && !j.toolUseResult) {
st.prompts++;
const mc = j.message && j.message.content;
const text = typeof mc === 'string' ? mc
: Array.isArray(mc) ? mc.filter((c) => c && c.type === 'text').map((c) => c.text).join(' ') : '';
// Skip harness noise (slash-command wrappers, attachments) as "prompts".
if (text.trim() && !text.trim().startsWith('<')) digestPrompt(dg, text, j.timestamp);
}
}
return { stats: st, digest: dg };
}
// ---------- Codex rollouts (CODEX_HOME/sessions/**/rollout-*-<uuid>.jsonl) ----------
const codexText = (p) => (Array.isArray(p.content) ? p.content.map((c) => (c && c.text) || '').join(' ') : '');
function parseCodex(txt) {
const st = emptyStats();
const dg = emptyDigest();
st.files = 1;
let tok = null; // token_count events are cumulative per run — keep the last
let tokAtPrompt = 0; // cumulative total when you last prompted (for sinceTokens)
for (const line of txt.split('\n')) {
if (!line) continue;
let j; try { j = JSON.parse(line); } catch { continue; }
if (j.timestamp) addTs(st, j.timestamp);
const p = j.payload || {};
if (j.type === 'session_meta') {
if (p.cwd) st.cwd = p.cwd; // for cwd-fallback attribution
// Codex >=0.142 spawns internal "guardian" safety-judge subagents whose
// rollouts share the session's cwd. They aren't the user's conversation —
// flag them so build() skips attribution (they'd otherwise pollute or
// blank out the Overview digest).
if (p.thread_source === 'subagent' || (p.source && p.source.subagent)) st.subagent = true;
}
if (j.type === 'response_item') {
switch (p.type) {
case 'message':
if (p.role === 'assistant') {
st.turns++;
dg.sinceTurns++;
const t = codexText(p);
if (t.trim()) digestAssistant(dg, t, j.timestamp);
} else if (p.role === 'user') {
st.prompts++;
const t = codexText(p);
// Codex wraps environment/instructions as user items — skip those.
if (t.trim() && !t.trim().startsWith('<')) {
digestPrompt(dg, t, j.timestamp);
tokAtPrompt = tok ? (tok.total_tokens || 0) : 0;
}
}
break;
case 'function_call':
case 'custom_tool_call':
case 'local_shell_call': {
st.toolCalls++;
const name = p.name || p.type;
st.tools[name] = (st.tools[name] || 0) + 1;
// apply_patch arguments carry the touched files in the patch header
let file = null;
if (name === 'apply_patch' && typeof p.arguments === 'string') {
const m = p.arguments.match(/\*\*\* (?:Update|Add|Delete) File: ([^\\\n"]+)/);
if (m) file = m[1].trim();
}
digestTool(dg, name, file);
break;
}
case 'web_search_call':
st.web++;
st.toolCalls++;
st.tools.web_search = (st.tools.web_search || 0) + 1;
digestTool(dg, 'web_search', null);
break;
default:
}
} else if (j.type === 'event_msg') {
// Task lifecycle: Codex runs one task per user prompt, made of several
// model turns. task_started/task_complete bracket it — that (not "an
// assistant message appeared") is the real running/done signal, and
// task_complete carries the authoritative final answer.
switch (p.type) {
case 'token_count':
if (p.info && p.info.total_token_usage) tok = p.info.total_token_usage;
break;
case 'task_started':
dg.running = true;
break;
case 'agent_message':
if (p.message) digestAssistant(dg, p.message, j.timestamp); // live progress text
break;
case 'task_complete':
dg.running = false;
if (p.last_agent_message) digestAssistant(dg, p.last_agent_message, j.timestamp);
break;
default:
}
}
}
if (tok) {
const cached = tok.cached_input_tokens || 0;
st.tokensIn = Math.max(0, (tok.input_tokens || 0) - cached); // align with Claude: fresh input only
st.cacheRead = cached;
st.tokensOut = tok.output_tokens || 0;
dg.sinceTokens = Math.max(0, (tok.total_tokens || 0) - tokAtPrompt);
}
return { stats: st, digest: dg };
}
// ---------- OpenClaw sessions (~/.openclaw/agents/*/sessions/<uuid>.jsonl) ----------
// Line shape: { type: 'message', timestamp, message: { role, content: [{type:'text',text}],
// usage: { input, output, cacheRead, ... } } } — other line types are metadata.
const ocText = (m) => Array.isArray(m.content)
? m.content.filter((c) => c && c.type === 'text' && c.text).map((c) => c.text).join(' ')
: (typeof m.content === 'string' ? m.content : '');
function parseOpenClaw(txt) {
const st = emptyStats();
const dg = emptyDigest();
st.files = 1;
for (const line of txt.split('\n')) {
if (!line) continue;
let j; try { j = JSON.parse(line); } catch { continue; }
if (j.timestamp) addTs(st, j.timestamp);
if (j.type !== 'message' || !j.message) continue;
const m = j.message;
const text = ocText(m);
if (m.role === 'user') {
st.prompts++;
if (text.trim() && !text.trim().startsWith('<')) digestPrompt(dg, text, j.timestamp);
} else if (m.role === 'assistant') {
st.turns++;
dg.sinceTurns++;
if (text.trim()) digestAssistant(dg, text, j.timestamp);
const u = m.usage;
if (u) {
const cached = u.cacheRead || 0;
st.tokensIn += Math.max(0, (u.input || 0) - cached);
st.cacheRead += cached;
st.tokensOut += u.output || 0;
dg.sinceTokens += (u.input || 0) + (u.output || 0);
}
if (Array.isArray(m.content)) {
for (const cb of m.content) {
if (cb && typeof cb.type === 'string' && /tool/i.test(cb.type)) {
st.toolCalls++;
const name = cb.name || cb.toolName || 'tool';
st.tools[name] = (st.tools[name] || 0) + 1;
digestTool(dg, name, null);
}
}
}
}
}
return { stats: st, digest: dg };
}
function openclawFiles() {
return memoList('openclaw', openclawFilesUncached);
}
async function openclawFilesUncached() {
const root = path.join(process.env.OPENCLAW_STATE_DIR || path.join(process.env.HOME || '', '.openclaw'), 'agents');
const out = [];
let agents = [];
try { agents = await fsp.readdir(root, { withFileTypes: true }); } catch { return out; }
for (const a of agents) {
if (!a.isDirectory()) continue;
const dir = path.join(root, a.name, 'sessions');
let files = [];
try { files = await fsp.readdir(dir); } catch { continue; }
for (const f of files) {
if (f.endsWith('.jsonl') && !f.includes('.trajectory')) out.push(path.join(dir, f));
}
}
return out;
}
// ---------- opencode (SQLite: ~/.local/share/opencode/opencode.db) ----------
// v1.x keeps conversations in SQLite (session/message/part with JSON payloads).
// Read-only via node:sqlite (node >= 22.5; degrades to "no digest" elsewhere).
let DatabaseSync = null;
try { ({ DatabaseSync } = await import('node:sqlite')); } catch { /* older node: skip opencode */ }
function opencodeDbPath() {
const xdgData = process.env.XDG_DATA_HOME || path.join(process.env.HOME || '', '.local', 'share');
return path.join(xdgData, 'opencode', 'opencode.db');
}
// WAL mode appends to <db>-wal without touching the main file's mtime — the
// change key and the hot-file check must look at both.
function dbChangeKey(p) {
let m;
try { m = fs.statSync(p); } catch { return null; }
let w = null;
try { w = fs.statSync(`${p}-wal`); } catch {}
return {
key: `${m.mtimeMs}:${m.size}:${w?.mtimeMs || 0}:${w?.size || 0}`,
hotMs: Math.max(m.mtimeMs, w?.mtimeMs || 0),
};
}
let ocMemo = { key: '', rows: [] };
function readOpencode() {
if (!DatabaseSync) return [];
const p = opencodeDbPath();
const ck = dbChangeKey(p);
if (!ck) return [];
if (ocMemo.key === ck.key) return ocMemo.rows;
// actively written db on the FUSE mount: serve the previous read (first read allowed)
if (ocMemo.key && Date.now() - ck.hotMs < 8_000) return ocMemo.rows;
const key = ck.key;
let db;
try { db = new DatabaseSync(p, { readOnly: true }); } catch { return ocMemo.rows; }
const rows = [];
try {
const sessions = db.prepare('select * from session').all();
const qLastUser = db.prepare("select id, time_created from message where session_id = ? and json_extract(data,'$.role') = 'user' order by time_created desc limit 1");
const qPromptText = db.prepare("select json_extract(data,'$.text') t from part where message_id = ? and json_extract(data,'$.type') = 'text' order by time_created");
const qCount = db.prepare("select count(*) c from message where session_id = ? and time_created > ? and json_extract(data,'$.role') = ?");
const qTools = db.prepare("select json_extract(data,'$.tool') tool, count(*) c from part where session_id = ? and time_created > ? and json_extract(data,'$.type') = 'tool' group by 1");
const qSinceTok = db.prepare("select coalesce(sum(json_extract(data,'$.tokens.total')), 0) t from part where session_id = ? and time_created > ? and json_extract(data,'$.type') = 'step-finish'");
const qLastAssistant = db.prepare("select json_extract(p.data,'$.text') t, p.time_created ts from part p join message m on m.id = p.message_id where p.session_id = ? and p.time_created > ? and json_extract(p.data,'$.type') = 'text' and json_extract(m.data,'$.role') = 'assistant' order by p.time_created desc limit 1");
for (const s of sessions) {
const st = emptyStats();
const dg = emptyDigest();
st.files = 1;
st.cwd = s.directory || null;
st.tokensIn = s.tokens_input || 0;
st.tokensOut = s.tokens_output || 0;
st.cacheRead = s.tokens_cache_read || 0;
st.firstTs = Number(s.time_created) || 0;
st.lastTs = Number(s.time_updated) || st.firstTs;
st.prompts = qCount.get(s.id, 0, 'user').c;
st.turns = qCount.get(s.id, 0, 'assistant').c;
for (const t of qTools.all(s.id, 0)) {
const name = t.tool || 'tool';
st.tools[name] = (st.tools[name] || 0) + t.c;
st.toolCalls += t.c;
if (/web/i.test(name)) st.web += t.c;
}
const lastU = qLastUser.get(s.id);
const t0 = lastU ? Number(lastU.time_created) || 0 : 0;
if (lastU) {
const text = qPromptText.all(lastU.id).map((r) => r.t).filter(Boolean).join(' ');
if (text.trim() && !text.trim().startsWith('<')) {
dg.lastPromptText = clip(text);
dg.lastPromptRaw = clipRaw(text);
dg.lastPromptTs = t0;
}
dg.sinceTurns = qCount.get(s.id, t0, 'assistant').c;
for (const t of qTools.all(s.id, t0)) {
const name = t.tool || 'tool';
dg.sinceTools[name] = (dg.sinceTools[name] || 0) + t.c;
dg.sinceToolCalls += t.c;
}
dg.sinceTokens = qSinceTok.get(s.id, t0).t || 0;
}
// Only an answer NEWER than the prompt counts — a stale one means "working".
const lastA = qLastAssistant.get(s.id, t0);
if (lastA && lastA.t) {
dg.lastAssistantText = clip(lastA.t);
dg.lastAssistantMd = clipRaw(lastA.t);
dg.lastAssistantTs = Number(lastA.ts) || 0;
}
rows.push({ directory: s.directory || null, parsed: { stats: st, digest: dg } });
}
} catch { /* torn read / schema drift: keep previous */ } finally {
try { db.close(); } catch {}
}
ocMemo = { key, rows };
return rows;
}
// ---------- Hermes (SQLite: ~/.hermes/state.db, WAL) ----------
// sessions carry cwd + token totals; messages carry role/content/tool_name.
// Timestamps are float SECONDS — converted to ms for digest fields.
let hermesMemo = { key: '', rows: [] };
function readHermes() {
if (!DatabaseSync) return [];
const p = path.join(process.env.HOME || '', '.hermes', 'state.db');
const ck = dbChangeKey(p);
if (!ck) return [];
if (hermesMemo.key === ck.key) return hermesMemo.rows;
if (hermesMemo.key && Date.now() - ck.hotMs < 8_000) return hermesMemo.rows;
let db;
try { db = new DatabaseSync(p, { readOnly: true }); } catch { return hermesMemo.rows; }
const rows = [];
try {
const sessions = db.prepare('select * from sessions').all();
const qLastUser = db.prepare("select content, timestamp from messages where session_id = ? and role = 'user' and active = 1 order by timestamp desc limit 1");
const qRole = db.prepare("select count(*) c from messages where session_id = ? and timestamp > ? and role = ?");
const qTools = db.prepare("select tool_name, count(*) c from messages where session_id = ? and timestamp > ? and tool_name is not null group by 1");
const qTok = db.prepare("select coalesce(sum(token_count), 0) t from messages where session_id = ? and timestamp > ?");
const qLastAssistant = db.prepare("select content, timestamp from messages where session_id = ? and role = 'assistant' and content is not null and content != '' and timestamp > ? order by timestamp desc limit 1");
const qMaxTs = db.prepare('select max(timestamp) t from messages where session_id = ?');
for (const s of sessions) {
const st = emptyStats();
const dg = emptyDigest();
st.files = 1;
st.cwd = s.cwd || null;
st.tokensIn = s.input_tokens || 0;
st.tokensOut = s.output_tokens || 0;
st.cacheRead = s.cache_read_tokens || 0;
st.firstTs = Math.round((Number(s.started_at) || 0) * 1000);
const maxTs = qMaxTs.get(s.id)?.t;
st.lastTs = maxTs ? Math.round(Number(maxTs) * 1000) : st.firstTs;
st.prompts = qRole.get(s.id, 0, 'user').c;
st.turns = qRole.get(s.id, 0, 'assistant').c;
for (const t of qTools.all(s.id, 0)) {
const name = t.tool_name || 'tool';
st.tools[name] = (st.tools[name] || 0) + t.c;
st.toolCalls += t.c;
if (/web|search/i.test(name)) st.web += t.c;
}
const lastU = qLastUser.get(s.id);
const t0 = lastU ? Number(lastU.timestamp) || 0 : 0; // seconds, for queries
if (lastU) {
const text = String(lastU.content || '');
if (text.trim() && !text.trim().startsWith('<')) {
dg.lastPromptText = clip(text);
dg.lastPromptRaw = clipRaw(text);
dg.lastPromptTs = Math.round(t0 * 1000);
}
dg.sinceTurns = qRole.get(s.id, t0, 'assistant').c;
for (const t of qTools.all(s.id, t0)) {
const name = t.tool_name || 'tool';
dg.sinceTools[name] = (dg.sinceTools[name] || 0) + t.c;
dg.sinceToolCalls += t.c;
}
dg.sinceTokens = qTok.get(s.id, t0).t || 0;
}
const lastA = qLastAssistant.get(s.id, t0);
if (lastA && lastA.content) {
dg.lastAssistantText = clip(lastA.content);
dg.lastAssistantMd = clipRaw(lastA.content);
dg.lastAssistantTs = Math.round((Number(lastA.timestamp) || 0) * 1000);
}
rows.push({ directory: s.cwd || null, parsed: { stats: st, digest: dg } });
}
} catch { /* torn read / schema drift: keep previous */ } finally {
try { db.close(); } catch {}
}
hermesMemo = { key: ck.key, rows };
return rows;
}
// Cap how much of a transcript we load. A long-running agent's .jsonl can grow
// to hundreds of MB; reading it whole (and splitting into a line array) can OOM
// the single process. Above the cap we read only the tail (dropping the partial
// first line), which keeps the "since your last prompt" digest accurate while
// bounding memory — older cumulative totals for that one file become approximate.
const MAX_TRACE_BYTES = 12 * 1024 * 1024;
async function readTraceFile(p, size) {
if (size <= MAX_TRACE_BYTES) return fsp.readFile(p, 'utf8');
const fh = await fsp.open(p, 'r');
try {
const buf = Buffer.alloc(MAX_TRACE_BYTES);
await fh.read(buf, 0, MAX_TRACE_BYTES, size - MAX_TRACE_BYTES);
const s = buf.toString('utf8');
const nl = s.indexOf('\n');
return nl >= 0 ? s.slice(nl + 1) : s;
} finally { await fh.close(); }
}
async function statsFor(p, parser, quietMs = 0) {
const c = fileCache.get(p);
const now = Date.now();
// Dormant transcripts (untouched for a day) skip the stat entirely for a
// minute at a time: hundreds of FUSE getattr round-trips per rebuild were
// most of the Overview's first-open wait.
if (c && c.statAt && now - c.statAt < 60_000 && c.mtimeMs && now - c.mtimeMs > 86_400_000) return c.parsed;
let m;
try { m = await fsp.stat(p); } catch { return null; }
const key = `${m.mtimeMs}:${m.size}`;
if (c && c.key === key) { c.statAt = now; c.mtimeMs = m.mtimeMs; return c.parsed; }
// Don't read a file its owner is actively writing. OpenClaw's session fence
// fingerprints metadata at nanosecond precision, and on the FUSE bucket our
// reads can disturb what it sees — so while a fence-sensitive file is hot,
// serve the previous parse and re-read once writes have gone quiet.
if (quietMs && c && Date.now() - m.mtimeMs < quietMs) return c.parsed;
// Parsing is synchronous CPU on THE event loop. A big transcript being
// actively written changes every poll; re-parsing tens of MB once a second
// starves every other request (blank app). Heavy files therefore re-parse
// on a duty cycle: at most ~10% of wall time, capped at one parse per 10s.
if (c && c.cost > 150 && Date.now() - c.at < Math.min(10_000, c.cost * 10)) return c.parsed;
let parsed;
const t0 = Date.now();
try { parsed = parser(await readTraceFile(p, m.size)); } catch { return null; }
fileCache.set(p, { key, parsed, cost: Date.now() - t0, at: t0, statAt: now, mtimeMs: m.mtimeMs });
return parsed;
}
// Directory walks (readdir over FUSE) also add up when repeated per rebuild —
// remember each file list briefly. New transcript files appear within 5s.
const listMemo = new Map(); // key -> { ts, val: Promise<string[]> }
function memoList(key, fn) {
const c = listMemo.get(key);
if (c && Date.now() - c.ts < 5_000) return c.val;
const val = fn();
listMemo.set(key, { ts: Date.now(), val });
return val;
}
function claudeFiles() {
return memoList('claude', claudeFilesUncached);
}
async function claudeFilesUncached() {
const home = process.env.HOME || '';
const dirs = [process.env.CLAUDE_CONFIG_DIR, path.join(home, '.claude'), path.join(home, '.config', 'claude')]
.filter(Boolean).filter((d, i, a) => a.indexOf(d) === i);
const out = [];
for (const d of dirs) {
const proj = path.join(d, 'projects');
let projects = [];
try { projects = await fsp.readdir(proj, { withFileTypes: true }); } catch { continue; }
for (const e of projects) {
if (!e.isDirectory()) continue;
let files = [];
try { files = await fsp.readdir(path.join(proj, e.name)); } catch { continue; }
for (const f of files) if (f.endsWith('.jsonl')) out.push(path.join(proj, e.name, f));
}
}
return out;
}
function codexFiles() {
return memoList('codex', codexFilesUncached);
}
async function codexFilesUncached() {
const home = process.env.CODEX_HOME || path.join(process.env.HOME || '', '.codex');
const root = path.join(home, 'sessions');
const out = [];
const walk = async (dir, depth) => {
if (depth > 5) return;
let ents = [];
try { ents = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; }
for (const e of ents) {
const p = path.join(dir, e.name);
if (e.isDirectory()) await walk(p, depth + 1);
else if (e.name.startsWith('rollout-') && e.name.endsWith('.jsonl')) out.push(p);
}
};
await walk(root, 0);
return out;
}
const UUID_RE = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\.jsonl)?$/;
async function build() {
const sessions = store.list();
const byClaudeUuid = new Map(sessions.filter((s) => s.cli === 'claude' && s.sessionUuid).map((s) => [s.sessionUuid, s]));
const byCodexId = new Map(sessions.filter((s) => s.codexSessionId).map((s) => [s.codexSessionId, s]));
const perSession = new Map(); // session id -> aggregate stats
const digests = new Map(); // session id -> digest of the freshest file
const other = emptyStats();
const totals = emptyStats();
const attribute = (session, parsed) => {
if (!parsed) return;
const { stats, digest } = parsed;
mergeInto(totals, stats);
if (session) {
if (!perSession.has(session.id)) perSession.set(session.id, emptyStats());
mergeInto(perSession.get(session.id), stats);
const prev = digests.get(session.id);
if (!prev || stats.lastTs > prev._ts) digests.set(session.id, { ...digest, _ts: stats.lastTs });
} else {
mergeInto(other, stats);
}
};
const seenFiles = new Set(); // for fileCache eviction (rotated/deleted transcripts)
for (const p of await claudeFiles()) {
seenFiles.add(p);
const m = path.basename(p).match(UUID_RE);
attribute(m ? byClaudeUuid.get(m[1]) : null, await statsFor(p, parseClaude));
}
// Codex fallback: sessions created before capture-and-pin have no
// codexSessionId — attribute their rollouts by cwd when it's unambiguous.
const byCodexCwd = new Map();
for (const s of sessions.filter((x) => x.cli === 'codex')) {
const key = path.resolve(WORKSPACES_DIR, s.path ?? s.id);
byCodexCwd.set(key, byCodexCwd.has(key) ? 'ambiguous' : s);
}
for (const p of await codexFiles()) {
seenFiles.add(p);
const parsed = await statsFor(p, parseCodex);
if (!parsed) continue;
if (parsed.stats.subagent) continue; // Codex guardian subagent — not the user's thread
const m = path.basename(p).match(UUID_RE);
let session = m ? byCodexId.get(m[1]) : null;
if (!session && parsed.stats.cwd) {
const hit = byCodexCwd.get(parsed.stats.cwd);
if (hit && hit !== 'ambiguous') session = hit;
}
attribute(session, parsed);
}
// OpenClaw: every pane shares the ONE embedded agent (agent "main"), so its
// traces belong to the single OpenClaw session when unambiguous, else other.
const ocSessions = sessions.filter((s) => s.cli === 'openclaw');
const ocOwner = ocSessions.length === 1 ? ocSessions[0] : null;
for (const p of await openclawFiles()) {
seenFiles.add(p);
// OpenClaw state lives on LOCAL disk now (see entrypoint.sh), so reads no
// longer risk tripping its metadata fence like on FUSE — keep a small
// quiet margin anyway since its session fence is unusually touchy.
attribute(ocOwner, await statsFor(p, parseOpenClaw, 10_000));
}
// Evict cache entries for files that no longer exist (rotated Codex rollouts,
// deleted transcripts) so fileCache doesn't grow unbounded on a long-lived Space.
for (const k of fileCache.keys()) if (!seenFiles.has(k)) fileCache.delete(k);
// opencode: sessions attribute by their recorded directory (like codex cwd).
const opencodeByDir = new Map();
for (const s of sessions.filter((x) => x.cli === 'opencode')) {
const key = path.resolve(WORKSPACES_DIR, s.path ?? s.id);
opencodeByDir.set(key, opencodeByDir.has(key) ? 'ambiguous' : s);
}
for (const { directory, parsed } of readOpencode()) {
const hit = directory ? opencodeByDir.get(path.resolve(directory)) : null;
attribute(hit && hit !== 'ambiguous' ? hit : null, parsed);
}
// Hermes: same story — sessions record their cwd.
const hermesByDir = new Map();
for (const s of sessions.filter((x) => x.cli === 'hermes')) {
const key = path.resolve(WORKSPACES_DIR, s.path ?? s.id);
hermesByDir.set(key, hermesByDir.has(key) ? 'ambiguous' : s);
}
for (const { directory, parsed } of readHermes()) {
const hit = directory ? hermesByDir.get(path.resolve(directory)) : null;
attribute(hit && hit !== 'ambiguous' ? hit : null, parsed);
}
return { perSession, digests, other, totals, sessions };
}
function memoized() {
if (resultMemo.val && Date.now() - resultMemo.ts < TTL) return resultMemo.val;
const val = build().catch(() => ({ perSession: new Map(), digests: new Map(), other: emptyStats(), totals: emptyStats(), sessions: store.list() }));
resultMemo = { ts: Date.now(), val };
return val;
}
export async function buildTraces() {
const { perSession, totals, sessions } = await memoized();
// Every agent session gets a row, traced or not; files that belong to no
// live session (deleted panes, ambiguous attribution) only show in totals.
return {
sessions: sessions
.filter((s) => s.cli !== 'shell' && s.cli !== 'files')
.map((s) => ({ id: s.id, name: s.name, cli: s.cli, path: s.path, ...(perSession.get(s.id) || emptyStats()) }))
.sort((a, b) => b.lastTs - a.lastTs),
totals,
generatedAt: new Date().toISOString(),
};
}
/** Per-session "since your last prompt" digests, keyed by session id. */
export async function traceDigests() {
const { digests } = await memoized();
return digests;
}
/** Targeted digest for ONE session — parses only its own transcript files so
* the Overview can fill tiles progressively instead of waiting for the full
* build. Cheap for Claude (transcript filename == sessionUuid) and Codex (the
* pinned rollout); db-backed CLIs return null and ride the bulk pass. Parsed
* files land in the shared per-file cache, so nothing is read twice. */
export async function digestFor(s) {
try {
if (s.cli === 'claude' && s.sessionUuid) {
let best = null;
for (const p of await claudeFiles()) {
if (!path.basename(p).includes(s.sessionUuid)) continue;
const parsed = await statsFor(p, parseClaude);
if (parsed && (!best || parsed.stats.lastTs > best.stats.lastTs)) best = parsed;
}
return best ? best.digest : null;
}
if (s.cli === 'codex' && s.codexRollout && fs.existsSync(s.codexRollout)) {
const parsed = await statsFor(s.codexRollout, parseCodex);
if (parsed && !parsed.stats.subagent) return parsed.digest;
}
} catch { /* fall through to the bulk pass */ }
return null;
}