@giamme/forge 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +106 -1766
  3. package/SKILL.md +3 -2
  4. package/VERSION +1 -1
  5. package/docs/README.md +26 -0
  6. package/docs/add-ons.md +33 -0
  7. package/docs/artifacts.md +69 -0
  8. package/docs/commands.md +315 -0
  9. package/docs/dataset.md +20 -0
  10. package/docs/decomposed-runs.md +268 -0
  11. package/docs/development.md +95 -0
  12. package/docs/forge-ui.md +92 -0
  13. package/docs/fractal.md +181 -0
  14. package/docs/install.md +160 -0
  15. package/docs/memory.md +156 -0
  16. package/docs/models.md +118 -0
  17. package/docs/reviews.md +231 -0
  18. package/docs/safety.md +55 -0
  19. package/docs/troubleshooting.md +54 -0
  20. package/forge +5 -0
  21. package/package.json +3 -2
  22. package/references/decompose.md +1 -1
  23. package/references/fractal.md +1 -1
  24. package/references/harnesses.md +18 -6
  25. package/references/solo.md +1 -1
  26. package/scripts/forge-dispatch.sh +7 -1
  27. package/scripts/forge-runs.py +121 -0
  28. package/scripts/forge-runtime.py +59 -6
  29. package/scripts/forge-version.py +54 -12
  30. package/scripts/forge_dataset/harvest.py +1 -1
  31. package/scripts/forge_dataset/importer.py +1 -1
  32. package/scripts/forge_fractal/__init__.py +2 -1
  33. package/ui/README.md +80 -20
  34. package/ui/THIRD_PARTY_NOTICES.md +108 -0
  35. package/ui/docs/api.md +99 -17
  36. package/ui/forge_ui/export.py +16 -6
  37. package/ui/forge_ui/fractal.py +1 -2
  38. package/ui/forge_ui/reader.py +95 -7
  39. package/ui/forge_ui/server.py +10 -1
  40. package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
  41. package/ui/forge_ui/static/app.js +279 -124
  42. package/ui/forge_ui/static/cloud3d.js +3 -2
  43. package/ui/forge_ui/static/cloudui.js +8 -5
  44. package/ui/forge_ui/static/convo.js +295 -0
  45. package/ui/forge_ui/static/datatable.js +232 -0
  46. package/ui/forge_ui/static/diffview.js +29 -10
  47. package/ui/forge_ui/static/findings3d.js +134 -0
  48. package/ui/forge_ui/static/graph.js +3 -3
  49. package/ui/forge_ui/static/icons.js +94 -0
  50. package/ui/forge_ui/static/index.html +25 -13
  51. package/ui/forge_ui/static/iso.js +70 -19
  52. package/ui/forge_ui/static/leaderboard.js +44 -48
  53. package/ui/forge_ui/static/panel.js +68 -32
  54. package/ui/forge_ui/static/runmodel.js +17 -1
  55. package/ui/forge_ui/static/scene3d.js +518 -43
  56. package/ui/forge_ui/static/search.js +2 -1
  57. package/ui/forge_ui/static/shell.js +137 -0
  58. package/ui/forge_ui/static/stagectl.js +150 -0
  59. package/ui/forge_ui/static/style.css +924 -469
  60. package/ui/forge_ui/static/theme.js +25 -0
  61. package/ui/forge_ui/transcripts.py +608 -0
