File size: 32,265 Bytes
604c3c1
 
 
 
a9cda9b
604c3c1
 
 
 
 
 
 
 
 
 
7d9aec4
604c3c1
436d14d
604c3c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d9aec4
 
 
 
a3d2eef
7d9aec4
 
a9cda9b
 
8e5d9a0
 
 
 
7d9aec4
a3d2eef
7c91582
8e5d9a0
a9cda9b
 
 
 
8e5d9a0
 
 
 
 
 
 
 
 
 
a9cda9b
 
7d9aec4
 
 
 
 
 
 
604c3c1
 
 
 
 
 
7d9aec4
604c3c1
 
 
 
 
 
 
 
 
 
 
 
 
7d9aec4
604c3c1
 
 
 
 
7c91582
604c3c1
 
 
 
 
 
7d9aec4
 
604c3c1
 
 
 
 
7d9aec4
 
 
a9cda9b
604c3c1
 
 
 
 
7d9aec4
 
 
 
 
604c3c1
 
7d9aec4
604c3c1
 
 
7d9aec4
 
604c3c1
 
7d9aec4
604c3c1
 
7c91582
604c3c1
 
 
 
 
68fe810
 
 
 
 
 
 
 
604c3c1
 
 
7d9aec4
 
 
 
a9cda9b
7d9aec4
 
 
 
7c91582
 
 
 
7d9aec4
604c3c1
 
 
 
 
 
 
7d9aec4
 
 
 
 
 
 
604c3c1
 
 
 
8e5d9a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
604c3c1
 
 
 
 
 
 
 
 
 
7c91582
604c3c1
7d9aec4
604c3c1
 
ad9c300
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0dd286d
 
 
 
ad9c300
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7661818
 
 
 
 
 
 
 
 
 
 
df0df18
 
 
 
 
 
 
 
 
 
 
 
 
7661818
 
 
 
 
df0df18
 
 
7661818
436d14d
df0df18
7661818
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a3d2eef
7661818
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
df0df18
 
 
 
 
 
 
 
 
 
 
436d14d
df0df18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a3d2eef
df0df18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ece21ed
 
 
 
 
85187c5
ece21ed
 
 
 
 
 
 
 
 
 
 
 
4ee54c8
0dd286d
 
 
 
 
 
604c3c1
 
 
0dd286d
4ee54c8
 
 
 
 
85187c5
 
 
 
 
7d9aec4
85187c5
ece21ed
0dd286d
7d9aec4
604c3c1
 
0dd286d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
604c3c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0dd286d
 
 
 
604c3c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d9aec4
 
604c3c1
 
 
7d9aec4
 
 
604c3c1
 
 
 
7d9aec4
 
604c3c1
 
 
 
 
ece21ed
604c3c1
ece21ed
604c3c1
 
 
a9cda9b
 
 
 
 
 
 
604c3c1
ece21ed
a9cda9b
 
68fe810
604c3c1
a9cda9b
 
 
 
 
 
604c3c1
 
ad9c300
 
 
 
 
ece21ed
436d14d
 
 
 
ad9c300
ece21ed
 
 
ad9c300
7661818
 
 
 
 
 
 
 
 
df0df18
 
 
 
 
 
 
 
 
 
 
7661818
 
7d9aec4
 
 
 
 
 
 
 
 
 
 
37775da
 
 
604c3c1
 
37775da
 
604c3c1
 
 
 
 
 
7d9aec4
 
 
 
604c3c1
e34ce4a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
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;
}