@double_geste/agent-standup 1.25.5 → 1.25.7
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.
- package/package.json +1 -1
- package/public/index.html +296 -110
- package/public/locales/da.json +3 -2
- package/public/locales/de.json +3 -2
- package/public/locales/en.json +3 -2
- package/public/locales/es.json +3 -2
- package/public/locales/ja.json +3 -2
- package/public/locales/pt-BR.json +3 -2
- package/public/locales/zh-CN.json +3 -2
- package/public/locales/zh-TW.json +3 -2
- package/server.js +157 -85
package/server.js
CHANGED
|
@@ -50,30 +50,44 @@ const resultText = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.map(
|
|
|
50
50
|
const IMG_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']), mediaMemo = new Map();
|
|
51
51
|
const isImg = (x) => !!x && x.type === 'image' && !!x.source && x.source.type === 'base64' && typeof x.source.data === 'string' && IMG_TYPES.has(x.source.media_type);
|
|
52
52
|
const resultImages = (c) => (Array.isArray(c) ? c.filter(isImg) : []);
|
|
53
|
+
const mtimeOf = (f) => { try { return fs.statSync(f).mtimeMs; } catch { return 0; } }; // 0 for a file that is gone, or a symlink that points nowhere: never an exception
|
|
54
|
+
// Every sub-agent transcript of a session: <id>/subagents/*.jsonl, plus the ones a workflow launched, one folder deeper in <id>/subagents/workflows/<run>/agent-*.jsonl (each run also keeps a journal.jsonl, which is not an agent).
|
|
55
|
+
function subFiles(proj, id) {
|
|
56
|
+
const sd = path.join(ROOT, proj, id, 'subagents'), out = [], seen = new Set(['main', 'user']); out.dirs = [sd]; out.journals = [];
|
|
57
|
+
const add = (dir, f, run) => { let aid = f.replace(/^agent-/, '').replace(/\.jsonl$/, '').replace(/[^\w.-]/g, '_'); const id0 = aid; if (seen.has(aid)) aid = (run || 'agent').replace(/[^\w.-]/g, '_') + '.' + aid; seen.add(aid); out.push({ file: path.join(dir, f), aid, id0, run }); }; // an id is the file name; the same name in two folders gets its run in front
|
|
58
|
+
let names; try { names = fs.readdirSync(sd); } catch { return out; }
|
|
59
|
+
for (const f of names) if (f.endsWith('.jsonl')) add(sd, f, '');
|
|
60
|
+
if (!names.includes('workflows')) return out;
|
|
61
|
+
const wd = path.join(sd, 'workflows'); let runs = []; try { runs = fs.readdirSync(wd).sort(); } catch {} out.dirs.push(wd);
|
|
62
|
+
for (const run of runs) {
|
|
63
|
+
const dir = path.join(wd, run); let fl; try { fl = fs.readdirSync(dir).sort(); } catch { continue; } out.dirs.push(dir);
|
|
64
|
+
for (const f of fl) { if (/^agent-.*\.jsonl$/.test(f)) add(dir, f, run); else if (f === 'journal.jsonl') out.journals.push({ file: path.join(dir, f), run }); }
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
53
68
|
function mediaFile(proj, sid, agent) {
|
|
54
69
|
if (agent === 'main') return path.join(ROOT, proj, sid + '.jsonl');
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
return hit ? path.join(dir, hit) : null;
|
|
70
|
+
const hit = subFiles(proj, sid).find((x) => x.aid === agent);
|
|
71
|
+
return hit ? hit.file : null;
|
|
58
72
|
}
|
|
59
73
|
function toolImage(file, toolId, n) { // looks for the tool's result line in the transcript, 8 MB at a time; the few pictures asked last are kept in memory
|
|
60
|
-
const key = file + '|' + toolId + '|' + n
|
|
61
|
-
const needle = Buffer.from(toolId), mark = Buffer.from('"image"'); let fd, found = null, carry = Buffer.alloc(0), pos = 0;
|
|
74
|
+
const key = file + '|' + toolId + '|' + n, memo = mediaMemo.get(key); if (memo && memo.buf) return memo;
|
|
75
|
+
const needle = Buffer.from('"' + toolId + '"'), mark = Buffer.from('"image"'), until = Date.now() + 2000; let fd, found = null, carry = Buffer.alloc(0), pos = 0, size = 0; // the id between its quotes, so a made-up one-letter id does not match on every line; and never more than 2 s of searching
|
|
62
76
|
try {
|
|
63
|
-
fd = fs.openSync(file, 'r');
|
|
64
|
-
while (pos < size && !found) {
|
|
77
|
+
fd = fs.openSync(file, 'r'); size = fs.fstatSync(fd).size; if (memo && memo.miss === size) return null; // already searched in vain, and nothing was written since
|
|
78
|
+
while (pos < size && !found && Date.now() < until) {
|
|
65
79
|
const buf = Buffer.alloc(Math.min(8 * 1024 * 1024, size - pos)); fs.readSync(fd, buf, 0, buf.length, pos); pos += buf.length;
|
|
66
80
|
const all = carry.length ? Buffer.concat([carry, buf]) : buf; let from = 0;
|
|
67
|
-
for (let hit; !found && (hit = all.indexOf(needle, from)) >= 0;) {
|
|
81
|
+
for (let hit; !found && (hit = all.indexOf(needle, from)) >= 0 && Date.now() < until;) {
|
|
68
82
|
const a = all.lastIndexOf(10, hit) + 1, b = all.indexOf(10, hit); if (b < 0) break; // the line goes on in the next chunk
|
|
69
|
-
from = b + 1;
|
|
83
|
+
from = b + 1; if (all.subarray(a, b).indexOf(mark) < 0) continue;
|
|
70
84
|
let r; try { r = JSON.parse(all.subarray(a, b).toString('utf8')); } catch { continue; }
|
|
71
85
|
for (const blk of blocks(r)) if (blk.type === 'tool_result' && blk.tool_use_id === toolId) { const im = resultImages(blk.content)[n]; if (im) found = { type: im.source.media_type, buf: Buffer.from(im.source.data, 'base64') }; }
|
|
72
86
|
}
|
|
73
87
|
carry = all.subarray(all.lastIndexOf(10) + 1);
|
|
74
88
|
}
|
|
75
89
|
} catch {} finally { if (fd != null) fs.closeSync(fd); }
|
|
76
|
-
|
|
90
|
+
mediaMemo.delete(key); mediaMemo.set(key, found || { miss: size }); if (mediaMemo.size > 12) mediaMemo.delete(mediaMemo.keys().next().value);
|
|
77
91
|
return found;
|
|
78
92
|
}
|
|
79
93
|
const RESULT_MAX = { AskUserQuestion: 900, Skill: 300, ToolSearch: 500, Read: 1900, Bash: 1300, Grep: 900, Glob: 800, WebFetch: 800, WebSearch: 800, Edit: 200, MultiEdit: 200, Write: 200 };
|
|
@@ -95,10 +109,8 @@ function listSessions0() {
|
|
|
95
109
|
try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl')); } catch { continue; }
|
|
96
110
|
for (const f of files) {
|
|
97
111
|
const id = f.replace(/\.jsonl$/, '');
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const agents = fs.existsSync(subDir) ? fs.readdirSync(subDir).filter((x) => x.endsWith('.jsonl')).length : 0;
|
|
101
|
-
rows.push({ proj, id, mtime: st.mtimeMs, size: st.size, agents });
|
|
112
|
+
let st; try { st = fs.statSync(path.join(dir, f)); } catch { continue; } // a link to a transcript that is gone
|
|
113
|
+
rows.push({ proj, id, mtime: st.mtimeMs, size: st.size, agents: subFiles(proj, id).length });
|
|
102
114
|
}
|
|
103
115
|
}
|
|
104
116
|
rows.sort((a, b) => b.mtime - a.mtime);
|
|
@@ -114,27 +126,48 @@ function stamp(proj, id) {
|
|
|
114
126
|
const base = path.join(ROOT, proj); let n = 0;
|
|
115
127
|
const add = (f) => { try { const s = fs.statSync(f); n += s.mtimeMs + s.size; } catch {} };
|
|
116
128
|
add(path.join(base, id + '.jsonl'));
|
|
117
|
-
const
|
|
118
|
-
|
|
129
|
+
const subs = subFiles(proj, id); // the transcripts and the folders they are in (a folder changes when a file appears in it, e.g. an agent's meta file), not every file around them
|
|
130
|
+
for (const x of subs) add(x.file); for (const x of subs.journals) add(x.file); for (const d of subs.dirs) n += mtimeOf(d);
|
|
119
131
|
return n;
|
|
120
132
|
}
|
|
121
|
-
const tlCache = new Map(); // "proj/id" → { st (file stamp), tl, at, ms, files[], json?, gz? }
|
|
133
|
+
const tlCache = new Map(); // "proj/id" → { st (file stamp), tl, at, ms, files[], feed, sq, json?, gz?, idle? }
|
|
122
134
|
const MAX_CACHED_SESSIONS = 3;
|
|
123
135
|
function timelineEntry(proj, id) {
|
|
124
136
|
const key = proj + '/' + id, st = stamp(proj, id), hit = tlCache.get(key), now = Date.now();
|
|
125
|
-
if (hit && hit.st === st && !(hit.recentUntil && now > hit.recentUntil))
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
const entry = { st, tl, at: Date.now(), ms: Date.now() - t0, files, recentUntil: tl.recentUntil || 0 };
|
|
137
|
+
if (hit && ((hit.st === st && !(hit.recentUntil && now > hit.recentUntil)) || now - hit.at < Math.max(250, hit.ms * 2))) { // nothing was written (a sub-agent's 4-minute silence rule can flip without any file change); or a very big session, which is not rebuilt on every poll
|
|
138
|
+
tlCache.delete(key); tlCache.set(key, hit); return hit; // asked for again: the last one to be dropped
|
|
139
|
+
}
|
|
140
|
+
const t0 = Date.now(), files = [], tl = buildTimeline(proj, id, files), feed = feedStep(hit && hit.feed, tl, files);
|
|
141
|
+
const entry = { st, tl, at: Date.now(), ms: Date.now() - t0, files, feed, sq: feed.sq, recentUntil: tl.recentUntil || 0 };
|
|
130
142
|
tlCache.delete(key); tlCache.set(key, entry);
|
|
131
|
-
while (tlCache.size > MAX_CACHED_SESSIONS) { // free the memory of sessions nobody is looking at any more
|
|
143
|
+
while (tlCache.size > MAX_CACHED_SESSIONS) { // free the memory of sessions nobody is looking at any more (their live feed goes with them: a page still on one of them is told to load it again)
|
|
132
144
|
const [oldKey, old] = tlCache.entries().next().value; tlCache.delete(oldKey);
|
|
133
145
|
for (const f of old.files) fileStates.delete(f);
|
|
134
146
|
}
|
|
135
147
|
return entry;
|
|
136
148
|
}
|
|
149
|
+
// ---- Live feed. The timeline is sorted again at each build and events already sent can change (an order typed mid-turn is stamped at typing time and lands before what the page has; a sub-agent's last line becomes its report; a tool call joins an earlier beat),
|
|
150
|
+
// so a page cannot ask for "what comes after index n". Every event has a key `k` that never changes, each build that changes something gets the next sequence number, and a page asks for the events that are new or different since the number it holds.
|
|
151
|
+
// `ep` names this memory: it is a new one after a restart, after the session left the cache, or when a transcript was rewritten, and the page then loads the whole session again.
|
|
152
|
+
const sigOf = (e) => e.kind + '|' + e.agent + '|' + (e.to || '') + '|' + (e.end || '') + '|' + (e.kind === 'tools' ? e.tools.length + ':' + e.tools[e.tools.length - 1].id : e.kind === 'usage' ? e.u.i + ',' + e.u.o + ',' + e.u.r + ',' + e.u.w + ',' + e.model : e.kind === 'order' ? e.toolId + ',' + e.bg + ',' + e.title : e.kind === 'finish' ? e.ts : ''); // everything about an event that can differ from one build to the next. Its text, its tool calls, its results and its time never do: they come from one transcript record (same key = same record); and `t` moves only when time zero does, which feedStep looks at once for all
|
|
153
|
+
function feedStep(prev, tl, files) {
|
|
154
|
+
let f = prev && files.every((x) => { const was = prev.gens.get(x); return was === undefined || was === (fileStates.get(x) || {}).gen; }) ? prev : null; // a transcript read again from its start numbers its events again: the keys no longer mean the same thing
|
|
155
|
+
for (let pass = 0; ; pass++) {
|
|
156
|
+
if (!f) f = { ep: crypto.randomBytes(6).toString('hex'), seq: 0, known: new Map(), gens: new Map() };
|
|
157
|
+
const n = tl.events.length, sq = new Uint32Array(n), seq = f.seq + 1, before = f.known.size, z = tl.events.find((e) => e.kind !== 'usage' && e.kind !== 'finish' && e.kind !== 'compact'), t0 = z ? z.ts : 0, moved = f.t0 !== t0; let hits = 0, changed = false; f.t0 = t0; // time zero moved (something now comes before the first line): every `t` is different
|
|
158
|
+
for (let i = 0; i < n; i++) {
|
|
159
|
+
const e = tl.events[i], s = sigOf(e), was = f.known.get(e.k);
|
|
160
|
+
if (was) { hits++; if (was.s !== s || moved) { was.s = s; was.q = seq; changed = true; } sq[i] = was.q; } else { f.known.set(e.k, { s, q: seq }); sq[i] = seq; changed = true; }
|
|
161
|
+
}
|
|
162
|
+
if (hits < before && !pass) { f = null; continue; } // an event the page may hold no longer exists: nothing can tell it to drop it, so everything starts over
|
|
163
|
+
if (changed) f.seq = seq;
|
|
164
|
+
for (const x of files) f.gens.set(x, (fileStates.get(x) || {}).gen);
|
|
165
|
+
f.sq = sq; tl.ep = f.ep; tl.seq = f.seq; return f;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
137
168
|
const timeline = (proj, id) => timelineEntry(proj, id).tl;
|
|
169
|
+
const demoEntry = () => { const tl = require('./demo.js')(); tl.events.forEach((e, i) => { e.k = 'd:' + i; }); tl.ep = 'demo'; tl.seq = 1; return { tl }; }; // the built-in session never changes: one sequence number for all of it
|
|
170
|
+
const feedSince = (entry, after) => { const ev = entry.tl.events, sq = entry.sq, out = []; if (after < entry.tl.seq) for (let i = 0; i < ev.length; i++) if ((sq ? sq[i] : 1) > after) out.push(ev[i]); return out; }; // in timeline order; nothing to scan when the page is up to date
|
|
138
171
|
|
|
139
172
|
// ---- Board: one cheap row per recent session (state from the tail of its transcript), so many parallel sessions can be watched at once.
|
|
140
173
|
const readTail = (file, bytes) => { const fd = fs.openSync(file, 'r'); try { const sz = fs.fstatSync(fd).size, n = Math.min(bytes, sz), buf = Buffer.alloc(n); fs.readSync(fd, buf, 0, n, sz - n); return { text: buf.toString('utf8'), cut: sz > bytes }; } finally { fs.closeSync(fd); } };
|
|
@@ -145,23 +178,32 @@ let boardMemo = { at: 0, rows: [] };
|
|
|
145
178
|
function parseTail(file, st) {
|
|
146
179
|
const sig = st.mtimeMs + ':' + st.size, hit = boardCache.get(file); if (hit && hit.sig === sig) return hit;
|
|
147
180
|
const { text, cut } = readTail(file, 192 * 1024), lines = text.split('\n'); if (cut) lines.shift();
|
|
148
|
-
const pend = new Map(); let lastType = null, lastTool = false, lastTs = 0;
|
|
181
|
+
const pend = new Map(); let lastType = null, lastTool = false, lastTs = 0, custom = (hit && hit.custom) || null, ai = (hit && hit.ai) || null, first = hit ? hit.first : undefined;
|
|
149
182
|
for (const l of lines) {
|
|
150
183
|
if (!l) continue; let r; try { r = JSON.parse(l); } catch { continue; }
|
|
184
|
+
if (r.type === 'custom-title' && typeof r.customTitle === 'string') custom = r.customTitle; else if (r.type === 'ai-title' && typeof r.aiTitle === 'string') ai = r.aiTitle; // a session is renamed, or named, long after its row was first shown: the latest one read wins
|
|
151
185
|
const t = Date.parse(r.timestamp) || 0, bl = blocks(r);
|
|
152
186
|
if (r.type === 'assistant' || r.type === 'user') { lastType = r.type; lastTool = bl.some((b) => b.type === 'tool_use'); if (t) lastTs = t; }
|
|
153
187
|
for (const b of bl) { if (b.type === 'tool_use') pend.set(b.id, { name: b.name, ts: t }); else if (b.type === 'tool_result') pend.delete(b.tool_use_id); }
|
|
154
188
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if (m) { try { title = JSON.parse('"' + m[1] + '"'); } catch {} }
|
|
159
|
-
if (!title) { const hd = readHead(file, 64 * 1024), u2 = hd.match(/"type":"user"[\s\S]{0,3000}?"type":"text","text":"(?!<)((?:[^"\\]|\\.)*)"/); if (u2) { try { title = JSON.parse('"' + u2[1] + '"').replace(/\s+/g, ' ').slice(0, 90); } catch {} } }
|
|
160
|
-
if (!title) { const u = readHead(file, 64 * 1024).match(/"role":"user","content":"((?:[^"\\]|\\.)*)"/); if (u) { try { title = JSON.parse('"' + u[1] + '"').replace(/\s+/g, ' ').slice(0, 90); } catch {} } }
|
|
189
|
+
if (!hit && !custom) { // first look at this file: a little further back than the lines read above, and without needing whole lines
|
|
190
|
+
const big = readTail(file, 256 * 1024).text, un = (m) => { try { return m ? JSON.parse('"' + m[1] + '"') : null; } catch { return null; } };
|
|
191
|
+
custom = un([...big.matchAll(/"type":"custom-title","customTitle":"((?:[^"\\]|\\.)*)"/g)].pop()); if (!ai) ai = un([...big.matchAll(/"type":"ai-title","aiTitle":"((?:[^"\\]|\\.)*)"/g)].pop());
|
|
161
192
|
}
|
|
162
|
-
|
|
193
|
+
if (!custom && !ai && first == null) first = firstPrompt(file) || (st.size > 64 * 1024 ? '' : null); // '' = looked for and not there; a file that has just been created is looked at again
|
|
194
|
+
const out = { sig, title: custom || ai || first || null, custom, ai, first, lastType, lastTool, lastTs, pending: [...pend.values()] }; // a title the person typed wins over the generated one, and both over the first prompt
|
|
163
195
|
boardCache.set(file, out); if (boardCache.size > 200) boardCache.delete(boardCache.keys().next().value); return out;
|
|
164
196
|
}
|
|
197
|
+
function firstPrompt(file) { // the first thing the person typed, read record by record: a pattern over the raw text ran from a user record into the assistant's first answer and took that as the title
|
|
198
|
+
const ONE = (t) => t.replace(/\s+/g, ' ').trim().slice(0, 90);
|
|
199
|
+
for (const l of readHead(file, 64 * 1024).split('\n')) {
|
|
200
|
+
if (!l) continue; let r; try { r = JSON.parse(l); } catch { r = null; }
|
|
201
|
+
if (r) { if (r.type !== 'user' || r.isMeta || r.isCompactSummary) continue; const t = ONE(blocks(r).filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n').replace(HARNESS, '')); if (t && t[0] !== '<' && !/^\[Request interrupted/i.test(t)) return t; continue; }
|
|
202
|
+
const u = /"type":"user"/.test(l) && l.match(/"role":"user","content":(?:\[\{"type":"text","text":)?"(?!<)((?:[^"\\]|\\.){1,200})/); // a first prompt too long to be whole in what was read: its start, from that same line only
|
|
203
|
+
if (u) { try { const t = ONE(JSON.parse('"' + u[1].replace(/\\u[0-9a-fA-F]{0,3}$/, '') + '"')); if (t) return t; } catch {} }
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
165
207
|
function boardRows() {
|
|
166
208
|
if (Date.now() - boardMemo.at < 1200) return boardMemo.rows; // several windows polling at once share one scan
|
|
167
209
|
const out = boardRows0(); boardMemo = { at: Date.now(), rows: out }; return out;
|
|
@@ -183,8 +225,8 @@ function boardRows0() {
|
|
|
183
225
|
else if (p.lastType === 'assistant' && !p.lastTool) state = 'turn';
|
|
184
226
|
else state = age < 10 * 60e3 ? 'working' : 'idle';
|
|
185
227
|
}
|
|
186
|
-
let subActive = 0, subTotal = 0;
|
|
187
|
-
|
|
228
|
+
let subActive = 0, subTotal = 0;
|
|
229
|
+
for (const x of subFiles(proj, id)) { subTotal++; if (now - mtimeOf(x.file) < 90e3) subActive++; }
|
|
188
230
|
return { proj, id, title: p.title || id, mtime: st.mtimeMs, state, tool, ageMs, subActive, subTotal };
|
|
189
231
|
});
|
|
190
232
|
}
|
|
@@ -195,7 +237,8 @@ function writePrefs(v) { fs.mkdirSync(path.dirname(PREFS), { recursive: true });
|
|
|
195
237
|
// Most recently active session (optionally limited to one project dir) — drives the live feed.
|
|
196
238
|
const STICK_MS = 90000; // a session written to in the last 90 s keeps the call, so another session writing elsewhere does not steal it mid-line
|
|
197
239
|
function sessionMtime(proj, id) {
|
|
198
|
-
|
|
240
|
+
let m = mtimeOf(path.join(ROOT, proj, id + '.jsonl')); if (!m) return 0; const subs = subFiles(proj, id);
|
|
241
|
+
for (const x of subs) m = Math.max(m, mtimeOf(x.file)); for (const d of subs.dirs) m = Math.max(m, mtimeOf(d)); return m; // the folders too: a new file in them is activity, as when every file around the transcripts was looked at
|
|
199
242
|
}
|
|
200
243
|
function currentSession(projFilter, idFilter, stick) {
|
|
201
244
|
const safe = (x) => typeof x === 'string' && /^[\w.\-]+$/.test(x) && x !== '..';
|
|
@@ -210,10 +253,7 @@ function currentSession(projFilter, idFilter, stick) {
|
|
|
210
253
|
try { files = fs.readdirSync(dir); } catch { continue; }
|
|
211
254
|
for (const f of files) {
|
|
212
255
|
if (!f.endsWith('.jsonl')) continue;
|
|
213
|
-
const
|
|
214
|
-
const sd = path.join(dir, id, 'subagents');
|
|
215
|
-
if (fs.existsSync(sd)) for (const x of fs.readdirSync(sd)) m = Math.max(m, fs.statSync(path.join(sd, x)).mtimeMs);
|
|
216
|
-
consider(proj, id, m);
|
|
256
|
+
const m = sessionMtime(proj, f.slice(0, -6)); if (m) consider(proj, f.slice(0, -6), m); // 0: a link to a transcript that is gone
|
|
217
257
|
}
|
|
218
258
|
}
|
|
219
259
|
if (!best && projFilter) return currentSession('', '', stick);
|
|
@@ -222,18 +262,24 @@ function currentSession(projFilter, idFilter, stick) {
|
|
|
222
262
|
|
|
223
263
|
// ---- Incremental transcript reader: each .jsonl is parsed once, then only the bytes appended since are read.
|
|
224
264
|
// A session is merged from its main file and one file per sub-agent; the per-file results are cached and re-assembled cheaply.
|
|
225
|
-
const fileStates = new Map(); // path → { agent, offset, tail, raw[], toolName{}, msgUsage Map }
|
|
226
|
-
|
|
227
|
-
|
|
265
|
+
const fileStates = new Map(); // path → { agent, gen, offset, mtime, fp, tail, raw[], toolName{}, msgUsage Map }
|
|
266
|
+
let fileGen = 0; // each time a transcript is read from its start it gets a new number, which is how the live feed knows its events were numbered again
|
|
267
|
+
const sameBytes = (file, st) => { // are the last bytes we read still where we read them? If not, the file was rewritten and not appended to
|
|
268
|
+
let fd; try { fd = fs.openSync(file, 'r'); const b = Buffer.alloc(st.fp.length); fs.readSync(fd, b, 0, b.length, st.offset - b.length); return b.equals(st.fp); } catch { return false; } finally { if (fd != null) fs.closeSync(fd); }
|
|
269
|
+
};
|
|
270
|
+
function readNew(file, agent, journal) {
|
|
271
|
+
let size, mtime; try { const s = fs.statSync(file); size = s.size; mtime = s.mtimeMs; } catch { fileStates.delete(file); return null; }
|
|
228
272
|
let st = fileStates.get(file);
|
|
229
|
-
if (
|
|
273
|
+
if (st && size >= st.offset && mtime !== st.mtime && st.fp.length && !sameBytes(file, st)) st = null; // rewritten in place and not shorter: checked only when the file was written to, with one small read
|
|
274
|
+
if (!st || size < st.offset) { st = { agent, journal: !!journal, gen: ++fileGen, offset: 0, mtime: 0, fp: Buffer.alloc(0), tail: Buffer.alloc(0), raw: [], toolName: {}, msgUsage: new Map() }; fileStates.set(file, st); }
|
|
275
|
+
st.mtime = mtime;
|
|
230
276
|
while (size > st.offset) { // read in chunks of at most 16 MB so a huge transcript never needs one giant buffer
|
|
231
277
|
const fd = fs.openSync(file, 'r'); let buf;
|
|
232
278
|
try { buf = Buffer.alloc(Math.min(16 * 1024 * 1024, size - st.offset)); fs.readSync(fd, buf, 0, buf.length, st.offset); } finally { fs.closeSync(fd); }
|
|
233
|
-
st.offset += buf.length;
|
|
279
|
+
st.offset += buf.length; st.fp = Buffer.from(buf.length >= 64 ? buf.subarray(buf.length - 64) : Buffer.concat([st.fp, buf]).subarray(-64)); // a copy: a view would keep the whole chunk in memory
|
|
234
280
|
const all = st.tail.length ? Buffer.concat([st.tail, buf]) : buf, cut = all.lastIndexOf(10); // only complete lines; a half-written last line waits for the next read
|
|
235
281
|
if (cut < 0) { st.tail = all; continue; }
|
|
236
|
-
st.tail = all.subarray(cut + 1);
|
|
282
|
+
st.tail = Buffer.from(all.subarray(cut + 1)); // a copy, for the same reason
|
|
237
283
|
for (const line of all.subarray(0, cut).toString('utf8').split('\n')) {
|
|
238
284
|
if (!line) continue;
|
|
239
285
|
let r; try { r = JSON.parse(line); } catch { continue; }
|
|
@@ -244,77 +290,98 @@ function readNew(file, agent) {
|
|
|
244
290
|
return st;
|
|
245
291
|
}
|
|
246
292
|
function parseRecord(st, r) {
|
|
247
|
-
const agent = st.agent, ts = Date.parse(r.timestamp) || 0;
|
|
248
|
-
if (
|
|
293
|
+
const agent = st.agent, ts = Date.parse(r.timestamp) || 0, push = (e) => { e.k = agent + ':' + st.raw.length; st.raw.push(e); return e; }; // k: the event's key in the live feed = whose file + its rank in it. A file is only ever appended to, so the rank never changes (and when it is not, the whole file is read again under a new number, see readNew)
|
|
294
|
+
if (ts > (st.lastTs || 0)) st.lastTs = ts;
|
|
295
|
+
if (st.journal) { if (r.type === 'result' && r.agentId) push({ ts: 0, agent, kind: 'wfdone', child: String(r.agentId).replace(/[^\w.-]/g, '_') }); return; } // a workflow's journal: one line when an agent starts, one when it hands its result back (no time on them)
|
|
296
|
+
if (r.type === 'system' && r.subtype === 'compact_boundary') { push({ ts, agent, kind: 'compact' }); return; } // Claude Code compacted the conversation
|
|
249
297
|
if (r.type === 'queue-operation' && typeof r.content === 'string' && r.content.includes('<task-notification>')) { // how Claude Code reports that a background sub-agent has stopped
|
|
250
298
|
const id = (r.content.match(/<tool-use-id>([^<]+)<\/tool-use-id>/) || [])[1], status = (r.content.match(/<status>([^<]+)<\/status>/) || [])[1];
|
|
251
|
-
if (id && status && status !== 'running')
|
|
299
|
+
if (id && status && status !== 'running') push({ ts, agent, kind: 'agentdone', toolId: id });
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (r.type === 'attachment' && r.attachment && r.attachment.type === 'queued_command') { // an order typed while the lead was working: Claude Code hands it over in the middle of the turn, as an attachment and not as a user message
|
|
303
|
+
const q = r.attachment, human = q.origin ? q.origin.kind === 'human' : q.commandMode === 'prompt'; if (agent !== 'main' || !human) return; // the same record also carries task notifications
|
|
304
|
+
const txt = (Array.isArray(q.prompt) ? q.prompt.filter((b) => b && b.type === 'text').map((b) => b.text).join('\n') : typeof q.prompt === 'string' ? q.prompt : '').replace(HARNESS, '').trim();
|
|
305
|
+
if (txt && !/^<(system-reminder|command-|local-command|task-notification)/.test(txt)) push({ ts, agent: 'user', kind: 'user', text: txt });
|
|
252
306
|
return;
|
|
253
307
|
}
|
|
254
308
|
if (r.type === 'user') {
|
|
255
|
-
const items = []; // tool results: the real output that appeared on the agent's screen
|
|
256
|
-
for (const b of blocks(r)) if (b.type === 'tool_result' &&
|
|
309
|
+
const items = [], wf = r.toolUseResult && typeof r.toolUseResult === 'object' && typeof r.toolUseResult.runId === 'string' ? r.toolUseResult.runId : ''; // tool results: the real output that appeared on the agent's screen
|
|
310
|
+
for (const b of blocks(r)) if (b.type === 'tool_result' && wf) push({ ts, agent, kind: 'wfrun', toolId: b.tool_use_id, run: wf }); // the answer to a Workflow call names the run it started: its agents are in subagents/workflows/<run>/
|
|
311
|
+
for (const b of blocks(r)) if (b.type === 'tool_result' && (st.toolName[b.tool_use_id] === 'Agent' || st.toolName[b.tool_use_id] === 'Task') && !(st.bg && st.bg[b.tool_use_id]) && !/^\s*Async agent launched/i.test(resultText(b.content))) push({ ts, agent, kind: 'agentdone', toolId: b.tool_use_id }); // a synchronous call: the lead got the sub-agent's answer back, so it has finished (async launches answer immediately and are not a completion)
|
|
257
312
|
for (const b of blocks(r)) if (b.type === 'tool_result' && st.toolName[b.tool_use_id] && !NO_RESULT.has(st.toolName[b.tool_use_id])) {
|
|
258
313
|
const n = st.toolName[b.tool_use_id], img = resultImages(b.content).length;
|
|
259
314
|
items.push({ id: b.tool_use_id, err: !!b.is_error, out: clip(resultText(b.content).replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trimEnd(), RESULT_MAX[n] || (/^mcp__/.test(n) ? 900 : 600)), ...(img ? { img } : {}) }); // img: how many pictures came back; the page fetches them from /api/media
|
|
260
315
|
}
|
|
261
|
-
if (items.length)
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
if (
|
|
316
|
+
if (items.length) push({ ts, agent, kind: 'results', items });
|
|
317
|
+
const typed = blocks(r).filter((b) => b.type === 'text').map((b) => b.text).join('\n').replace(HARNESS, ''), cut = /^\s*\[Request interrupted[^\]]*\]/i.test(typed); // what the human typed, without the blocks the harness wraps around it
|
|
318
|
+
if (agent !== 'main') { if (!st.prompt && typed.trim() && !st.raw.length) st.prompt = { ts, text: typed.trim() }; return; } // a sub-agent's first user message is the order, emitted from the parent's side (kept here for the agents of a workflow, whose order is in no other file)
|
|
319
|
+
if (cut && st.act >= 0) st.raw[st.act].end = 1; // Esc: the turn stops there, without the end_turn mark. The last thing the lead said or did closes it
|
|
320
|
+
const txt = typed.replace(/^\s*\[Request interrupted[^\]]*\]\s*/i, '').trim();
|
|
321
|
+
if (txt && !/^<(system-reminder|command-|local-command|task-notification)/.test(txt) && !r.isMeta && !r.isCompactSummary) push({ ts, agent: 'user', kind: 'user', text: txt }); // not the summary Claude Code writes when it compacts: thousands of characters the human never typed
|
|
265
322
|
} else if (r.type === 'assistant') {
|
|
266
|
-
const m = r.message || {};
|
|
323
|
+
const m = r.message || {}; let said = null;
|
|
267
324
|
if (m.id && m.usage) { // the same message is repeated once per content block with identical numbers: keep one per message id
|
|
268
325
|
const u = m.usage, prev = st.msgUsage.get(m.id);
|
|
269
326
|
st.msgUsage.set(m.id, { ts: prev ? prev.ts : ts, u: { i: u.input_tokens || 0, o: u.output_tokens || 0, r: u.cache_read_input_tokens || 0, w: u.cache_creation_input_tokens || 0 }, model: m.model });
|
|
270
327
|
}
|
|
271
328
|
for (const b of blocks(r)) {
|
|
272
|
-
if (b.type === 'text' && typeof b.text === 'string' && b.text.trim())
|
|
329
|
+
if (b.type === 'text' && typeof b.text === 'string' && b.text.trim()) { said = push({ ts, agent, kind: 'say', text: b.text.trim() }); st.act = st.raw.length - 1; st.sayOf = m.id; st.sayAt = st.act; }
|
|
273
330
|
else if (b.type === 'tool_use') {
|
|
274
331
|
st.toolName[b.id] = b.name; if (b.input && b.input.run_in_background) (st.bg = st.bg || {})[b.id] = true;
|
|
275
|
-
if (b.name === 'Agent' || b.name === 'Task')
|
|
276
|
-
else
|
|
332
|
+
if (b.name === 'Agent' || b.name === 'Task') push({ ts, agent, kind: 'agentcall', toolId: b.id, bg: !!(b.input && b.input.run_in_background), text: (b.input && b.input.prompt) || '', title: (b.input && b.input.description) || '' });
|
|
333
|
+
else { push({ ts, agent, kind: 'tool', tool: toolLabel(b, ts) }); st.act = st.raw.length - 1; }
|
|
277
334
|
}
|
|
278
335
|
}
|
|
336
|
+
if (agent === 'main' && m.stop_reason === 'end_turn') { // the lead's turn is over: Claude Code writes one record per content block, each with the message's stop reason, so the mark goes on the line this record carries (a thinking block carries none)
|
|
337
|
+
if (!said && m.id && st.sayOf === m.id) said = st.raw[st.sayAt]; // ...or on the line the same message brought in an earlier record
|
|
338
|
+
if (said) said.end = 1;
|
|
339
|
+
}
|
|
279
340
|
}
|
|
280
341
|
}
|
|
281
342
|
const metaCache = new Map(); // meta file path → { mtime, value }
|
|
282
343
|
function readMeta(f) {
|
|
283
|
-
try { const mt = fs.statSync(f).mtimeMs, c = metaCache.get(f); if (c && c.mtime === mt) return c.value; const v = JSON.parse(fs.readFileSync(f, 'utf8')); metaCache.set(f, { mtime: mt, value: v }); return v; } catch { return {}; }
|
|
344
|
+
try { const mt = fs.statSync(f).mtimeMs, c = metaCache.get(f); if (c && c.mtime === mt) return c.value; const v = JSON.parse(fs.readFileSync(f, 'utf8')); if (metaCache.size > 3000) metaCache.clear(); metaCache.set(f, { mtime: mt, value: v }); return v; } catch { return {}; }
|
|
284
345
|
}
|
|
285
346
|
|
|
286
|
-
function buildTimeline(proj, id) {
|
|
347
|
+
function buildTimeline(proj, id, files = []) {
|
|
287
348
|
const base = path.join(ROOT, proj), mainFile = path.join(base, id + '.jsonl');
|
|
288
349
|
if (!fs.existsSync(mainFile)) throw new Error('session not found');
|
|
289
350
|
const agents = { main: { id: 'main', role: 'Lead', type: 'main' } };
|
|
290
|
-
const states = [readNew(mainFile, 'main')];
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
const st = readNew(path.join(subDir, f), aid); if (st) states.push(st);
|
|
298
|
-
}
|
|
351
|
+
const states = [readNew(mainFile, 'main')], subs = subFiles(proj, id), runs = {}; files.push(mainFile); // runs: workflow run → { agents (file name → agent id), by (who launched it), toolId, done }
|
|
352
|
+
for (const x of subs) {
|
|
353
|
+
const aid = x.aid, m = readMeta(x.file.replace(/\.jsonl$/, '.meta.json'));
|
|
354
|
+
agents[aid] = { id: aid, role: m.description || m.workflowPhase || m.agentType || 'Agent', type: m.agentType || 'agent', toolUseId: m.toolUseId };
|
|
355
|
+
if (x.run) { agents[aid].wf = x.run; if (m.workflowPhase) agents[aid].phase = m.workflowPhase; (runs[x.run] = runs[x.run] || { agents: {}, done: new Set() }).agents[x.id0] = aid; } // launched by a workflow: no Agent call of the lead points at it
|
|
356
|
+
agents[aid].mtime = mtimeOf(x.file) || undefined;
|
|
357
|
+
const st = readNew(x.file, aid); if (st) states.push(st); files.push(x.file);
|
|
299
358
|
}
|
|
359
|
+
for (const j of subs.journals) { const st = readNew(j.file, 'wf:' + j.run, true), run = runs[j.run]; files.push(j.file); if (st && run) for (const e of st.raw) if (run.agents[e.child]) run.done.add(run.agents[e.child]); }
|
|
300
360
|
const childByTool = {}; // Agent tool_use id → the sub-agent it created, so we know who ordered whom
|
|
301
361
|
for (const a of Object.values(agents)) if (a.toolUseId) childByTool[a.toolUseId] = a.id;
|
|
362
|
+
const runByTool = {}; for (const st of states) if (st) for (const e of st.raw) if (e.kind === 'wfrun' && runs[e.run] && !runs[e.run].by) { runs[e.run].by = e.agent; runs[e.run].toolId = e.toolId; runByTool[e.toolId] = runs[e.run]; } // Workflow tool_use id → the run it started
|
|
302
363
|
|
|
303
|
-
const finished = new Set(), now0 = Date.now(); for (const st of states) if (st) for (const e of st.raw) if (e.kind === 'agentdone'
|
|
364
|
+
const finished = new Set(), now0 = Date.now(); for (const st of states) if (st) for (const e of st.raw) if (e.kind === 'agentdone') { if (childByTool[e.toolId]) finished.add(childByTool[e.toolId]); else if (runByTool[e.toolId]) for (const a of Object.values(runByTool[e.toolId].agents)) finished.add(a); } // a run that stopped: so did every agent in it
|
|
365
|
+
for (const run of Object.values(runs)) for (const a of run.done) finished.add(a);
|
|
304
366
|
let recentUntil = 0; for (const a of Object.values(agents)) if (a.id !== 'main' && a.mtime && now0 - a.mtime <= 240000) recentUntil = Math.max(recentUntil, a.mtime + 241000);
|
|
305
367
|
for (const a of Object.values(agents)) if (a.id !== 'main' && now0 - (a.mtime || 0) > 240000) finished.add(a.id); // old transcripts without a completion record: silent for 4 min counts as finished
|
|
306
|
-
const events = [], parent = {}, doneSeen = new Set();
|
|
368
|
+
const events = [], parent = {}, doneSeen = new Set(), lastTs = {}; for (const st of states) if (st) lastTs[st.agent] = st.lastTs || 0;
|
|
369
|
+
const finish = (to, ts, by) => { if (!doneSeen.has(to)) { doneSeen.add(to); events.push({ ts, agent: to, kind: 'finish', to: by, k: 'f/' + to }); } }; // one per agent, whichever record says it first: the key is the agent, not the record
|
|
307
370
|
for (const st of states) {
|
|
308
371
|
if (!st) continue;
|
|
309
372
|
let lastSay = -1; // the last thing a sub-agent says is its report back to the lead
|
|
310
373
|
if (st.agent !== 'main' && finished.has(st.agent)) for (let i = st.raw.length - 1; i >= 0; i--) if (st.raw[i].kind === 'say') { lastSay = i; break; }
|
|
374
|
+
const run = agents[st.agent] && agents[st.agent].wf ? runs[agents[st.agent].wf] : null;
|
|
375
|
+
if (run && st.prompt) { parent[st.agent] = run.by || 'main'; events.push({ ts: st.prompt.ts, agent: parent[st.agent], to: st.agent, kind: 'order', ...(run.toolId ? { toolId: run.toolId } : {}), bg: true, wf: agents[st.agent].wf, text: st.prompt.text, title: agents[st.agent].role, k: 'o/' + st.agent }); } // the order a workflow gave is the first message of the agent's own file: shown as an order from whoever launched the workflow (the lead when nothing says who), given in the background, when the agent started
|
|
311
376
|
st.raw.forEach((e, i) => {
|
|
312
|
-
if (e.kind === 'agentdone') { const to = childByTool[e.toolId]; if (to
|
|
313
|
-
else if (e.kind === 'agentcall') { const to = childByTool[e.toolId]; if (to) { parent[to] = e.agent; events.push({ ts: e.ts, agent: e.agent, to, kind: 'order', toolId: e.toolId, bg: e.bg, text: e.text, title: e.title }); } }
|
|
377
|
+
if (e.kind === 'agentdone') { const to = childByTool[e.toolId], wr = runByTool[e.toolId]; if (to) finish(to, e.ts, e.agent); else if (wr) for (const a of Object.values(wr.agents)) if (!wr.done.has(a)) finish(a, Math.max(e.ts, lastTs[a] || 0), e.agent); } // the agents the journal knows about are finished below, at their own time
|
|
378
|
+
else if (e.kind === 'agentcall') { const to = childByTool[e.toolId]; if (to) { parent[to] = e.agent; events.push({ ts: e.ts, agent: e.agent, to, kind: 'order', toolId: e.toolId, bg: e.bg, text: e.text, title: e.title, k: e.k }); } }
|
|
379
|
+
else if (e.kind === 'wfrun' || e.kind === 'wfdone') return;
|
|
314
380
|
else if (i === lastSay) events.push({ ...e, kind: 'report', to: null }); // `to` filled below, once every order is known
|
|
315
381
|
else events.push(e);
|
|
316
382
|
});
|
|
317
|
-
|
|
383
|
+
if (run && run.done.has(st.agent) && st.lastTs) finish(st.agent, st.lastTs, parent[st.agent] || 'main'); // the journal has no times: the agent finished when it last wrote
|
|
384
|
+
for (const [mid, mu] of st.msgUsage) events.push({ ts: mu.ts, agent: st.agent, kind: 'usage', u: mu.u, model: mu.model, k: 'u/' + st.agent + '/' + mid });
|
|
318
385
|
}
|
|
319
386
|
for (const e of events) if (e.kind === 'report' && e.to === null) e.to = parent[e.agent] || 'main';
|
|
320
387
|
|
|
@@ -323,8 +390,8 @@ function buildTimeline(proj, id) {
|
|
|
323
390
|
for (const e of events) {
|
|
324
391
|
const p = lastReal >= 0 ? merged[lastReal] : null;
|
|
325
392
|
if (e.kind === 'usage' || e.kind === 'finish' || e.kind === 'compact') { merged.push(e); continue; }
|
|
326
|
-
if (e.kind === 'tool' && p && p.kind === 'tools' && p.agent === e.agent) p.tools.push(e.tool);
|
|
327
|
-
else { merged.push(e.kind === 'tool' ? { ts: e.ts, agent: e.agent, kind: 'tools', tools: [e.tool] } : e); lastReal = merged.length - 1; }
|
|
393
|
+
if (e.kind === 'tool' && p && p.kind === 'tools' && p.agent === e.agent && !p.end) { p.tools.push(e.tool); if (e.end) p.end = 1; } // a beat keeps the key of its first call; one that closed a turn takes no more calls
|
|
394
|
+
else { merged.push(e.kind === 'tool' ? { ts: e.ts, agent: e.agent, kind: 'tools', tools: [e.tool], ...(e.end ? { end: 1 } : {}), k: e.k } : e); lastReal = merged.length - 1; }
|
|
328
395
|
}
|
|
329
396
|
const real = merged.find((e) => e.kind !== 'usage' && e.kind !== 'finish' && e.kind !== 'compact'), first = real ? real.ts : 0, last = merged.length ? merged[merged.length - 1].ts : 0; // time zero = the first thing said or done
|
|
330
397
|
return { id, proj, agents: Object.values(agents), events: merged.map((e) => ({ ...e, t: Math.max(0, e.ts - first) })), durationMs: last - first, recentUntil };
|
|
@@ -471,7 +538,7 @@ const ttsJobs = new Map();
|
|
|
471
538
|
try { for (const f of fs.readdirSync(ttsDir)) { const p = path.join(ttsDir, f); if (Date.now() - fs.statSync(p).mtimeMs > 864e5) fs.unlinkSync(p); } } catch {} // drop cached audio older than a day
|
|
472
539
|
function synthesize(voice, wpm, text, out) {
|
|
473
540
|
return new Promise((resolve, reject) => {
|
|
474
|
-
let p;
|
|
541
|
+
let p; text = text.replace(/\[\[[^\]]*\]\]/g, ' ').replace(/\[(?=\[)/g, '[ ').replace(/\](?=\])/g, '] '); // macOS `say` obeys commands written between [[ and ]] (a 30-second silence, another voice, another speed) and this text comes from a transcript: they are taken out, and no two brackets are left side by side
|
|
475
542
|
if (MAC) p = spawn('say', ['-v', voice, '-r', String(wpm), '--data-format=LEI16@22050', '-o', out, '--', text]);
|
|
476
543
|
else if (WIN) {
|
|
477
544
|
const rate = Math.max(-6, Math.min(8, Math.round((wpm - 175) / 12))); // SAPI rate: -10..10
|
|
@@ -516,15 +583,17 @@ function listCharacters(pack) {
|
|
|
516
583
|
let audioOwner = { id: null, seen: 0 };
|
|
517
584
|
function start(port = PORT) {
|
|
518
585
|
return http.createServer((req, res) => {
|
|
519
|
-
|
|
586
|
+
let url; try { url = new URL(req.url, 'http://x'); } catch { res.writeHead(400); return res.end('bad request'); } // "GET //" is not an address: answered, where it used to stop the whole server
|
|
587
|
+
req.on('error', () => {}); res.on('error', () => {}); // a browser that goes away mid-request is not our problem
|
|
520
588
|
// Only the local machine may talk to this server: blocks other websites (cross-site requests) and DNS-rebinding attempts.
|
|
521
589
|
const local = (h) => /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(h || '');
|
|
522
590
|
let originOk = true; try { originOk = !req.headers.origin || local(new URL(req.headers.origin).host); } catch { originOk = false; }
|
|
523
591
|
if (!local(req.headers.host) || !originOk) { res.writeHead(403); return res.end('forbidden'); }
|
|
524
|
-
const json = (o, code = 200) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
|
|
592
|
+
const json = (o, code = 200) => { try { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); } catch { res.destroy(); } }; // also called from callbacks that run later, where nothing would catch an exception
|
|
593
|
+
const sendFile = (fp) => { const s = fs.createReadStream(fp); s.on('error', () => res.destroy()); s.pipe(res); }; // a file that vanished between the check and the read ends this answer, not the server
|
|
525
594
|
// Endpoints with side effects (they spawn programs or claim the sound), and the one that serves pictures from a session, refuse requests that a browser marks as coming from another site, e.g. an <img> on a web page.
|
|
526
595
|
const sfs = req.headers['sec-fetch-site'];
|
|
527
|
-
if (sfs && sfs !== 'same-origin' && sfs !== 'none' && (req.method !== 'GET' || ['/api/translate', '/api/quips', '/api/warm', '/api/audio', '/api/tts', '/api/prefs', '/api/voices', '/api/kokoro', '/api/kokoro/install', '/api/media'].includes(url.pathname))) { res.writeHead(403); return res.end('forbidden'); }
|
|
596
|
+
if (sfs && sfs !== 'same-origin' && sfs !== 'none' && (req.method !== 'GET' || ['/api/translate', '/api/quips', '/api/warm', '/api/audio', '/api/tts', '/api/prefs', '/api/prefs.js', '/api/voices', '/api/kokoro', '/api/kokoro/install', '/api/media'].includes(url.pathname))) { res.writeHead(403); return res.end('forbidden'); }
|
|
528
597
|
const readBody = (cb) => { // request bodies are small JSON: stop reading beyond 256 KB and give up after 10 s
|
|
529
598
|
const chunks = []; let size = 0, over = false; req.setTimeout(10000, () => req.destroy());
|
|
530
599
|
if (+req.headers['content-length'] > 262144) { res.writeHead(413); res.end('too large'); return req.destroy(); }
|
|
@@ -557,7 +626,7 @@ function start(port = PORT) {
|
|
|
557
626
|
if (url.pathname === '/api/kokoro') return json(kokoro.status());
|
|
558
627
|
if (url.pathname === '/api/tts') {
|
|
559
628
|
return tts(url.searchParams.get('text') || '', url.searchParams.get('voice'), url.searchParams.get('rate'), url.searchParams.get('engine'))
|
|
560
|
-
.then((f) => { res.writeHead(200, { 'content-type': 'audio/wav', 'cache-control': 'max-age=86400' });
|
|
629
|
+
.then((f) => { res.writeHead(200, { 'content-type': 'audio/wav', 'cache-control': 'max-age=86400' }); sendFile(f); })
|
|
561
630
|
.catch((e) => json({ error: String(e.message) }, 400));
|
|
562
631
|
}
|
|
563
632
|
if (url.pathname === '/api/prefs.js') { res.writeHead(200, { 'content-type': 'application/javascript', 'cache-control': 'no-store' }); return res.end('window.SAVED_PREFS = ' + JSON.stringify(readPrefs()).replace(/</g, '\\u003c') + ';'); }
|
|
@@ -568,7 +637,7 @@ function start(port = PORT) {
|
|
|
568
637
|
if (url.pathname === '/api/characters') return json(listCharacters(url.searchParams.get('pack') || ''));
|
|
569
638
|
if (url.pathname === '/api/packs') return json(Object.keys(PACKS).map((name) => ({ name, n: listCharacters(name).chars.length })));
|
|
570
639
|
if (url.pathname === '/api/media') {
|
|
571
|
-
const q = (k) => url.searchParams.get(k) || '', SAFE = /^[\w.\-]+$/, n = +q('n') || 0, bad = [q('proj'), q('id'), q('agent'), q('tool')].some((v) => !SAFE.test(v) || v === '..' || v.length > 200);
|
|
640
|
+
const q = (k) => url.searchParams.get(k) || '', SAFE = /^[\w.\-]+$/, n = +q('n') || 0, bad = [q('proj'), q('id'), q('agent'), q('tool')].some((v) => !SAFE.test(v) || v === '..' || v.length > 200) || q('tool').length < 6; // no tool call has an id that short, and looking for one meant reading the whole transcript
|
|
572
641
|
const file = bad || !Number.isInteger(n) || n < 0 || n > 9 ? null : mediaFile(q('proj'), q('id'), q('agent')), im = file && toolImage(file, q('tool'), n);
|
|
573
642
|
if (!im) { res.writeHead(bad ? 400 : 404); return res.end(bad ? 'bad request' : 'not found'); }
|
|
574
643
|
res.writeHead(200, { 'content-type': im.type, 'content-length': im.buf.length, 'cache-control': 'private, max-age=3600', 'x-content-type-options': 'nosniff' }); return res.end(im.buf);
|
|
@@ -577,8 +646,8 @@ function start(port = PORT) {
|
|
|
577
646
|
const proj = url.searchParams.get('proj') || '', sid = url.searchParams.get('id') || '', SAFE = /^[\w.\-]+$/;
|
|
578
647
|
if (!SAFE.test(proj) || !SAFE.test(sid) || proj === '..' || sid === '..' || proj.length > 200 || sid.length > 200) { res.writeHead(400); return res.end('bad request'); }
|
|
579
648
|
if (proj !== '_demo' && !fs.existsSync(path.join(ROOT, proj, sid + '.jsonl'))) return json({ error: 'session not found' }, 404);
|
|
580
|
-
const entry = proj === '_demo' ?
|
|
581
|
-
const after = url.searchParams.get('after');
|
|
649
|
+
const entry = proj === '_demo' ? demoEntry() : timelineEntry(proj, sid), tl = entry.tl; // built-in fictional session
|
|
650
|
+
const after = url.searchParams.get('after'), ep = url.searchParams.get('ep');
|
|
582
651
|
if (after == null) { // the whole timeline: serialize once per version, send gzip-compressed when the browser allows it (12 MB → ~1.5 MB)
|
|
583
652
|
const gz = /\bgzip\b/.test(req.headers['accept-encoding'] || '');
|
|
584
653
|
if (!entry.json) entry.json = JSON.stringify(tl);
|
|
@@ -586,14 +655,17 @@ function start(port = PORT) {
|
|
|
586
655
|
res.writeHead(200, { 'content-type': 'application/json', ...(gz ? { 'content-encoding': 'gzip' } : {}), vary: 'accept-encoding' });
|
|
587
656
|
return res.end(gz ? entry.gz : entry.json);
|
|
588
657
|
}
|
|
589
|
-
return json({ id: tl.id, agents: tl.agents, total: tl.events.length, events: tl.events.slice(+after) });
|
|
658
|
+
if (ep == null) return json({ id: tl.id, agents: tl.agents, total: tl.events.length, events: tl.events.slice(+after) }); // a page loaded before 1.25.7 still asks by position
|
|
659
|
+
const a = +after; if (ep !== tl.ep || !(a >= 0 && a <= tl.seq)) return json({ resync: true }); // not the feed this page was following (restart, session dropped from memory, transcript rewritten): it loads the session again
|
|
660
|
+
if (a === tl.seq) { if (!entry.idle) entry.idle = JSON.stringify({ id: tl.id, agents: tl.agents, ep: tl.ep, seq: tl.seq, events: [] }); res.writeHead(200, { 'content-type': 'application/json' }); return res.end(entry.idle); } // nothing new, which is the answer to nearly every poll: written once
|
|
661
|
+
return json({ id: tl.id, agents: tl.agents, ep: tl.ep, seq: tl.seq, events: feedSince(entry, a) }); // new events and changed ones alike, whole, under the key they always had
|
|
590
662
|
}
|
|
591
663
|
const pk = url.pathname.match(/^\/packs\/([a-z]+)\/(.*)$/);
|
|
592
664
|
if (url.pathname.startsWith('/characters/') || (pk && packDir(pk[1]))) { // only plain image files of the characters folder (or of a built-in pack), by exact name
|
|
593
665
|
let f = ''; try { f = decodeURIComponent(pk ? pk[2] : url.pathname.slice('/characters/'.length)); } catch {}
|
|
594
666
|
const dir = pk ? packDir(pk[1]) : charactersDir(), fp = path.join(dir, f);
|
|
595
667
|
try { const ls = fs.lstatSync(fp); if (!CHAR_NAME.test(f) || !fp.startsWith(dir + path.sep) || !ls.isFile() || ls.size > CHAR_MAX) throw 0; } catch { res.writeHead(404); return res.end('not found'); }
|
|
596
|
-
res.writeHead(200, { 'content-type': CHAR_TYPES[f.split('.').pop().toLowerCase()] || 'application/octet-stream', 'cache-control': 'no-cache' }); return
|
|
668
|
+
res.writeHead(200, { 'content-type': CHAR_TYPES[f.split('.').pop().toLowerCase()] || 'application/octet-stream', 'cache-control': 'no-cache' }); return sendFile(fp);
|
|
597
669
|
}
|
|
598
670
|
const file = path.join(PUBLIC, url.pathname === '/' ? 'index.html' : url.pathname);
|
|
599
671
|
let fst = null; try { fst = fs.statSync(file); } catch {}
|