@@ -87,8 +87,9 @@ export function createCloud(E, host, cb = {}) {
87
87
  TMP.copy(col).lerp(E.accent, c.hot > .5 ? c.hot : 0);
88
88
  const lit = statusOf(el.f) === 'PENDING' ? 0 : .3;
89
89
  E.paint(el.M, c.vis, Math.min(1, lit + c.hot * .9), c.hov, 1, 1, TMP, 0);
90
- el.M.body.color.lerp(col, (E.theme === 'light' ? .1 : .12) * c.vis);
91
- setRoof(el, col, Math.round((E.theme === 'light' ? .14 : .2) * c.vis * 50) / 50);
90
+ const light = E.theme === 'light', plain = statusOf(el.f) === 'PENDING';
91
+ el.M.body.color.lerp(col, (light ? plain ? .06 : .34 : .12) * c.vis);
92
+ setRoof(el, col, Math.round((light ? plain ? .12 : .55 : .2) * c.vis * 50) / 50);
92
93
  el.haloMat.uniforms.color.value.copy(col);
93
94
  const glow = (el.f.state === 'live' ? .14 + .12 * pulse : 0) * c.vis + (c.hot > .5 ? .12 : 0);
94
95
  el.haloMat.uniforms.opacity.value = glow; el.halo.visible = glow > .002; // no draw for a halo that is off
@@ -99,7 +99,7 @@ window.ForgeCloudUI = (() => {
99
99
  else if (root.requestFullscreen) root.requestFullscreen().catch(() => say('Full screen is not available here.'));
100
100
  });
101
101
  document.addEventListener('fullscreenchange', () => { full.textContent = document.fullscreenElement === root ? 'Exit full screen' : 'Full screen'; });
102
- const title = el('h1', 'Run cloud');
102
+ const title = el('h1', 'Run cloud', 'module-title');
103
103
  const left = el('div', undefined, 'cloud-bar-left'); left.append(title, count);
104
104
  const tools = el('div', undefined, 'cloud-bar-tools'); tools.append(filtersBtn, layoutSeg, shapeSeg, resetView, posMenu, full);
105
105
  if (ctx.viewToggle) tools.append(ctx.viewToggle());
@@ -225,7 +225,7 @@ window.ForgeCloudUI = (() => {
225
225
  const f = prefs.filter, O = CM.options(S.facts), P = CM.projects(S.runs);
226
226
  panel.replaceChildren();
227
227
  const head = el('div', undefined, 'cloud-panel-head');
228
- head.append(el('h2', 'Filters'), button('Reset', 'linkish', () => { prefs.filter = CM.emptyFilter(); apply({ rebuild: true }); }, 'Show every run'));
228
+ head.append(el('h2', 'Filters', 'plate'), button('Reset', 'linkish', () => { prefs.filter = CM.emptyFilter(); apply({ rebuild: true }); }, 'Show every run'));
229
229
  panel.append(head);
230
230
  const text = el('input'); text.type = 'search'; text.value = f.text; text.placeholder = 'goal, repo, run or task id, title, model…'; text.dataset.key = 'text';
231
231
  text.setAttribute('aria-label', 'Search the runs');
@@ -285,16 +285,19 @@ window.ForgeCloudUI = (() => {
285
285
  const head = el('div', undefined, 'cloud-card-head');
286
286
  head.append(el('span', STATE[f.state] ? STATE[f.state].label : f.state, 'chip st-' + (STATE[f.state] || { st: 'unknown' }).st),
287
287
  el('span', `${proj ? proj.name : RM.basename(r.repo)} · ${r.kind || 'plan'}`, 'muted'));
288
- head.append(button('×', 'cloud-card-close', () => ctx.route({ sel: null, focus: S.focus }), 'Close (Esc)'));
288
+ const shut = button('', 'cloud-card-close', () => ctx.route({ sel: null, focus: S.focus }), 'Close (Esc)');
289
+ shut.setAttribute('aria-label', 'Close (Esc)'); if (window.ForgeIcons) shut.append(window.ForgeIcons.icon('close'));
290
+ head.append(shut);
289
291
  card.append(head, el('h2', String(r.goal || 'Untitled run').split('\n')[0]), el('p', r.run_id || '', 'mono muted'));
290
292
  const leds = el('span', undefined, 'leds');
291
293
  for (const t of list(r.task_statuses).slice(0, 120)) { const l = el('i', undefined, 'led st-' + String(t.status || 'pending').toLowerCase()); l.title = `${t.id}: ${t.status}${t.title ? ' · ' + t.title : ''}`; leds.append(l); }
292
294
  card.append(leds);
293
295
  const dl = el('dl', undefined, 'fields');
294
296
  const mix = ['high', 'medium', 'low', 'none'].filter(k => f.diff[k]).map(k => `${f.diff[k]} ${k === 'none' ? 'unset' : k}`).join(' · ');
295
- for (const [k, v, mono] of [['Tasks', `${f.n}${mix ? ' · ' + mix : ''}`], ['Implementers', [...f.dwarf].join(', ') || '—', true], ['Reviewers', [...f.qa].join(', ') || '—', true],
297
+ const plates = specs => { if (!specs.length || !window.ForgeIcons) return specs.join(', ') || '—'; const s = el('span', undefined, 'plates'); for (const x of specs) s.append(window.ForgeIcons.modelPlate(x)); return s; };
298
+ for (const [k, v, mono] of [['Tasks', `${f.n}${mix ? ' · ' + mix : ''}`], ['Implementers', plates([...f.dwarf])], ['Reviewers', plates([...f.qa])],
296
299
  ['Started', when(r.created)], ['Updated', when(r.updated)], ['Final review', r.final_review || '—'], ['Verification', r.verification_status || '—'], ['Decompose', r.decompose_level || 'off']]) {
297
- dl.append(el('dt', k), el('dd', v, mono ? 'mono' : ''));
300
+ const dd = el('dd', undefined, mono ? 'mono' : ''); dd.append(v instanceof Node ? v : document.createTextNode(String(v))); dl.append(el('dt', k), dd);
298
301
  }
299
302
  card.append(dl);
300
303
  if (!shown) card.append(el('p', 'Hidden by the current filters.', 'warn'));
@@ -0,0 +1,295 @@
1
+ /* forge-ui conversation: what one dwarf or reviewer said and did, read from its agent CLI's log (transcripts.py), shown
2
+ in the stage's place like a diff. Its messages, its thinking folded, each command with its exit and output, each edit
3
+ with its patch and a link to the diff, other tool calls, the final answer and the tokens; a search, the errors one key
4
+ apart, and while the agent runs a poll every few seconds that follows the end. Defines window.ForgeConvo.render(host, ctx).
5
+ All API values enter the DOM as text.
6
+ ctx = { run, spec: {task, exec}, brief (the Execution, when the run lists it), live (false in an export), fetchJSON(path),
7
+ rerender(), closeHref, diffHash(spec) → hash } */
8
+ window.ForgeConvo = (() => {
9
+ 'use strict';
10
+ const list = v => Array.isArray(v) ? v : [];
11
+ const POLL = 2500; // ms between reads of a log being written
12
+ const FOLD = 14; // lines of an output shown before it folds
13
+ const cache = new Map(); // url → {data, error, pending, at, timer}
14
+ const views = new Map(); // url → {query, show, follow, open: Set, err}
15
+ const enc = encodeURIComponent;
16
+ function el(tag, text, cls) {
17
+ const n = document.createElement(tag);
18
+ if (text !== undefined && text !== null) n.textContent = String(text);
19
+ if (cls) n.className = cls;
20
+ return n;
21
+ }
22
+ const icon = name => window.ForgeIcons ? window.ForgeIcons.icon(name) : el('span');
23
+ const chip = (text, st) => el('span', text, 'chip st-' + st);
24
+ const num = v => Number.isFinite(Number(v)) && v !== null ? Number(v).toLocaleString() : '—';
25
+ const ICON = { prompt: 'flag', message: 'chat', reasoning: 'thought', command: 'terminal', edit: 'pencil', tool: 'tool', result: 'check', error: 'cross', text: 'keyboard', gap: 'chevron-down' };
26
+ // which kinds each filter key shows
27
+ const SHOW = [['all', 'All', null], ['said', 'Said', ['prompt', 'message', 'reasoning', 'result', 'error']], ['ran', 'Ran', ['command', 'tool', 'error']],
28
+ ['changed', 'Changed', ['edit']]];
29
+ const urlFor = (run, spec) => `/api/runs/${enc(run.key)}/executions/${enc(spec.exec)}/transcript`;
30
+
31
+ function read(ctx, url, entry) {
32
+ entry.pending = true;
33
+ ctx.fetchJSON(url).then(d => { entry.data = d; entry.error = null; }, e => { entry.error = e; })
34
+ .finally(() => { entry.at = performance.now(); entry.pending = false; ctx.rerender(); });
35
+ }
36
+ function load(ctx, url) {
37
+ let entry = cache.get(url);
38
+ if (!entry) {
39
+ entry = { pending: false, data: null, error: null, at: 0, timer: null };
40
+ cache.set(url, entry);
41
+ for (const old of cache.keys()) if (cache.size > 12 && old !== url) cache.delete(old); // the conversations opened last
42
+ read(ctx, url, entry);
43
+ }
44
+ // a finished log never changes; one still being written is read again when the view comes back to it, and a
45
+ // read that failed is tried again after a while
46
+ else if (ctx.live && !entry.pending && !entry.timer && performance.now() - entry.at > (entry.error ? 10 * POLL : POLL) &&
47
+ (entry.error || (entry.data && entry.data.state === 'running'))) read(ctx, url, entry);
48
+ return entry;
49
+ }
50
+
51
+ /* ---------- the frame: title, what was read, the tools; built once per conversation ---------- */
52
+ function frame(host, ctx, url, v) {
53
+ host.replaceChildren();
54
+ const f = { url, drawn: [], nodes: [] };
55
+ const top = el('div', undefined, 'diff-top convo-top');
56
+ f.title = el('h2'); f.chips = el('div', undefined, 'chips');
57
+ const close = el('a', undefined, 'diff-close button'); close.href = ctx.closeHref; close.append(icon('close'), document.createTextNode('Close'));
58
+ top.append(f.title, f.chips, close);
59
+ f.meta = el('p', undefined, 'diff-meta convo-meta muted');
60
+ const tools = el('div', undefined, 'convo-tools'); tools.setAttribute('role', 'toolbar'); tools.setAttribute('aria-label', 'Conversation');
61
+ const find = el('label', undefined, 'convo-find'); find.append(icon('search'));
62
+ f.search = el('input'); f.search.type = 'search'; f.search.placeholder = 'Search what it said and ran'; f.search.setAttribute('aria-label', 'Search the conversation');
63
+ f.search.value = v.query;
64
+ f.search.addEventListener('input', () => { v.query = f.search.value; filter(f, v); });
65
+ f.search.addEventListener('keydown', e => { if (e.key === 'Escape' && f.search.value) { e.stopPropagation(); f.search.value = ''; v.query = ''; filter(f, v); } });
66
+ find.append(f.search);
67
+ f.count = el('span', undefined, 'convo-count muted'); f.count.setAttribute('aria-live', 'polite');
68
+ f.show = el('div', undefined, 'seg'); f.show.setAttribute('role', 'group'); f.show.setAttribute('aria-label', 'Show');
69
+ for (const [key, label] of SHOW) {
70
+ const b = el('button', label); b.type = 'button'; b.dataset.show = key;
71
+ b.addEventListener('click', () => { v.show = key; filter(f, v); });
72
+ f.show.append(b);
73
+ }
74
+ f.errs = el('span', undefined, 'convo-errs');
75
+ f.err = el('button', 'Next error', 'convo-err'); f.err.type = 'button'; f.err.title = 'Next error (E)'; f.err.setAttribute('aria-keyshortcuts', 'E');
76
+ f.err.append(el('kbd', 'E'));
77
+ f.err.addEventListener('click', () => nextError(f, v));
78
+ f.follow = el('button', 'Follow', 'convo-follow latch'); f.follow.type = 'button'; f.follow.title = 'Keep the newest event in view while it runs';
79
+ f.follow.addEventListener('click', () => { v.follow = !v.follow; f.follow.setAttribute('aria-pressed', String(v.follow)); if (v.follow) end(f); });
80
+ tools.append(find, f.count, f.show, f.errs, f.err, f.follow);
81
+ f.status = el('div', undefined, 'convo-status');
82
+ f.list = el('ol', undefined, 'convo-list'); f.list.setAttribute('aria-label', 'Events');
83
+ host.append(top, f.meta, tools, f.status, f.list);
84
+ host.tabIndex = -1; host.setAttribute('aria-label', 'Conversation: / searches, E goes to the next error');
85
+ host.onkeydown = e => {
86
+ if (e.altKey || e.ctrlKey || e.metaKey || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return;
87
+ if (e.key === '/') { e.preventDefault(); f.search.focus(); f.search.select(); }
88
+ else if (e.key === 'e' || e.key === 'E') { e.preventDefault(); nextError(f, v); }
89
+ };
90
+ // a fold opened or closed stays so through a rebuild
91
+ f.list.addEventListener('toggle', e => { const d = e.target; if (d.dataset && d.dataset.fold) { if (d.open) v.open.add(d.dataset.fold); else v.open.delete(d.dataset.fold); } }, true);
92
+ return f;
93
+ }
94
+
95
+ function header(f, ctx, D) {
96
+ const spec = ctx.spec, run = ctx.run, b = ctx.brief || {};
97
+ const owner = D && D.owner || {}, role = (D && D.role) || b.role || String(spec.exec).split('-')[0];
98
+ const where = owner.kind === 'final_review' ? 'Final review' : spec.task === 'solo' || run.kind === 'solo' ? 'Solo run' :
99
+ owner.kind === 'shadow' ? `Task ${String(owner.id).split('/')[0]} · shadow` : spec.task ? `Task ${spec.task}` : 'Run';
100
+ const attempt = D && D.attempt != null ? D.attempt : b.attempt;
101
+ f.title.replaceChildren(el('span', 'Conversation · ', 'muted'), document.createTextNode(`${where} · ${role === 'qa' ? 'reviewer' : role}${attempt != null ? ' · attempt ' + attempt : ''}`));
102
+ const chips = [];
103
+ const s = (D && D.spec) || (b.model && b.model.spec);
104
+ if (s && window.ForgeIcons) chips.push(window.ForgeIcons.modelPlate(s));
105
+ // one lamp: running, else a review's verdict, else how it ended
106
+ const state = (D && D.state) || b.state;
107
+ if (state === 'running') chips.push(chip('running', 'running'));
108
+ else if (b.verdict) chips.push(chip('verdict ' + b.verdict, String(b.verdict).toLowerCase()));
109
+ else if (b.id && window.ForgeRunModel) { const st = window.ForgeRunModel.execStatus(b); chips.push(chip(st, String(st).toLowerCase())); }
110
+ f.chips.replaceChildren(...chips);
111
+ if (!D) { f.meta.replaceChildren(); return; }
112
+ // where it was read from, then the numbers in a describer window
113
+ const t = D.tokens, from = { log: 'read from its log', session: "read from claude's session file", none: 'no log' }[D.source] || '';
114
+ const n = list(D.events).filter(e => e.kind !== 'gap').length;
115
+ const desc = el('span', undefined, 'describer');
116
+ if (t) desc.append(el('span', `${num(t.input)} in`), ...(t.cached ? [el('span', `${num(t.cached)} cached`, 'dim')] : []), el('span', `${num(t.output)} out`));
117
+ if (D.cost != null) desc.append(el('span', '$' + Number(D.cost).toFixed(D.cost < 1 ? 3 : 2)));
118
+ desc.append(el('span', `${n} event${n === 1 ? '' : 's'}`));
119
+ if (D.session) desc.append(el('span', `session ${D.session}`, 'dim'));
120
+ f.meta.replaceChildren(el('span', [D.harness, from].filter(Boolean).join(' · ')), desc);
121
+ }
122
+
123
+ /* ---------- one event ---------- */
124
+ const lines = s => String(s || '').split('\n').length;
125
+ function out(parent, text, key, v, { title = 'Output', open = false, cls = '' } = {}) {
126
+ if (text == null || text === '') return;
127
+ const pre = el('pre', text, 'ev-pre ' + cls);
128
+ if (lines(text) <= FOLD && String(text).length < 1600) { parent.append(pre); return; }
129
+ const d = el('details', undefined, 'ev-fold'); d.dataset.fold = key;
130
+ d.open = open || v.open.has(key);
131
+ d.append(el('summary', `${title} · ${lines(text).toLocaleString()} lines`), pre); parent.append(d);
132
+ }
133
+ function patch(parent, text, key, v) {
134
+ const pre = el('pre', undefined, 'ev-pre ev-patch');
135
+ for (const line of String(text).split('\n')) pre.append(el('span', line, line[0] === '+' ? 'add' : line[0] === '-' ? 'del' : null));
136
+ if (lines(text) <= FOLD) { parent.append(pre); return; }
137
+ const d = el('details', undefined, 'ev-fold'); d.dataset.fold = key; d.open = v.open.has(key);
138
+ d.append(el('summary', `Patch · ${lines(text).toLocaleString()} lines`), pre); parent.append(d);
139
+ }
140
+ // a path in the worktree the agent ran in (or the run's repo) as the diff names it
141
+ function rel(D, path, run) {
142
+ for (const r of [D.repo, run && run.repo]) {
143
+ const root = String(r || '').replace(/\/+$/, '');
144
+ if (root && path.startsWith(root + '/')) return path.slice(root.length + 1);
145
+ }
146
+ return path;
147
+ }
148
+ function eventNode(ctx, D, ev, i, v, t0) {
149
+ const kind = ICON[ev.kind] ? ev.kind : 'text';
150
+ const li = el('li', undefined, `ev ev--${kind}${ev.error ? ' is-error' : ''}${ev.running ? ' is-running' : ''}`);
151
+ li.id = 'ev-' + i; li.tabIndex = -1; li.dataset.kind = kind;
152
+ const gut = el('div', undefined, 'ev-gut'); gut.setAttribute('aria-hidden', 'true'); gut.append(icon(ICON[kind]));
153
+ const body = el('div', undefined, 'ev-body');
154
+ const head = el('div', undefined, 'ev-head');
155
+ const when = Number.isFinite(ev.t) && Number.isFinite(t0) ? el('span', '+' + clock(ev.t - t0), 'ev-time') : null;
156
+ const key = i + ':';
157
+ if (kind === 'gap') { li.append(el('p', `… ${num(ev.count)} events left out: the log is too long to show whole …`, 'muted ev-gap')); return li; }
158
+ if (kind === 'prompt' || kind === 'reasoning') {
159
+ const d = el('details', undefined, 'ev-fold'); d.dataset.fold = key + 'x'; d.open = v.open.has(key + 'x');
160
+ const sum = el('summary'); sum.append(el('span', kind === 'prompt' ? 'The task as it got it' : 'Thinking', 'ev-label'), el('span', String(ev.text || '').split('\n').find(s => s.trim()) || '', 'ev-peek'));
161
+ if (when) sum.append(when);
162
+ d.append(sum, el('div', ev.text, 'ev-text' + (kind === 'reasoning' ? ' ev-thought' : '')));
163
+ body.append(d);
164
+ } else if (kind === 'message') {
165
+ head.append(el('span', D.role === 'qa' ? 'Reviewer' : 'Dwarf', 'ev-label')); if (when) head.append(when);
166
+ body.append(head, el('div', ev.text, 'ev-text'));
167
+ } else if (kind === 'command') {
168
+ const st = ev.running ? 'running' : ev.error ? 'fail' : ev.exit === 0 || ev.error === false ? 'pass' : ev.exit != null ? 'fail' : 'none';
169
+ const line = el('span', undefined, 'ev-cmdline'); line.append(el('i', undefined, 'lamp st-' + st), el('code', ev.command || '(no command)', 'ev-cmd'));
170
+ head.append(line);
171
+ if (ev.exit != null) head.append(el('span', 'exit ' + ev.exit, 'tag' + (ev.exit ? ' tag-bad' : '')));
172
+ else if (ev.running) head.append(el('span', 'running', 'tag'));
173
+ if (when) head.append(when);
174
+ body.append(head); out(body, ev.output, key + 'o', v, { open: !!ev.error });
175
+ } else if (kind === 'edit') {
176
+ head.append(el('span', 'Edited', 'ev-label'));
177
+ if (ev.tool && ev.tool.toLowerCase() !== 'edit') head.append(el('span', ev.tool, 'mono muted ev-toolname'));
178
+ for (const p of list(ev.files)) {
179
+ const r = rel(D, String(p), ctx.run);
180
+ if (ctx.diffHash && ctx.spec.task) { const a = el('a', r, 'mono ev-file'); a.href = ctx.diffHash({ kind: 'task', task: ctx.spec.task, source: ctx.spec.exec, file: r }); a.title = 'This file in the diff its review read'; head.append(a); }
181
+ else head.append(el('span', r, 'mono ev-file'));
182
+ }
183
+ if (list(ev.change).length) head.append(el('span', [...new Set(ev.change)].join(', '), 'tag'));
184
+ if (ev.error) head.append(el('span', 'failed', 'tag tag-bad'));
185
+ if (when) head.append(when);
186
+ body.append(head);
187
+ if (ev.patch) patch(body, ev.patch, key + 'p', v); else out(body, ev.input, key + 'i', v, { title: 'Input' });
188
+ out(body, ev.output, key + 'o', v, { open: !!ev.error });
189
+ } else if (kind === 'tool') {
190
+ head.append(el('span', 'Tool', 'ev-label'), el('span', ev.tool || '?', 'mono ev-toolname'));
191
+ if (ev.target) head.append(el('span', rel(D, String(ev.target), ctx.run), 'mono ev-file'));
192
+ if (ev.error) head.append(el('span', 'failed', 'tag tag-bad'));
193
+ if (when) head.append(when);
194
+ body.append(head); out(body, ev.input, key + 'i', v, { title: 'Input' }); out(body, ev.output, key + 'o', v, { open: !!ev.error });
195
+ } else if (kind === 'result') {
196
+ head.append(el('span', ev.error ? 'Ended with an error' : 'Final answer', 'ev-label'));
197
+ const t = ev.tokens; if (t) head.append(el('span', `${num(t.input)} in · ${num(t.output)} out`, 'muted ev-tokens'));
198
+ if (when) head.append(when);
199
+ body.append(head);
200
+ if (!ev.same) body.append(el('div', ev.text, 'ev-text'));
201
+ } else if (kind === 'error') {
202
+ head.append(el('span', 'Error', 'ev-label')); if (when) head.append(when);
203
+ body.append(head, el('div', ev.text, 'ev-text warn'));
204
+ } else {
205
+ head.append(el('span', ev.stream === 'stderr' ? 'stderr' : 'Log', 'ev-label')); if (when) head.append(when);
206
+ body.append(head); out(body, ev.text, key + 't', v, { title: 'Lines' });
207
+ }
208
+ li.append(gut, body);
209
+ li.dataset.find = [ev.text, ev.command, ev.output, ev.input, ev.patch, ev.tool, ev.target, list(ev.files).join(' ')].filter(Boolean).join('\n').toLowerCase();
210
+ return li;
211
+ }
212
+ function clock(s) { s = Math.max(0, Math.round(s)); const h = Math.floor(s / 3600), m = Math.floor(s / 60) % 60, x = s % 60; return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(x).padStart(2, '0'); }
213
+
214
+ /* ---------- the list: rebuilt from the first event that changed, so a live log only appends ---------- */
215
+ function events(f, ctx, D, v) {
216
+ const evs = list(D.events), sigs = evs.map(e => JSON.stringify(e));
217
+ let i = 0;
218
+ while (i < f.drawn.length && i < sigs.length && f.drawn[i] === sigs[i]) i++;
219
+ const grew = sigs.length > f.drawn.length || i < f.drawn.length;
220
+ for (const n of f.nodes.splice(i)) n.remove();
221
+ const t0 = evs.map(e => e.t).find(Number.isFinite);
222
+ for (let k = i; k < evs.length; k++) { const n = eventNode(ctx, D, evs[k], k, v, t0); f.nodes.push(n); f.list.append(n); }
223
+ // a final answer that repeats the agent's last message marks that message instead
224
+ const r = evs.findIndex(e => e.kind === 'result' && e.same);
225
+ let m = r - 1; while (m >= 0 && evs[m].kind !== 'message') m--;
226
+ const said = m >= 0 && f.nodes[m];
227
+ if (said && !said.querySelector('.ev-final')) said.querySelector('.ev-head').append(el('span', 'final answer', 'tag ev-final'));
228
+ f.drawn = sigs;
229
+ return grew && i < sigs.length;
230
+ }
231
+ function filter(f, v) {
232
+ const q = v.query.trim().toLowerCase(), kinds = (SHOW.find(s => s[0] === v.show) || SHOW[0])[2];
233
+ let shown = 0;
234
+ for (const n of f.nodes) {
235
+ const kind = n.dataset.kind;
236
+ const on = (!kinds || kinds.includes(kind)) && (!q || (n.dataset.find || '').includes(q));
237
+ n.hidden = !on; if (on && kind !== 'gap') shown++;
238
+ }
239
+ for (const b of f.show.children) b.setAttribute('aria-pressed', String(b.dataset.show === v.show));
240
+ const total = f.nodes.filter(n => n.dataset.kind !== 'gap').length;
241
+ f.count.textContent = q || kinds ? `${shown} of ${total}` : '';
242
+ const errs = f.nodes.filter(n => n.classList.contains('is-error')).length;
243
+ f.errs.replaceChildren(errs ? chip(`${errs} error${errs === 1 ? '' : 's'}`, 'fail') : el('span', 'no errors', 'muted'));
244
+ f.err.hidden = !errs;
245
+ }
246
+ function nextError(f, v) {
247
+ const errs = f.nodes.filter(n => n.classList.contains('is-error'));
248
+ if (!errs.length) return;
249
+ v.err = (v.err == null ? -1 : v.err) + 1;
250
+ if (v.err >= errs.length) v.err = 0;
251
+ const n = errs[v.err];
252
+ if (n.hidden) { v.query = ''; v.show = 'all'; f.search.value = ''; filter(f, v); }
253
+ for (const d of n.querySelectorAll('details')) d.open = true;
254
+ n.scrollIntoView({ block: 'center' }); n.focus({ preventScroll: true });
255
+ n.classList.remove('flash'); void n.offsetWidth; n.classList.add('flash');
256
+ }
257
+ function end(f) { const last = f.nodes[f.nodes.length - 1]; if (last) last.scrollIntoView({ block: 'end' }); }
258
+
259
+ /* ---------- the view ---------- */
260
+ function render(host, ctx) {
261
+ const url = urlFor(ctx.run, ctx.spec), entry = load(ctx, url);
262
+ let v = views.get(url);
263
+ if (!v) { v = { query: '', show: 'all', follow: true, open: new Set(), err: null }; views.set(url, v); }
264
+ let f = host.convoFrame;
265
+ const fresh = !f || f.url !== url;
266
+ if (fresh) { f = frame(host, ctx, url, v); host.convoFrame = f; }
267
+ const D = entry.data;
268
+ header(f, ctx, D);
269
+ if (!D) {
270
+ f.status.replaceChildren(el('p', entry.error ? (entry.error.snapshot ? 'This conversation is not in this export: it was made without prompts and texts.' :
271
+ `Could not load this conversation (${entry.error.status === 404 ? 'not found' : entry.error.message}).`) : 'Reading the log…', entry.error ? 'warn' : 'muted'));
272
+ return;
273
+ }
274
+ const running = D.state === 'running' && ctx.live !== false;
275
+ f.follow.hidden = !running; f.follow.setAttribute('aria-pressed', String(v.follow));
276
+ const status = [];
277
+ const ws = list(D.warnings);
278
+ if (ws.length) status.push(el('p', ws[0], 'warn'));
279
+ if (ws.length > 1) { const d = el('details', undefined, 'diff-warnings'); d.append(el('summary', `${ws.length - 1} more warning${ws.length === 2 ? '' : 's'}`), el('pre', ws.slice(1).join('\n'))); status.push(d); }
280
+ if (D.format === 'text' && list(D.events).length) status.push(el('p', `${D.harness || 'This harness'} left plain text, not a stream of events: its log is shown as it is.`, 'muted'));
281
+ if (!list(D.events).length) status.push(el('p', running ? 'Nothing logged yet: the agent has just started.' : D.source === 'none' ? 'No log was kept for this execution.' : 'Its log holds no conversation.', 'muted convo-empty'));
282
+ f.status.replaceChildren(...status);
283
+ const grew = events(f, ctx, D, v);
284
+ filter(f, v);
285
+ if (running && v.follow && (grew || fresh)) requestAnimationFrame(() => end(f));
286
+ // a log still being written: read it again shortly, while this view shows it
287
+ if (running && !entry.timer) {
288
+ entry.timer = setTimeout(() => {
289
+ entry.timer = null;
290
+ if (host.convoFrame === f && host.isConnected && !host.hidden && !entry.pending) read(ctx, url, entry);
291
+ }, POLL);
292
+ }
293
+ }
294
+ return { render, urlFor, cache };
295
+ })();
@@ -0,0 +1,232 @@
1
+ /* forge-ui data tables: sortable, reorderable columns that can be hidden, remembered per table.
2
+ A column is { id, label, title?, cls?, mono?, value(row) → number|string|null (sort key), cell(row) → Node|<td>|text,
3
+ sortable? (default true), hideable? (default true; the first column never hides), hidden? (starts hidden until shown) }.
4
+ Click a header to sort (ascending, descending, off); drag a header, or Alt+←/→ on it, to move it; the Columns menu shows
5
+ or hides columns and resets the table. Empty values always sort last.
6
+ window.ForgeTable = { render(parent, opts) → root, normPrefs, sortRows, compare, move } and module.exports for node tests.
7
+ opts = { id, label, columns, rows, prefs, savePrefs(prefs), rowClass(row)?, onRow(row, tr)?, scrollKey?, tools? (an element
8
+ that receives the Columns button instead of a toolbar row) }. */
9
+ (function (root, factory) {
10
+ const api = factory();
11
+ if (typeof module === 'object' && module.exports) module.exports = api;
12
+ else root.ForgeTable = api;
13
+ })(typeof self !== 'undefined' ? self : this, () => {
14
+ 'use strict';
15
+ const list = v => Array.isArray(v) ? v : [];
16
+ const empty = v => v === null || v === undefined || v === '' || (typeof v === 'number' && !Number.isFinite(v));
17
+
18
+ /* ---------- pure: preferences, ordering, sorting ---------- */
19
+ // Saved preferences against today's columns: unknown ids dropped, new columns slotted in at their default place
20
+ // (and hidden when they start hidden).
21
+ function normPrefs(prefs, columns) {
22
+ prefs = prefs && typeof prefs === 'object' ? prefs : {};
23
+ const ids = list(columns).map(c => c.id), known = new Set(ids);
24
+ const saved = list(prefs.order).filter((id, i, a) => known.has(id) && a.indexOf(id) === i);
25
+ const order = saved.slice();
26
+ ids.forEach((id, i) => {
27
+ if (order.includes(id)) return;
28
+ // after the nearest column that precedes it by default and is already placed
29
+ let at = 0;
30
+ for (let j = i - 1; j >= 0; j--) { const k = order.indexOf(ids[j]); if (k >= 0) { at = k + 1; break; } }
31
+ order.splice(at, 0, id);
32
+ });
33
+ const hideable = new Set(list(columns).filter((c, i) => i > 0 && c.hideable !== false).map(c => c.id));
34
+ const fresh = list(columns).filter(c => c.hidden && !saved.includes(c.id)).map(c => c.id);
35
+ const hidden = list(prefs.hidden).concat(fresh).filter((id, i, a) => hideable.has(id) && a.indexOf(id) === i);
36
+ const col = list(columns).find(c => prefs.sort && c.id === prefs.sort.id && c.sortable !== false);
37
+ const sort = col && (prefs.sort.dir === 'asc' || prefs.sort.dir === 'desc') ? { id: col.id, dir: prefs.sort.dir } : null;
38
+ return { order, hidden, sort };
39
+ }
40
+ // Numbers by value, text in natural order ("task-2" before "task-10"), numbers before text, empty last.
41
+ function compare(a, b) {
42
+ const ea = empty(a), eb = empty(b);
43
+ if (ea || eb) return ea === eb ? 0 : ea ? 1 : -1;
44
+ const na = typeof a === 'number', nb = typeof b === 'number';
45
+ if (na && nb) return a - b;
46
+ if (na !== nb) return na ? -1 : 1;
47
+ return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
48
+ }
49
+ // A stable sort by one column; empty values stay at the bottom whichever way it runs.
50
+ function sortRows(rows, column, dir) {
51
+ const items = list(rows).map((row, i) => ({ row, i, v: column && column.value ? column.value(row) : null }));
52
+ if (!column || (dir !== 'asc' && dir !== 'desc')) return items.map(x => x.row);
53
+ const sign = dir === 'desc' ? -1 : 1;
54
+ items.sort((x, y) => {
55
+ const ex = empty(x.v), ey = empty(y.v);
56
+ if (ex || ey) return ex === ey ? x.i - y.i : ex ? 1 : -1;
57
+ return sign * compare(x.v, y.v) || x.i - y.i;
58
+ });
59
+ return items.map(x => x.row);
60
+ }
61
+ // The order with `id` moved to index `to` (clamped).
62
+ function move(order, id, to) {
63
+ const out = list(order).filter(x => x !== id);
64
+ if (out.length === list(order).length) return list(order).slice();
65
+ out.splice(Math.max(0, Math.min(out.length, to)), 0, id);
66
+ return out;
67
+ }
68
+ const nextSort = (sort, id) => !sort || sort.id !== id ? { id, dir: 'asc' } : sort.dir === 'asc' ? { id, dir: 'desc' } : null;
69
+
70
+ /* ---------- DOM ---------- */
71
+ const menusOpen = new Set(); // tables whose Columns menu was open, kept across re-renders
72
+ function el(tag, text, cls) {
73
+ const n = document.createElement(tag);
74
+ if (text !== undefined && text !== null) n.textContent = String(text);
75
+ if (cls) n.className = cls;
76
+ return n;
77
+ }
78
+ const icon = (name) => window.ForgeIcons ? window.ForgeIcons.icon(name) : document.createTextNode('');
79
+
80
+ function render(parent, opts) {
81
+ const columns = list(opts.columns), byId = new Map(columns.map(c => [c.id, c]));
82
+ let prefs = normPrefs(opts.prefs, columns);
83
+ const save = next => { prefs = next; if (opts.savePrefs) opts.savePrefs({ order: next.order, hidden: next.hidden, sort: next.sort }); redraw(); };
84
+ let current = null;
85
+
86
+ function menu() {
87
+ const btn = el('button', undefined, 'dt-columns-btn'); btn.type = 'button';
88
+ btn.append(icon('columns'), el('span', 'Columns'));
89
+ btn.title = 'Show, hide and reset this table\'s columns';
90
+ const pop = el('div', undefined, 'dt-menu popover'); pop.popover = 'auto'; pop.id = 'dt-menu-' + opts.id;
91
+ pop.setAttribute('aria-label', `Columns of ${opts.label || opts.id}`);
92
+ btn.popoverTargetElement = pop; btn.setAttribute('aria-haspopup', 'true');
93
+ const head = el('div', undefined, 'dt-menu-head'); head.append(el('span', 'Columns', 'plate'));
94
+ const reset = el('button', 'Reset', 'dt-reset'); reset.type = 'button';
95
+ reset.title = 'Default order, every column shown, no sort';
96
+ reset.addEventListener('click', () => save(normPrefs({}, columns)));
97
+ head.append(reset); pop.append(head);
98
+ const ul = el('ul', undefined, 'dt-menu-list');
99
+ prefs.order.forEach((id, i) => {
100
+ const c = byId.get(id), li = el('li'), lab = el('label'), box = el('input');
101
+ box.type = 'checkbox'; box.checked = !prefs.hidden.includes(id);
102
+ box.disabled = columns[0].id === id || c.hideable === false;
103
+ box.addEventListener('change', () => save({ ...prefs, hidden: box.checked ? prefs.hidden.filter(x => x !== id) : prefs.hidden.concat(id) }));
104
+ lab.append(box, el('span', c.label));
105
+ const up = el('button', undefined, 'dt-step'); up.type = 'button'; up.append(icon('chevron-up')); up.disabled = i === 0;
106
+ up.setAttribute('aria-label', `Move ${c.label} left`); up.addEventListener('click', () => save({ ...prefs, order: move(prefs.order, id, i - 1) }));
107
+ const down = el('button', undefined, 'dt-step'); down.type = 'button'; down.append(icon('chevron-down')); down.disabled = i === prefs.order.length - 1;
108
+ down.setAttribute('aria-label', `Move ${c.label} right`); down.addEventListener('click', () => save({ ...prefs, order: move(prefs.order, id, i + 1) }));
109
+ li.append(lab, up, down); ul.append(li);
110
+ });
111
+ pop.append(ul);
112
+ pop.addEventListener('toggle', e => {
113
+ if (e.newState === 'open') {
114
+ menusOpen.add(opts.id);
115
+ const r = btn.getBoundingClientRect(), w = pop.offsetWidth || 260;
116
+ pop.style.top = Math.round(r.bottom + 6) + 'px';
117
+ pop.style.left = Math.round(Math.max(8, Math.min(window.innerWidth - w - 8, r.right - w))) + 'px';
118
+ } else menusOpen.delete(opts.id);
119
+ });
120
+ return [btn, pop];
121
+ }
122
+
123
+ function header(shown) {
124
+ const thead = el('thead'), tr = el('tr');
125
+ shown.forEach((c, i) => {
126
+ const th = el('th', undefined, c.cls || ''); th.scope = 'col'; th.dataset.col = c.id;
127
+ if (c.title) th.title = c.title;
128
+ const sorted = prefs.sort && prefs.sort.id === c.id;
129
+ if (sorted) th.setAttribute('aria-sort', prefs.sort.dir === 'asc' ? 'ascending' : 'descending');
130
+ th.draggable = true;
131
+ const lead = c.sortable === false ? el('span', undefined, 'th-label') : el('button', undefined, 'th-btn');
132
+ if (c.sortable !== false) {
133
+ lead.type = 'button';
134
+ lead.addEventListener('click', () => save({ ...prefs, sort: nextSort(prefs.sort, c.id) }));
135
+ }
136
+ lead.append(el('span', c.label, 'th-text'));
137
+ if (c.sortable !== false) {
138
+ const mark = el('span', undefined, 'th-sort' + (sorted ? ' on' : ''));
139
+ mark.append(icon(sorted ? (prefs.sort.dir === 'asc' ? 'sort-asc' : 'sort-desc') : 'sort'));
140
+ lead.append(mark);
141
+ }
142
+ lead.addEventListener('keydown', e => {
143
+ if (!e.altKey || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
144
+ e.preventDefault();
145
+ const at = prefs.order.indexOf(c.id), to = e.key === 'ArrowLeft' ? at - 1 : at + 1;
146
+ if (to < 0 || to >= prefs.order.length) return;
147
+ save({ ...prefs, order: move(prefs.order, c.id, to) });
148
+ current.querySelector(`th[data-col="${CSS.escape(c.id)}"] .th-btn, th[data-col="${CSS.escape(c.id)}"] .th-label`)?.focus();
149
+ });
150
+ th.addEventListener('dragstart', e => { e.dataTransfer.setData('text/plain', c.id); e.dataTransfer.effectAllowed = 'move'; th.classList.add('dragging'); });
151
+ th.addEventListener('dragend', () => { th.classList.remove('dragging'); for (const n of thead.querySelectorAll('.drop-before, .drop-after')) n.classList.remove('drop-before', 'drop-after'); });
152
+ th.addEventListener('dragover', e => {
153
+ e.preventDefault(); e.dataTransfer.dropEffect = 'move';
154
+ const r = th.getBoundingClientRect(), after = e.clientX > r.left + r.width / 2;
155
+ th.classList.toggle('drop-after', after); th.classList.toggle('drop-before', !after);
156
+ });
157
+ th.addEventListener('dragleave', () => th.classList.remove('drop-before', 'drop-after'));
158
+ th.addEventListener('drop', e => {
159
+ e.preventDefault();
160
+ const id = e.dataTransfer.getData('text/plain'); if (!byId.has(id) || id === c.id) return;
161
+ const r = th.getBoundingClientRect(), after = e.clientX > r.left + r.width / 2;
162
+ const rest = prefs.order.filter(x => x !== id), at = rest.indexOf(c.id) + (after ? 1 : 0);
163
+ save({ ...prefs, order: move(prefs.order, id, at) });
164
+ });
165
+ th.append(lead);
166
+ if (i === 0) th.classList.add('dt-first');
167
+ tr.append(th);
168
+ });
169
+ thead.append(tr);
170
+ return thead;
171
+ }
172
+
173
+ function build() {
174
+ const node = el('div', undefined, 'dt');
175
+ node.dataset.table = opts.id;
176
+ const shown = prefs.order.filter(id => !prefs.hidden.includes(id)).map(id => byId.get(id));
177
+ const [btn, pop] = menu();
178
+ if (opts.tools) opts.tools.replaceChildren(btn);
179
+ else { const bar = el('div', undefined, 'dt-bar'); if (prefs.sort || prefs.hidden.length) bar.append(status()); bar.append(btn); node.append(bar); }
180
+ node.append(pop);
181
+ const wrap = el('div', undefined, 'table-scroll');
182
+ if (opts.scrollKey) wrap.dataset.scroll = opts.scrollKey;
183
+ wrap.tabIndex = 0; wrap.setAttribute('role', 'region'); wrap.setAttribute('aria-label', opts.label || opts.id);
184
+ const table = el('table', undefined, opts.className || '');
185
+ if (opts.label) table.append(el('caption', opts.label, 'visually-hidden'));
186
+ table.append(header(shown));
187
+ const body = el('tbody');
188
+ const col = prefs.sort && byId.get(prefs.sort.id);
189
+ for (const row of sortRows(opts.rows, col, prefs.sort && prefs.sort.dir)) {
190
+ const tr = el('tr', undefined, opts.rowClass ? opts.rowClass(row) || '' : '');
191
+ for (const c of shown) {
192
+ let v = c.cell ? c.cell(row) : c.value ? c.value(row) : null;
193
+ let td;
194
+ if (v instanceof Node && v.nodeName === 'TD') td = v;
195
+ else {
196
+ td = el('td', undefined, [c.cls, c.mono ? 'mono' : ''].filter(Boolean).join(' '));
197
+ td.append(v instanceof Node ? v : document.createTextNode(empty(v) ? '—' : String(v)));
198
+ }
199
+ td.dataset.col = c.id;
200
+ tr.append(td);
201
+ }
202
+ if (opts.onRow) opts.onRow(row, tr);
203
+ body.append(tr);
204
+ }
205
+ table.append(body); wrap.append(table);
206
+ // a fade on each edge that has more table past it
207
+ const frame = el('div', undefined, 'dt-frame'); frame.append(wrap); node.append(frame);
208
+ const edges = () => { const end = wrap.scrollWidth - wrap.clientWidth; frame.classList.toggle('more-left', wrap.scrollLeft > 1); frame.classList.toggle('more-right', wrap.scrollLeft < end - 1); };
209
+ wrap.addEventListener('scroll', edges, { passive: true });
210
+ if (typeof ResizeObserver !== 'undefined') new ResizeObserver(edges).observe(wrap);
211
+ if (menusOpen.has(opts.id)) queueMicrotask(() => { if (pop.isConnected && pop.showPopover) try { pop.showPopover(); } catch (_) { /* already open */ } });
212
+ return node;
213
+ }
214
+ function status() {
215
+ const s = el('span', undefined, 'dt-status muted');
216
+ const bits = [];
217
+ if (prefs.sort) bits.push(`sorted by ${byId.get(prefs.sort.id).label}, ${prefs.sort.dir === 'asc' ? 'ascending' : 'descending'}`);
218
+ if (prefs.hidden.length) bits.push(`${prefs.hidden.length} hidden`);
219
+ s.textContent = bits.join(' · ');
220
+ return s;
221
+ }
222
+ function redraw() {
223
+ const next = build();
224
+ current.replaceWith(next); current = next;
225
+ }
226
+ current = build();
227
+ parent.append(current);
228
+ return current;
229
+ }
230
+
231
+ return { render, normPrefs, sortRows, compare, move, nextSort };
232
+ });