@double_geste/agent-standup 1.25.8 → 1.25.10
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/kokoro-worker.js +31 -5
- package/package.json +1 -1
- package/public/index.html +59 -21
- package/public/locales/da.json +0 -1
- package/public/locales/de.json +0 -1
- package/public/locales/en.json +0 -1
- package/public/locales/es.json +0 -1
- package/public/locales/ja.json +0 -1
- package/public/locales/pt-BR.json +0 -1
- package/public/locales/zh-CN.json +0 -1
- package/public/locales/zh-TW.json +0 -1
- package/server.js +4 -4
- package/examples/characters/README.txt +0 -5
- package/examples/characters/buddy.png +0 -0
- package/examples/characters/lead-talk.png +0 -0
- package/examples/characters/lead.png +0 -0
package/kokoro-worker.js
CHANGED
|
@@ -11,18 +11,44 @@ function wav(samples, rate) { // Float32 in [-1, 1] → 16-bit mono WAV
|
|
|
11
11
|
for (let i = 0; i < n; i++) { const s = Math.max(-1, Math.min(1, samples[i])); buf.writeInt16LE(Math.round(s < 0 ? s * 32768 : s * 32767), 44 + i * 2); }
|
|
12
12
|
return buf;
|
|
13
13
|
}
|
|
14
|
+
const FILE = { q8: 'model_quantized', q4: 'model_q4', fp32: 'model', fp16: 'model_fp16', q4f16: 'model_q4f16' }; // the file transformers.js keeps for each dtype, under models/<MODEL>/onnx/
|
|
15
|
+
// The model takes 510 phonemes per call (about 480 letters of plain English, 300 when the line is full of numbers) and kokoro-js cuts the rest without a word:
|
|
16
|
+
// a line that does not fit is made in parts and joined. LONG is what the tokenizer throws instead of cutting (see main).
|
|
17
|
+
const LONG = new Error('too long for one call'), QUIET = 0.003;
|
|
18
|
+
const CUTS = [[/[.!?…]+["'”’»)\]]*\s+(?![\sa-zà-ÿ])|\s*\n\s*/g, 0.45], [/[,;:—–]\s+/g, 0.2], [/\s+/g, 0.05]]; // where a line may be cut, best first, each with the pause it gets in seconds at speed 1: the end of a sentence, a comma, a space
|
|
19
|
+
function halve(s) { // two parts at the cut nearest the middle, so no part is a stray word; a text with no space at all is cut in the middle
|
|
20
|
+
for (const [re, gap] of CUTS) { let at = 0; for (const m of s.matchAll(re)) { const i = m.index + m[0].length; if (i < s.length && Math.abs(i - s.length / 2) < Math.abs(at - s.length / 2)) at = i; } if (at) return [s.slice(0, at), s.slice(at), gap]; }
|
|
21
|
+
const at = Math.ceil(s.length / 2); return [s.slice(0, at), s.slice(at), 0];
|
|
22
|
+
}
|
|
23
|
+
function join(parts, rate) { // [audio, pause, audio, …] → one Float32Array. The model puts about 0.3 s of silence before and 0.45 s after what it says: kept at the two ends of the line, removed at each join, where the pause decides
|
|
24
|
+
const cut = parts.map((p, i) => { if (typeof p === 'number') return new Float32Array(Math.round(p * rate)); const s = p.audio; let a = 0, z = s.length; if (i) while (a < z && Math.abs(s[a]) < QUIET) a++; if (i < parts.length - 1) while (z > a && Math.abs(s[z - 1]) < QUIET) z--; return s.subarray(a, z); });
|
|
25
|
+
const out = new Float32Array(cut.reduce((n, c) => n + c.length, 0)); let at = 0; for (const c of cut) { out.set(c, at); at += c.length; } return out;
|
|
26
|
+
}
|
|
14
27
|
async function main() {
|
|
15
|
-
const dir = process.env.AGENT_STANDUP_KOKORO_DIR, dtype = process.env.AGENT_STANDUP_KOKORO_DTYPE || '
|
|
28
|
+
const dir = process.env.AGENT_STANDUP_KOKORO_DIR, dtype = process.env.AGENT_STANDUP_KOKORO_DTYPE || 'q4';
|
|
16
29
|
const req = createRequire(path.join(dir, 'node_modules', 'x.js')); // resolve from the install folder, not from the extension
|
|
17
30
|
const tf = req('@huggingface/transformers'); tf.env.cacheDir = path.join(dir, 'models');
|
|
18
|
-
const { KokoroTTS } = req('kokoro-js')
|
|
31
|
+
const { KokoroTTS } = req('kokoro-js'), load = () => KokoroTTS.from_pretrained(MODEL, { dtype, device: 'cpu' });
|
|
32
|
+
let tts; try { tts = await load(); } catch (e) {
|
|
33
|
+
// transformers.js writes the model straight to its final name and never checks it again: a download cut short (the worker killed while the model
|
|
34
|
+
// was being fetched, e.g. by a reload of the window) leaves a file that fails to load every time after. Drop this model's files and fetch them once more.
|
|
35
|
+
const onnx = path.join(dir, 'models', ...MODEL.split('/'), 'onnx'), name = FILE[dtype] || 'model'; let dropped = 0;
|
|
36
|
+
for (const ext of ['.onnx', '.onnx_data']) { try { fs.unlinkSync(path.join(onnx, name + ext)); dropped++; } catch {} }
|
|
37
|
+
if (!dropped) throw e; console.error('kokoro: the ' + dtype + ' model could not be loaded (' + String((e && e.message) || e).slice(0, 80) + '); fetching it again'); tts = await load();
|
|
38
|
+
}
|
|
39
|
+
const tok = tts.tokenizer; // kokoro-js calls it with truncation on, which drops every phoneme past the model's limit and says nothing: count them all here and refuse instead
|
|
40
|
+
if (tok) tts.tokenizer = (t, o) => { const r = tok(t, { ...o, truncation: false }); if (r.input_ids.dims.at(-1) > (tok.model_max_length || 512)) throw LONG; return r; };
|
|
41
|
+
const make = async (text, o) => { let a = await tts.generate(text, o); if (!finite(a.audio)) { a = await tts.generate(text, o); if (!finite(a.audio)) throw new Error('the model returned NaN'); } return a; }; // made once more; if it fails again the server hands the line to a system voice
|
|
42
|
+
const parts = async (text, o) => { // → [audio] when the line fits (the usual case, made exactly as before), else [audio, pause, audio, …] in the order of the text
|
|
43
|
+
try { return [await make(text, o)]; } catch (e) { if (e !== LONG) throw e; }
|
|
44
|
+
const [a, b, gap] = halve(text); return [...await parts(a, o), gap / o.speed, ...await parts(b, o)];
|
|
45
|
+
};
|
|
19
46
|
send({ ready: true });
|
|
20
47
|
for await (const line of readline.createInterface({ input: process.stdin })) {
|
|
21
48
|
let m; try { m = JSON.parse(line); } catch { continue; }
|
|
22
49
|
try {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
fs.writeFileSync(m.out, wav(a.audio, a.sampling_rate)); send({ id: m.id, ok: true, seconds: a.audio.length / a.sampling_rate });
|
|
50
|
+
const ps = await parts(String(m.text).trim(), { voice: m.voice, speed: +m.speed || 1 }), rate = ps[0].sampling_rate, audio = ps.length > 1 ? join(ps, rate) : ps[0].audio;
|
|
51
|
+
fs.writeFileSync(m.out, wav(audio, rate)); send({ id: m.id, ok: true, seconds: audio.length / rate });
|
|
26
52
|
}
|
|
27
53
|
catch (e) { send({ id: m.id, ok: false, error: String((e && e.message) || e).slice(0, 200) }); }
|
|
28
54
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@double_geste/agent-standup",
|
|
3
|
-
"version": "1.25.
|
|
3
|
+
"version": "1.25.10",
|
|
4
4
|
"description": "Your Claude Code agents as a live video call, from the terminal: a local page in your browser, plus one-line status for status bars. Free, open source, zero token cost.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"agent-standup": "bin/agent-standup.js"
|
package/public/index.html
CHANGED
|
@@ -160,8 +160,9 @@
|
|
|
160
160
|
.tile.speaking, .tile.bye { overflow:visible; z-index:4 } .tile > svg:first-child { border-radius:13px } .tile.speaking .prop { left:auto; right:8px; top:52px }
|
|
161
161
|
.tile.focus.speaking:has(.status:not(:empty)) .bubble { top:32px } /* the spotlight has room: there the badge stays visible above the bubble */
|
|
162
162
|
body:not(.tiny) #center:has(#pcap:not(:empty)) .tile.presenter:not(.bye) .bubble { display:none } /* the presenter's line is the caption of the shared screen: not said twice */
|
|
163
|
-
.tile.musing:not(.speaking):not(.bye) .bubble { display:
|
|
164
|
-
.tile.focus.musing:not(.speaking):not(.bye) .bubble { bottom:
|
|
163
|
+
.tile.musing:not(.speaking):not(.bye) .bubble { display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:3; overflow:hidden; padding-bottom:0; border-bottom:6px solid transparent; top:auto; bottom:54px; max-width:min(80%, 320px); font-size:12px; font-style:italic; color:#40455c; background:rgba(255,255,255,.88); border-radius:14px; box-shadow:0 2px 8px rgba(0,0,0,.28); animation:fadein .5s } /* an inner thought: not said, so lower, lighter, and never over the status at the top */
|
|
164
|
+
.tile.focus.musing:not(.speaking):not(.bye) .bubble { bottom:62px; font-size:13px }
|
|
165
|
+
body.compact #stage .tile.musing:not(.speaking):not(.bye) .bubble { display:block; border:0; position:absolute; left:64px; right:8px; bottom:3px; top:auto; width:auto; max-width:none; margin:0; padding:0; font-size:11px !important; white-space:nowrap; text-overflow:ellipsis; background:none; box-shadow:none; color:#aab0c8; animation:fadein .5s } .tile.musing:not(.speaking):not(.bye) .bubble small { display:inline; margin-right:5px; font-style:normal }
|
|
165
166
|
.tile.bye .bubble { display:block; font-size:13.5px; animation:fadein .25s } .tile.speaking .bubble { display:block; font-size:13px } .tile.focus.speaking .bubble { font-size:14.5px } .bubble small { display:block; color:#6a6f85; font-size:10.5px }
|
|
166
167
|
.ticker { display:none; position:absolute; left:6px; right:6px; bottom:30px; font:10.5px/1.3 ui-monospace,Menlo,monospace; color:#fff; background:rgba(15,20,35,.8); border-radius:5px; padding:2px 7px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
|
|
167
168
|
.ticker.on { display:block } .ticker i { font-style:normal; color:#ffd24a } .ticker.flash { animation:flash .25s } @keyframes flash { from { background:#3b5bdb } }
|
|
@@ -293,7 +294,7 @@
|
|
|
293
294
|
<select id="liveProj" style="display:none;margin-top:6px" title="Project to follow"></select>
|
|
294
295
|
<select id="sessions" style="display:none"></select></div>
|
|
295
296
|
<h4 data-fr="Personnages" data-en="Characters">Personnages</h4>
|
|
296
|
-
<div class="box"><select id="charPack" aria-label="Characters"><option value="none" data-fr="Personnages dessinés" data-en="Drawn characters">Personnages dessinés</option><option value="folder" data-fr="Mon dossier" data-en="My folder">Mon dossier</option><option value="
|
|
297
|
+
<div class="box"><select id="charPack" aria-label="Characters"><option value="none" data-fr="Personnages dessinés" data-en="Drawn characters">Personnages dessinés</option><option value="folder" data-fr="Mon dossier" data-en="My folder">Mon dossier</option><option value="cats" data-fr="Pack de chats" data-en="Cat pack">Pack de chats</option></select></div>
|
|
297
298
|
<div class="box"><div id="charInfo" class="dim" style="font-size:11.5px;line-height:1.5"></div><div style="display:flex;gap:6px;margin-top:6px"><button class="btn" id="charReload" data-fr="Recharger" data-en="Reload">Recharger</button><button class="btn" id="charOpen" style="display:none" data-fr="Ouvrir le dossier" data-en="Open folder">Ouvrir le dossier</button></div></div>
|
|
298
299
|
<button class="mi" data-t="customLaptop"><span data-fr="Ordinateur devant vos personnages" data-en="Laptop in front of your own characters">Ordinateur devant vos personnages</span><i class="sw"></i></button>
|
|
299
300
|
<h4 data-fr="Outils" data-en="Tools">Outils</h4>
|
|
@@ -387,7 +388,7 @@ const mix = (a, b, t) => { const A = hexRGB(a), B = hexRGB(b); return '#' + A.ma
|
|
|
387
388
|
let CHARS = { lead: null, you: null, pool: [], dir: '', n: 0 };
|
|
388
389
|
const toData = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => { const f = new FileReader(); f.onload = () => res(f.result); f.readAsDataURL(b); }));
|
|
389
390
|
async function loadChars() {
|
|
390
|
-
if (!['none', 'folder', '
|
|
391
|
+
if (!['none', 'folder', 'cats'].includes(prefs.charPack)) { // drawn characters by default; someone who already had images in their folder keeps them (this is also where a choice of the starter pack, no longer shipped, ends up)
|
|
391
392
|
let n = 0; try { n = ((await (await fetch('/api/characters')).json()).chars || []).length; } catch {} prefs.charPack = n ? 'folder' : 'none'; savePrefs(); const sel = $('#charPack'); if (sel) sel.value = prefs.charPack;
|
|
392
393
|
}
|
|
393
394
|
const pack = prefs.charPack, next = { lead: null, you: null, pool: [], dir: '', n: 0, pack };
|
|
@@ -545,6 +546,36 @@ function short(t, max = 230) {
|
|
|
545
546
|
const cut = s.slice(0, max), end = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('! '), cut.lastIndexOf('? '));
|
|
546
547
|
return end > 80 ? cut.slice(0, end + 1) : cut.replace(/\s+\S*$/, '') + '…';
|
|
547
548
|
}
|
|
549
|
+
// The wrap-up, for the ear: a Markdown message as a few plain sentences. What the page shows with layout (headings, lists, tables), a voice can only say with full stops.
|
|
550
|
+
// Kept: the first sentence of each paragraph and of each list item, in the order of the message (then second sentences if there is room); a short heading or a "…:" line, as a sentence, in front of what it introduces.
|
|
551
|
+
// Dropped: tables, code blocks, rules, long headings, emphasis marks, emoji, the seconds of a clock time, and what the voice has already read (heard: the text read so far).
|
|
552
|
+
function gist(md, heard = '', max = 280) { // max: 300 characters with the lead-in, 15 to 20 s of speech
|
|
553
|
+
const bare = (x) => x.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ''), said = bare(heard), blocks = [], units = []; let cur = null, fence = false, wrapped = false, intro = []; // bare: letters and digits only, the same sentence whatever the marks around it
|
|
554
|
+
for (const raw of String(md).split('\n')) {
|
|
555
|
+
const l = raw.replace(/^\s*(?:>\s*)*/, '').trimEnd(); // a quoted line is read like any other
|
|
556
|
+
if (/^(?:```|~~~)/.test(l)) { fence = !fence; cur = null; blocks.push(null); continue; }
|
|
557
|
+
if (fence) continue; // code: for the eye only
|
|
558
|
+
if (!l || /^(?:[-*_=]\s*){3,}$/.test(l)) { cur = null; continue; } // an empty line or a rule ends the paragraph
|
|
559
|
+
if (l.split('|').length > 2) { cur = null; blocks.push(null); continue; } // a table row (null: something was skipped here)
|
|
560
|
+
const h = l.match(/^#{1,6}\s+(.+?)[\s#]*$/) || l.match(/^\*\*([^*]*[^*.!?])\*\*:?$/), li = l.match(/^(?:[-*+•]|\d+[.)])\s+(?:\[[ xX]\]\s+)?(.+)$/); // a line all in bold is a heading too, unless it is a sentence
|
|
561
|
+
if (h) { blocks.push({ h: 1, t: h[1].replace(/^\d+[.)]\s+/, '') }); cur = null; } else if (!li && cur && wrapped) cur.t += ' ' + l; else blocks.push(cur = { t: li ? li[1] : l }); // each line is a block of its own (a paragraph, a list item), unless the line above was cut at the margin
|
|
562
|
+
wrapped = !h && l.length > 60 && !/[.!?…:;]["')»*_]*$/.test(l);
|
|
563
|
+
}
|
|
564
|
+
const end = (s) => (/[.!?…]["')»]*$/.test(s) ? s : s.replace(/[\s:;,–—-]+$/, '') + '.'); // every piece ends like a sentence: that is where the voice breathes
|
|
565
|
+
const fresh = (s) => { const n = bare(s), k = n ? said.indexOf(n.slice(0, 16)) : 0; return k < 0 || said.length - k < n.length / 2; }; // not read yet, or cut before its half: said whole. Past its half, it is not said twice
|
|
566
|
+
for (const b of blocks) {
|
|
567
|
+
if (!b) { intro = intro.filter((x) => x.h); continue; } // "Here is the table:" has nothing left to introduce
|
|
568
|
+
const eye = (b.t.match(/`[^`]+`/g) || []).some((c) => c.length > 42 || (/\s/.test(c) && /[-=(){}$;|&<>\\]/.test(c))); // a command or a snippet in the line: kept only if there is nothing else to say
|
|
569
|
+
const t = plain(b.t.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/<\/?[a-z][^>]*>/gi, ' ').replace(/\*+|~~|__/g, '')).replace(/\s*[\u2190-\u21FF]\s*/g, ', ').replace(/[\p{Extended_Pictographic}\uFE0F\u200D\u2700-\u27BF]/gu, '').replace(/\b(\d{1,2}:\d{2}):\d{2}\b/g, '$1').replace(/\s+/g, ' ').replace(/^[\s,;:.–—-]+/, '').trim();
|
|
570
|
+
if (b.h) { if (t && t.length <= 40 && fresh(t)) intro = [{ h: 1, s: end(t) }]; continue; } // a short heading becomes a sentence; a long one says again what follows it
|
|
571
|
+
const all = t.split(/(?<=[.!?…]["')\]»]*)(?<!\b(?:e\.g|i\.e|vs|cf|ex)\.)\s+(?=["“«(]?[\p{Lu}\d])/u).filter(Boolean), colon = /:$/.test(t) ? all.pop() : '', sens = all.filter(fresh);
|
|
572
|
+
sens.forEach((s, r) => units.push({ s: end(s), r: eye ? 99 : r, pre: r ? [] : intro })); // r: its rank, the first sentence of each block comes before any second one
|
|
573
|
+
intro = (all.length ? [] : intro).concat(colon && colon.length <= 60 && fresh(colon) ? [{ s: end(colon) }] : []); // "The fix has two parts:" waits for the item it announces, and is dropped with it
|
|
574
|
+
}
|
|
575
|
+
let n = 0; const keep = new Set(), cost = (u) => u.pre.reduce((a, x) => a + x.s.length + 1, u.s.length + 1);
|
|
576
|
+
for (const u of units.map((u, i) => ({ ...u, i })).sort((a, b) => a.r - b.r || a.i - b.i)) { if (n + cost(u) > max || (u.r > 98 && n)) { if (u.r) break; continue; } keep.add(u.i); n += cost(u); } // whole sentences only, up to max characters
|
|
577
|
+
return units.filter((u, i) => keep.has(i)).map((u) => u.pre.map((x) => x.s).concat(u.s).join(' ')).join(' ') || (units[0] ? short(units[0].s, max) : ''); // back in the order of the message
|
|
578
|
+
}
|
|
548
579
|
// make code-ish text pronounceable
|
|
549
580
|
const PER = /^(?:s|sec|ms|min|h|hr|j|jour|d|day|mois|mo|month|an|yr|year)$/i;
|
|
550
581
|
function slashSay(m, pre, lead, body) { // word/word (pre: the character before it, put back as it is): only a file path is named by its last part; anything else keeps every word, so the voice says what the caption shows ("and/or", "client/server", "2/3")
|
|
@@ -1325,7 +1356,7 @@ setInterval(() => {
|
|
|
1325
1356
|
// ---------- speaking tiles ----------
|
|
1326
1357
|
const mouths = new Map();
|
|
1327
1358
|
function startTalk(id, text, label) {
|
|
1328
|
-
const t = $('#t-' + id); if (!t) return; endThought(id); stopMumble(); TH.next[id] = Date.now() + 8000; // a spoken line takes the bubble and the floor
|
|
1359
|
+
const t = $('#t-' + id); if (!t) return; endThought(id); stopMumble(); TH.next[id] = Math.max(TH.next[id] || 0, Date.now() + 8000); // a spoken line takes the bubble and the floor
|
|
1329
1360
|
if (S.present && S.present !== id) hidePresent();
|
|
1330
1361
|
S.spokeAt = performance.now(); S.fill = 0; S.mused = false;
|
|
1331
1362
|
if (!S.speakers.size && !(S.focusLock > Date.now())) setFocus(id); S.gone.delete(id); if (S.doneAt[id] != null) S.doneAt[id] = S.clock; t.style.display = ''; t.classList.add('speaking'); S.speakers.add(id); S.say[id] = { text, label }; AUD.duck(true);
|
|
@@ -1369,30 +1400,37 @@ const clearSpeaking = () => [...S.speakers].forEach(stopTalk);
|
|
|
1369
1400
|
const TH = { since: {}, next: {}, said: {}, last: 0, voice: null };
|
|
1370
1401
|
const THINK = {
|
|
1371
1402
|
en: { run: ['Still running… {x}.', 'Come on… {x}.', '{x}. Any second now.'], err: ['Why did that fail?', 'That error again… what am I missing?', 'Not what I expected from “{x}”.'], two: ['{a}, then {b}… what links them?', '{a} and {b}. It has to be in one of them.'], one: ['Something in {a} does not add up.', '{a}… where was that part again?'], again: ['Back to {a}. Again.', '{a}, once more. I am missing something.'],
|
|
1372
|
-
todo: ['One thing at a time: {x}.', '{x}… then the next one.'], next: ['After this: {x}.'], grep: ['Where is “{x}” hiding?', '“{x}”… it has to be somewhere.'], web: ['“{x}”… let’s see what comes up.'], task: ['{x}. Stay on it.', '{x}… getting there, I think.']
|
|
1403
|
+
todo: ['One thing at a time: {x}.', '{x}… then the next one.'], next: ['After this: {x}.'], grep: ['Where is “{x}” hiding?', '“{x}”… it has to be somewhere.'], web: ['“{x}”… let’s see what comes up.'], grep0: ['It has to be somewhere in here.', 'Where is that thing hiding?'], task: ['{x}. Stay on it.', '{x}… getting there, I think.'] },
|
|
1373
1404
|
fr: { run: ['Toujours en cours… {x}.', 'Allez… {x}.', '{x}. Ça arrive.'], err: ['Pourquoi ça a planté ?', 'Encore cette erreur… qu’est-ce que je rate ?', 'Pas ce que j’attendais de « {x} ».'], two: ['{a}, puis {b}… quel est le lien ?', '{a} et {b}. C’est forcément dans l’un des deux.'], one: ['Il y a un truc dans {a} qui ne colle pas.', '{a}… c’était où, déjà ?'], again: ['Retour à {a}. Encore.', '{a}, une fois de plus. Je rate quelque chose.'],
|
|
1374
|
-
todo: ['Une chose à la fois : {x}.', '{x}… puis la suite.'], next: ['Après ça : {x}.'], grep: ['Où se cache « {x} » ?', '« {x} »… c’est forcément quelque part.'], web: ['« {x} »… voyons ce que ça donne.'], task: ['{x}. On reste dessus.', '{x}… j’y arrive, je crois.']
|
|
1405
|
+
todo: ['Une chose à la fois : {x}.', '{x}… puis la suite.'], next: ['Après ça : {x}.'], grep: ['Où se cache « {x} » ?', '« {x} »… c’est forcément quelque part.'], web: ['« {x} »… voyons ce que ça donne.'], grep0: ['C’est forcément quelque part là-dedans.', 'Où est-ce que ça se cache ?'], task: ['{x}. On reste dessus.', '{x}… j’y arrive, je crois.'] },
|
|
1375
1406
|
};
|
|
1376
1407
|
function thoughtFor(id) {
|
|
1377
|
-
const now = Date.now(), W = THINK[FR() ? 'fr' : 'en'], seen = (TH.said[id] = TH.said[id] || []), cut = (x, n) => { x = String(x || '').replace(/\s+/g, ' ').trim(); return (x.length > n ? x.slice(0, n).replace(/\s+\S*$/, '')
|
|
1408
|
+
const now = Date.now(), W = THINK[FR() ? 'fr' : 'en'], seen = (TH.said[id] = TH.said[id] || []), cut = (x, n) => { x = String(x || '').replace(/\s+/g, ' ').trim(); return (x.length > n ? x.slice(0, n).replace(/\s+\S*$/, '') : x).replace(/[.…,;:\s]+$/, ''); }; // the line it goes into brings its own punctuation
|
|
1378
1409
|
const real = realThought(id); if (real) return real; // its own words first, always: the rest is only for when there are none (left)
|
|
1379
|
-
const buf = (S.term[id] || []).slice(-8), last = buf[buf.length - 1], r = S.run[id], hot = [], out = [], add = (to, k, v) => { for (const tpl of W[k]) to.push(tpl.replace(/\{(\w+)\}/g, (m, key) => v[key])); };
|
|
1380
|
-
const
|
|
1410
|
+
const buf = (S.term[id] || []).slice(-8), last = buf[buf.length - 1], r = S.run[id], hot = [], out = [], add = (to, k, v) => { for (const tpl of W[k]) to.push(tpl.replace(/\{(\w+)\}/g, (m, key) => v[key]).replace(/([?!])[.…]+/g, '$1').replace(/([?!]) (\p{Ll})/u, (m, a, b) => a + ' ' + b.toUpperCase())); }; // "Why is it slow?" keeps its own mark, without a full stop after it
|
|
1411
|
+
const cmd = (t) => String(t || '').trim().replace(/^(?:cd\s+\S+\s*(?:&&|;)\s*)+/, '').replace(/^(?:[A-Za-z_]\w*=\S*\s+)+/, '').split(/\s+(?:&&|\|\||[|;>])/)[0].split(/\s+/).slice(0, 3).join(' '); // the command itself: not the folder it starts from, nor what it is piped into
|
|
1412
|
+
const what = (e) => { const d = e.tool.d || {}; return cut(d.note || (d.kind === 'cmd' ? cmd(d.text) : base(d.file) || e.tool.name), 60); }, fname = (f) => (f.length > 30 ? f.slice(0, 14) + '…' + f.slice(-13) : f);
|
|
1381
1413
|
if (last && last.k === 'cmd' && r && r.id === last.id && now - (r.ts || now) > 20000) add(hot, 'run', { x: what(last) }); // a command that takes its time
|
|
1382
1414
|
if (last && last.err) add(hot, 'err', { x: what(last) });
|
|
1383
|
-
const files = buf.filter((e) => /^(read|edit|write)$/.test(e.k)).map((e) => base((e.tool.d || {}).file)).filter(Boolean), uniq = [...new Set(files)], f = uniq[uniq.length - 1];
|
|
1415
|
+
const files = buf.filter((e) => /^(read|edit|write)$/.test(e.k)).map((e) => fname(base((e.tool.d || {}).file))).filter(Boolean), uniq = [...new Set(files)], f = uniq[uniq.length - 1];
|
|
1384
1416
|
if (f && files.filter((x) => x === f).length >= 3) add(out, 'again', { a: f }); else if (uniq.length > 1) add(out, 'two', { a: uniq[uniq.length - 2], b: f }); else if (f) add(out, 'one', { a: f });
|
|
1385
1417
|
const td = S.todos[id] || [], cur = td.find((x) => x.s === 'in_progress'), nx = td.find((x) => x.s === 'pending'); if (cur) add(out, 'todo', { x: cut(cur.c, 70) }); if (nx) add(out, 'next', { x: cut(nx.c, 70) });
|
|
1386
|
-
const g = buf.slice(-3).reverse().find((e) => e.k === 'grep' && (e.tool.d || {}).pattern); if (g) add(out, 'grep', { x:
|
|
1418
|
+
const g = buf.slice(-3).reverse().find((e) => e.k === 'grep' && (e.tool.d || {}).pattern); if (g) { const pat = String(g.tool.d.pattern).trim(); if (/^[\p{L}\p{N}][\p{L}\p{N} _.-]{1,28}$/u.test(pat)) add(out, 'grep', { x: pat }); else add(out, 'grep0', {}); } // a regular expression between quotes reads like noise
|
|
1387
1419
|
const w = buf.slice(-3).reverse().find((e) => e.k === 'web' && (e.tool.d || {}).query); if (w) add(out, 'web', { x: cut(w.tool.d.query, 50) });
|
|
1388
|
-
const p = S.agents[id]; if (isSub(id) && p && p.role && !/^agent$/i.test(p.role)) add(out, 'task', { x: cut(p.role, 60) });
|
|
1389
|
-
if ((S.tc[id] || 0) >= 15) add(out, 'many', { n: S.tc[id] });
|
|
1420
|
+
const p = S.agents[id]; if (isSub(id) && p && p.role && p.role !== p.type && !/^(agent|explore|plan|claude|general-purpose)$/i.test(p.role)) add(out, 'task', { x: cut(p.role, 60) }); // a task with a title of its own, not the kind of agent
|
|
1390
1421
|
const fresh = (a) => a.filter((x) => !seen.includes(x)), h = fresh(hot), o = fresh(out), pool = h.length && (!o.length || Math.random() < 0.6) ? h : o; if (!pool.length) return null;
|
|
1391
1422
|
const line = pool[Math.floor(Math.random() * pool.length)]; seen.push(line); if (seen.length > 6) seen.shift(); return line;
|
|
1392
1423
|
}
|
|
1393
1424
|
function realThought(id, peek) { // the next sentence of the agent's own reasoning for the work it is on (kept by the session for some of its steps); null when there is none, or when all of it has been shown
|
|
1394
1425
|
const th = S.th[id]; if (!th || Date.now() - th.ts > 600000) return null;
|
|
1395
|
-
if (!th.ss) {
|
|
1426
|
+
if (!th.ss) { // a sentence ends on . ! ? … followed by a space and a capital: "db/schema.sql", "1.25.8" and "example.com" stay whole
|
|
1427
|
+
const ss = [], lines = th.text.split('\n').map((l) => plain(l.replace(/^\s*(?:[-*•]|\d{1,2}[.)])\s+/, '')).replace(/\s+/g, ' ').trim()).filter(Boolean); // each line of its notes on its own (a list item, a step), without its mark
|
|
1428
|
+
lines.forEach((t, k) => {
|
|
1429
|
+
const re = /[.!?…]+["”’»)]*\s+(?=["“«(]?[A-ZÀ-ÖØ-Þ])/g; let from = 0, m; while ((m = re.exec(t))) { ss.push(t.slice(from, m.index + m[0].length).trim()); from = m.index + m[0].length; }
|
|
1430
|
+
const tail = t.slice(from).trim(); if (tail && (k < lines.length - 1 || th.text.length < 880 || /[.!?…]["”’»)]*$/.test(tail))) ss.push(tail); // the server keeps the first 900 characters: a last sentence cut there is left out
|
|
1431
|
+
});
|
|
1432
|
+
th.ss = ss.filter((x) => x.length >= 12).map((x) => (x.length > 120 ? x.slice(0, 120).replace(/\s+\S*$/, '') + '…' : x)); th.i = 0;
|
|
1433
|
+
}
|
|
1396
1434
|
return th.i < th.ss.length ? (peek ? th.ss[th.i] : th.ss[th.i++]) : null;
|
|
1397
1435
|
}
|
|
1398
1436
|
function showThought(id, text) {
|
|
@@ -1407,7 +1445,7 @@ const clearThoughts = () => { document.querySelectorAll('.tile.musing').forEach(
|
|
|
1407
1445
|
function stopMumble() { const v = TH.voice; if (v) { TH.voice = null; try { v.src.stop(); } catch {} } }
|
|
1408
1446
|
async function mumble(id, text) { // lips closed: the mouth does not move, the music does not make room, and anyone who starts to speak cuts it
|
|
1409
1447
|
const p = S.agents[id], t = $('#t-' + id); if (!p || !t || !prefs.voice || AUD.off() || !AUD.ctx || S.speakers.size || TH.voice) return;
|
|
1410
|
-
if (!TTS.server || prefs.vstyle === 'animal' || prefs.vstyle === 'sims') { AUD.murmur(0.5 + (p.vi % 5) / 10, Math.min(2.2, estMs(text) / 1000), 0.4); return; }
|
|
1448
|
+
if (!TTS.server || prefs.vstyle === 'animal' || prefs.vstyle === 'sims') { const o = AUD.murmur(0.5 + (p.vi % 5) / 10, Math.min(2.2, estMs(text) / 1000), 0.4); if (o) { TH.voice = { id, src: o }; o.addEventListener('ended', () => { if (TH.voice && TH.voice.src === o) TH.voice = null; }); } return; }
|
|
1411
1449
|
const voice = TTS.voiceFor(p, frVoice(text)), token = S.token; if (!voice) return; let buf; try { buf = await TTS.buf(speakable(text), voice, TTS.rate(), true); } catch { return; } // made like a line prepared ahead: it never delays a real one
|
|
1412
1450
|
if (token !== S.token || S.speakers.size || TH.voice || !t.classList.contains('musing')) return; // someone spoke meanwhile, or the thought is gone
|
|
1413
1451
|
const c = AUD.ctx, src = c.createBufferSource(), lp = c.createBiquadFilter(), g = c.createGain(); src.buffer = buf; lp.type = 'lowpass'; lp.frequency.value = 1100; g.gain.value = 0.2; let stops = [];
|
|
@@ -1420,8 +1458,8 @@ setInterval(() => { // who gets a thought: someone at work for a while and sil
|
|
|
1420
1458
|
const ready = [];
|
|
1421
1459
|
for (const p of Object.values(S.agents)) {
|
|
1422
1460
|
const id = p.id, t = $('#t-' + id); if (id === 'user' || !t) continue;
|
|
1423
|
-
if (t.style.display === 'none' || S.gone.has(id) || agentState(id, now).k !== 'working') { delete TH.since[id]; continue; }
|
|
1424
|
-
TH.since[id] = TH.since[id] || now; if (!S.speakers.has(id) && !t.classList.contains('musing') && now - TH.since[id] > 15000 && now > (TH.next[id] || 0)) ready.push(id);
|
|
1461
|
+
if (t.style.display === 'none' || S.gone.has(id) || agentState(id, now).k !== 'working') { delete TH.since[id]; endThought(id); continue; } // done, or waiting: the thought goes with the work
|
|
1462
|
+
TH.since[id] = TH.since[id] || now; if (!S.speakers.has(id) && !t.classList.contains('musing') && now - TH.since[id] > 15000 && now > (TH.next[id] || 0) && !S.events.slice(S.pos).some((e) => e.agent === id && SPK.has(e.kind))) ready.push(id); // not when a line of its own is about to be read
|
|
1425
1463
|
}
|
|
1426
1464
|
if (!ready.length || now - TH.last < 5000 || document.querySelectorAll('.tile.musing').length >= 2) return;
|
|
1427
1465
|
const own = ready.filter((id) => realThought(id, true)), from = own.length ? own : ready; // whoever has reasoning of its own to show goes first
|
|
@@ -1744,8 +1782,8 @@ async function wrapUp(token) { // live, once the lead is done: the part of its
|
|
|
1744
1782
|
if (!S.wrap || S.speakers.size || !prefs.voice && !prefs.banter) return false; S.wrap = false;
|
|
1745
1783
|
let k = S.events.length - 1; while (k >= 0 && !(S.events[k].agent === 'main' && S.events[k].kind === 'say')) k--; if (k < 0) return false; const e = S.events[k], m = S.mainSaid;
|
|
1746
1784
|
if (S.wrapped === e || e.ts <= (S.from || 0)) return false; S.wrapped = e; // once per message: the lead's state can flip to done several times (a short command, a silence) without a new message
|
|
1747
|
-
const
|
|
1748
|
-
rest =
|
|
1785
|
+
const mine = !!m && m.ev === e; if (mine && m.full) return false; // mine: the reading of this very message was cut short. Otherwise it was never read, and is summed up from its start
|
|
1786
|
+
const rest = gist(e.text, mine ? plain(e.text).slice(0, m.said) : ''); if (rest.length < 25) return false; const line = (mine ? L('Pour résumer : ', 'To sum up: ') : '') + rest;
|
|
1749
1787
|
await sayLine('main', line, '🎉', token); return true;
|
|
1750
1788
|
}
|
|
1751
1789
|
async function smallTalk(token) {
|
|
@@ -2133,7 +2171,7 @@ function setView(v) { prefs.view = v; savePrefs(); document.body.classList.toggl
|
|
|
2133
2171
|
|
|
2134
2172
|
$('#viewSeg').onclick = (e) => { const b = e.target.closest('button'); if (b) setView(b.dataset.v); };
|
|
2135
2173
|
$('#langSel').onchange = () => setLang($('#langSel').value, true);
|
|
2136
|
-
$('#charPack').value = ['none', 'folder', '
|
|
2174
|
+
$('#charPack').value = ['none', 'folder', 'cats'].includes(prefs.charPack) ? prefs.charPack : 'none'; $('#charPack').onchange = () => { prefs.charPack = $('#charPack').value; savePrefs(); $('#charReload').onclick(); };
|
|
2137
2175
|
$('#charReload').onclick = async () => { await loadChars(); for (const k in imgCache) delete imgCache[k]; for (const id in S.agents) S.agents[id].custom = null; for (const id in S.agents) S.agents[id].custom = customFor(id); buildStage(); paintStatus(); renderCharInfo(); };
|
|
2138
2176
|
$('#charOpen').onclick = () => window.parent.postMessage({ agentStandup: 'openCharacters' }, '*');
|
|
2139
2177
|
$('#modeSeg').onclick = (e) => { const b = e.target.closest('button'); if (b) setMode(b.dataset.m); };
|
package/public/locales/da.json
CHANGED
|
@@ -214,7 +214,6 @@
|
|
|
214
214
|
"Cat pack": "Kattepakke",
|
|
215
215
|
"characters from the pack": "figurer fra pakken",
|
|
216
216
|
"Drawn characters": "Tegnede figurer",
|
|
217
|
-
"Starter pack": "Startpakke",
|
|
218
217
|
"To tweak them, copy them into your folder and pick “My folder”.": "For at tilpasse dem: kopiér dem til din mappe og vælg “Min mappe”.",
|
|
219
218
|
"My folder": "Min mappe",
|
|
220
219
|
"The characters drawn by the app. Pick “My folder” for your own images, or a pack.": "Figurerne tegnet af appen. Vælg “Min mappe” for dine egne billeder, eller en pakke."
|
package/public/locales/de.json
CHANGED
|
@@ -215,7 +215,6 @@
|
|
|
215
215
|
"Cat pack": "Katzen-Pack",
|
|
216
216
|
"characters from the pack": "Figuren aus dem Pack",
|
|
217
217
|
"Drawn characters": "Gezeichnete Figuren",
|
|
218
|
-
"Starter pack": "Starter-Pack",
|
|
219
218
|
"To tweak them, copy them into your folder and pick “My folder”.": "Zum Anpassen in deinen Ordner kopieren und „Mein Ordner“ wählen.",
|
|
220
219
|
"My folder": "Mein Ordner",
|
|
221
220
|
"The characters drawn by the app. Pick “My folder” for your own images, or a pack.": "Die von der App gezeichneten Figuren. Wähle „Mein Ordner“ für eigene Bilder oder ein Pack."
|
package/public/locales/en.json
CHANGED
|
@@ -168,7 +168,6 @@
|
|
|
168
168
|
"standing by": "standing by",
|
|
169
169
|
"Start Claude Code in {0} — the call will start by itself.": "Start Claude Code in {0} — the call will start by itself.",
|
|
170
170
|
"Start Claude Code in this folder — the call will start by itself.": "Start Claude Code in this folder — the call will start by itself.",
|
|
171
|
-
"Starter pack": "Starter pack",
|
|
172
171
|
"sub-agents active": "sub-agents active",
|
|
173
172
|
"task": "task",
|
|
174
173
|
"Tasks": "Tasks",
|
package/public/locales/es.json
CHANGED
|
@@ -215,7 +215,6 @@
|
|
|
215
215
|
"Cat pack": "Pack de gatos",
|
|
216
216
|
"characters from the pack": "personajes del pack",
|
|
217
217
|
"Drawn characters": "Personajes dibujados",
|
|
218
|
-
"Starter pack": "Pack inicial",
|
|
219
218
|
"To tweak them, copy them into your folder and pick “My folder”.": "Para retocarlos, cópialos en tu carpeta y elige «Mi carpeta».",
|
|
220
219
|
"My folder": "Mi carpeta",
|
|
221
220
|
"The characters drawn by the app. Pick “My folder” for your own images, or a pack.": "Los personajes dibujados por la aplicación. Elige «Mi carpeta» para tus propias imágenes, o un pack."
|
package/public/locales/ja.json
CHANGED
|
@@ -215,7 +215,6 @@
|
|
|
215
215
|
"Cat pack": "ネコパック",
|
|
216
216
|
"characters from the pack": "パックのキャラクター",
|
|
217
217
|
"Drawn characters": "描画キャラクター",
|
|
218
|
-
"Starter pack": "スターターパック",
|
|
219
218
|
"To tweak them, copy them into your folder and pick “My folder”.": "調整するには、自分のフォルダーにコピーして「自分のフォルダー」を選んでください。",
|
|
220
219
|
"My folder": "自分のフォルダー",
|
|
221
220
|
"The characters drawn by the app. Pick “My folder” for your own images, or a pack.": "アプリが描画するキャラクターです。自分の画像を使うには「自分のフォルダー」、またはパックを選んでください。"
|
|
@@ -215,7 +215,6 @@
|
|
|
215
215
|
"Cat pack": "Pacote de gatos",
|
|
216
216
|
"characters from the pack": "personagens do pacote",
|
|
217
217
|
"Drawn characters": "Personagens desenhados",
|
|
218
|
-
"Starter pack": "Pacote inicial",
|
|
219
218
|
"To tweak them, copy them into your folder and pick “My folder”.": "Para ajustá-los, copie-os para sua pasta e escolha “Minha pasta”.",
|
|
220
219
|
"My folder": "Minha pasta",
|
|
221
220
|
"The characters drawn by the app. Pick “My folder” for your own images, or a pack.": "Os personagens desenhados pelo aplicativo. Escolha “Minha pasta” para suas próprias imagens, ou um pacote."
|
|
@@ -215,7 +215,6 @@
|
|
|
215
215
|
"Cat pack": "猫咪包",
|
|
216
216
|
"characters from the pack": "来自角色包的角色",
|
|
217
217
|
"Drawn characters": "绘制的角色",
|
|
218
|
-
"Starter pack": "入门包",
|
|
219
218
|
"To tweak them, copy them into your folder and pick “My folder”.": "若要修改,请将它们复制到你的文件夹并选择“我的文件夹”。",
|
|
220
219
|
"My folder": "我的文件夹",
|
|
221
220
|
"The characters drawn by the app. Pick “My folder” for your own images, or a pack.": "由应用绘制的角色。选择“我的文件夹”使用你自己的图片,或选择一个角色包。"
|
|
@@ -215,7 +215,6 @@
|
|
|
215
215
|
"Cat pack": "貓咪包",
|
|
216
216
|
"characters from the pack": "來自角色包的角色",
|
|
217
217
|
"Drawn characters": "繪製的角色",
|
|
218
|
-
"Starter pack": "入門包",
|
|
219
218
|
"To tweak them, copy them into your folder and pick “My folder”.": "若要修改,請將它們複製到你的資料夾並選擇「我的資料夾」。",
|
|
220
219
|
"My folder": "我的資料夾",
|
|
221
220
|
"The characters drawn by the app. Pick “My folder” for your own images, or a pack.": "由應用程式繪製的角色。選擇「我的資料夾」使用你自己的圖片,或選擇一個角色包。"
|
package/server.js
CHANGED
|
@@ -327,7 +327,7 @@ function parseRecord(st, r) {
|
|
|
327
327
|
}
|
|
328
328
|
const th = () => { const t = st.th; st.th = ''; return t ? { th: t } : {}; }; // what the agent was thinking before this line or call, when Claude Code kept it (most of the time it only keeps a signature): the call shows it as an inner thought
|
|
329
329
|
for (const b of blocks(r)) {
|
|
330
|
-
if (b.type === 'thinking' && typeof b.thinking === 'string' && b.thinking.trim()) st.th = clip(b.thinking.replace(
|
|
330
|
+
if (b.type === 'thinking' && typeof b.thinking === 'string' && b.thinking.trim()) st.th = clip(b.thinking.replace(/[^\S\n]+/g, ' ').replace(/ ?\n[\n ]*/g, '\n').trim(), 900); // line breaks are kept: each line of its notes is a thought of its own
|
|
331
331
|
else if (b.type === 'text' && typeof b.text === 'string' && b.text.trim()) { said = push({ ts, agent, kind: 'say', text: b.text.trim(), ...th() }); st.act = st.raw.length - 1; st.sayOf = m.id; st.sayAt = st.act; }
|
|
332
332
|
else if (b.type === 'tool_use') {
|
|
333
333
|
st.toolName[b.id] = b.name; if (b.input && b.input.run_in_background) (st.bg = st.bg || {})[b.id] = true;
|
|
@@ -555,7 +555,7 @@ function tts(text, voice, rate, engine) {
|
|
|
555
555
|
if (kk) { if (!kokoro.hasVoice(voice)) return Promise.reject(new Error('unknown voice')); }
|
|
556
556
|
else { const vs = listVoices(); if (!vs.available || !vs.voices.some((v) => v.name === voice)) return Promise.reject(new Error('unknown voice')); }
|
|
557
557
|
text = String(text).slice(0, kk ? 1500 : 700); rate = Math.max(90, Math.min(360, Math.round(+rate || 180)));
|
|
558
|
-
const key = crypto.createHash('sha1').update([kk ? '
|
|
558
|
+
const key = crypto.createHash('sha1').update([kk ? 'kokoro2' : 'system', voice, rate, text].join('|')).digest('hex'), file = path.join(ttsDir, key + '.wav'); // kokoro2: clips made before 1.25.10 could be cut short and must not be played again
|
|
559
559
|
if (fs.existsSync(file)) return Promise.resolve(file);
|
|
560
560
|
if (ttsJobs.has(key)) return ttsJobs.get(key);
|
|
561
561
|
fs.mkdirSync(ttsDir, { recursive: true });
|
|
@@ -572,8 +572,8 @@ const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css
|
|
|
572
572
|
const CHAR_NAME = /^[\w][\w .()\-]{0,78}\.(png|gif|webp|jpe?g)$/i, CHAR_MAX = 300 * 1024, CHAR_TYPES = { png: 'image/png', gif: 'image/gif', webp: 'image/webp', jpg: 'image/jpeg', jpeg: 'image/jpeg' };
|
|
573
573
|
const charactersDir = () => process.env.AGENT_STANDUP_CHARACTERS || path.join(os.homedir(), '.agent-standup', 'characters');
|
|
574
574
|
// lstat, not stat: a symlink is not a regular file, so it is skipped and never served
|
|
575
|
-
// Built-in character packs, shipped with the app (examples/<folder>): picked in ⚙ → Characters
|
|
576
|
-
const PACKS = {
|
|
575
|
+
// Built-in character packs, shipped with the app (examples/<folder>): picked in ⚙ → Characters. examples/characters stays in the repository as a model for one's own folder; it is not shipped
|
|
576
|
+
const PACKS = { cats: 'cats' }, packDir = (name) => (PACKS[name] ? path.join(__dirname, 'examples', PACKS[name]) : null);
|
|
577
577
|
function listCharacters(pack) {
|
|
578
578
|
const dir = pack ? packDir(pack) : charactersDir(); let names = []; if (!dir) return { dir: '', max: CHAR_MAX, chars: [] }; try { names = fs.readdirSync(dir); } catch {}
|
|
579
579
|
const ok = names.filter((f) => CHAR_NAME.test(f)).filter((f) => { try { const st = fs.lstatSync(path.join(dir, f)); return st.isFile() && st.size <= CHAR_MAX; } catch { return false; } }).slice(0, 80);
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
Example pixel characters (public domain, made for Agent Standup).
|
|
2
|
-
Copy them into your characters folder to try the feature, then draw your own:
|
|
3
|
-
- 32x32 or 64x64 px PNG with a transparent background, facing front, feet at the bottom
|
|
4
|
-
- lead.png = the lead (Claude), you.png = you, any other name = a sub-agent
|
|
5
|
-
- name-talk.png = optional open-mouth frame
|
|
Binary file
|
|
Binary file
|
|
Binary file
|