Spaces:
Running
Running
| 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 = 5_000; // Overview polls; 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: '', lastPromptTs: 0, lastAssistantText: '', lastAssistantMd: '', lastAssistantTs: 0, sinceTurns: 0, sinceToolCalls: 0, sinceTools: {}, sinceFiles: [], sinceTokens: 0 }; | |
| } | |
| 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; }; | |
| function digestPrompt(d, text, ts) { | |
| d.lastPromptText = clip(text); d.lastPromptTs = Date.parse(ts) || 0; | |
| d.sinceTurns = 0; d.sinceToolCalls = 0; d.sinceTools = {}; d.sinceFiles = []; d.sinceTokens = 0; | |
| // 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) { | |
| d.lastAssistantText = clip(text); | |
| 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' && p.cwd) st.cwd = p.cwd; // for cwd-fallback attribution | |
| 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++; | |
| break; | |
| default: | |
| } | |
| } else if (j.type === 'event_msg' && p.type === 'token_count' && p.info && p.info.total_token_usage) { | |
| tok = p.info.total_token_usage; | |
| } | |
| } | |
| 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 }; | |
| } | |
| async function openclawFiles() { | |
| 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 < 15_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.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 < 15_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.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; | |
| } | |
| async function statsFor(p, parser, quietMs = 0) { | |
| let m; | |
| try { m = await fsp.stat(p); } catch { return null; } | |
| const key = `${m.mtimeMs}:${m.size}`; | |
| const c = fileCache.get(p); | |
| if (c && c.key === key) 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; | |
| let parsed; | |
| try { parsed = parser(await fsp.readFile(p, 'utf8')); } catch { return null; } | |
| fileCache.set(p, { key, parsed }); | |
| return parsed; | |
| } | |
| async function claudeFiles() { | |
| 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; | |
| } | |
| async function codexFiles() { | |
| 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); | |
| } | |
| }; | |
| for (const p of await claudeFiles()) { | |
| 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()) { | |
| const parsed = await statsFor(p, parseCodex); | |
| if (!parsed) continue; | |
| 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()) { | |
| attribute(ocOwner, await statsFor(p, parseOpenClaw, 30_000)); // fence-sensitive: read only when quiet | |
| } | |
| // 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; | |
| } | |