@double_geste/agent-standup 1.25.9 → 1.26.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.
package/kokoro-worker.js CHANGED
@@ -12,6 +12,18 @@ function wav(samples, rate) { // Float32 in [-1, 1] → 16-bit mono WAV
12
12
  return buf;
13
13
  }
14
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
+ }
15
27
  async function main() {
16
28
  const dir = process.env.AGENT_STANDUP_KOKORO_DIR, dtype = process.env.AGENT_STANDUP_KOKORO_DTYPE || 'q4';
17
29
  const req = createRequire(path.join(dir, 'node_modules', 'x.js')); // resolve from the install folder, not from the extension
@@ -24,13 +36,19 @@ async function main() {
24
36
  for (const ext of ['.onnx', '.onnx_data']) { try { fs.unlinkSync(path.join(onnx, name + ext)); dropped++; } catch {} }
25
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();
26
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
+ };
27
46
  send({ ready: true });
28
47
  for await (const line of readline.createInterface({ input: process.stdin })) {
29
48
  let m; try { m = JSON.parse(line); } catch { continue; }
30
49
  try {
31
- let a = await tts.generate(String(m.text), { voice: m.voice, speed: +m.speed || 1 });
32
- if (!finite(a.audio)) { a = await tts.generate(String(m.text), { voice: m.voice, speed: +m.speed || 1 }); if (!finite(a.audio)) throw new Error('the model returned NaN'); } // made once more; if it fails again the server hands the line to a system voice
33
- 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 });
34
52
  }
35
53
  catch (e) { send({ id: m.id, ok: false, error: String((e && e.message) || e).slice(0, 200) }); }
36
54
  }
package/media/icon.png CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@double_geste/agent-standup",
3
- "version": "1.25.9",
3
+ "version": "1.26.0",
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"
@@ -0,0 +1 @@
1
+ <svg width="64" height="64" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><defs><clipPath id="room_16_c"><rect width="1024" height="1024" rx="230"/></clipPath><linearGradient id="room_16_room" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#10282f"/><stop offset="1" stop-color="#18444f"/></linearGradient><linearGradient id="room_16_body" gradientUnits="userSpaceOnUse" x1="0" y1="12" x2="0" y2="72"><stop offset="0" stop-color="#efa98f"/><stop offset="1" stop-color="#e8845f"/></linearGradient><linearGradient id="room_16_desk" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#506b73"/><stop offset="1" stop-color="#2f4850"/></linearGradient><linearGradient id="room_16_scene" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#10282f"/><stop offset="1" stop-color="#18444f"/></linearGradient></defs><g clip-path="url(#room_16_c)"><rect width="1024" height="1024" fill="url(#room_16_room)"/><g transform="translate(-355.797 -138.847) scale(17.35593)"><ellipse cx="25" cy="56" rx="4.6" ry="9" fill="#ba6a4c" transform="rotate(12 25 56)"/><rect x="27" y="20" width="46" height="56" rx="22" fill="url(#room_16_body)"/><ellipse cx="75" cy="56" rx="4.6" ry="9" fill="#ba6a4c" transform="rotate(-12 75 56)"/><ellipse cx="41.5" cy="40" rx="3.1" ry="4.3" fill="#2a2233"/><ellipse cx="58.5" cy="40" rx="3.1" ry="4.3" fill="#2a2233"/><path d="M37.9 32.7q3.6-2 7.2 0" fill="none" stroke="#2a2233" stroke-width="2.8" stroke-linecap="round" transform="rotate(13 41.5 32.2) translate(0 .5)"/><path d="M54.9 32.7q3.6-2 7.2 0" fill="none" stroke="#2a2233" stroke-width="2.8" stroke-linecap="round" transform="rotate(-13 58.5 32.2) translate(0 .5)"/><path d="M46 50.5q4 3.6 8 0" fill="none" stroke="#2a2233" stroke-width="2.9" stroke-linecap="round"/><path d="M26.5 37Q27 13 50 13Q73 13 73.5 37" fill="none" stroke="#2c2f40" stroke-width="4.2" stroke-linecap="round"/><rect x="23" y="33" width="7" height="14" rx="3.5" fill="#f5c04a"/><rect x="70" y="33" width="7" height="14" rx="3.5" fill="#f5c04a"/></g></g></svg>
@@ -0,0 +1 @@
1
+ <svg width="64" height="64" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><defs><clipPath id="night_32_c"><rect width="1024" height="1024" rx="230"/></clipPath><linearGradient id="night_32_room" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#10282f"/><stop offset="1" stop-color="#18444f"/></linearGradient><linearGradient id="night_32_body" gradientUnits="userSpaceOnUse" x1="0" y1="12" x2="0" y2="72"><stop offset="0" stop-color="#efa98f"/><stop offset="1" stop-color="#e8845f"/></linearGradient><linearGradient id="night_32_desk" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#506b73"/><stop offset="1" stop-color="#2f4850"/></linearGradient><linearGradient id="night_32_scene" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#10282f"/><stop offset="1" stop-color="#18444f"/></linearGradient></defs><g clip-path="url(#night_32_c)"><rect width="1024" height="1024" fill="url(#night_32_room)"/><g transform="translate(-300.698 -97.524) scale(16.25397)"><ellipse cx="25" cy="56" rx="4.6" ry="9" fill="#ba6a4c" transform="rotate(12 25 56)"/><rect x="27" y="20" width="46" height="56" rx="22" fill="url(#night_32_body)"/><ellipse cx="75" cy="56" rx="4.6" ry="9" fill="#ba6a4c" transform="rotate(-12 75 56)"/><ellipse cx="35.5" cy="46" rx="3.3" ry="2" fill="#ff6f91" opacity=".34"/><ellipse cx="64.5" cy="46" rx="3.3" ry="2" fill="#ff6f91" opacity=".34"/><ellipse cx="41.5" cy="40" rx="3.1" ry="4.3" fill="#2a2233"/><circle cx="42.5" cy="38.5" r="1.1" fill="#fff"/><ellipse cx="58.5" cy="40" rx="3.1" ry="4.3" fill="#2a2233"/><circle cx="59.5" cy="38.5" r="1.1" fill="#fff"/><path d="M37.9 32.7q3.6-2 7.2 0" fill="none" stroke="#2a2233" stroke-width="2.1" stroke-linecap="round" transform="rotate(13 41.5 32.2) translate(0 .5)"/><path d="M54.9 32.7q3.6-2 7.2 0" fill="none" stroke="#2a2233" stroke-width="2.1" stroke-linecap="round" transform="rotate(-13 58.5 32.2) translate(0 .5)"/><path d="M46 50.5q4 3.6 8 0" fill="none" stroke="#2a2233" stroke-width="2.2" stroke-linecap="round"/><path d="M26.5 37Q27 13 50 13Q73 13 73.5 37" fill="none" stroke="#2c2f40" stroke-width="3.7" stroke-linecap="round"/><rect x="23" y="33" width="7" height="14" rx="3.5" fill="#f5c04a" stroke="#2c2f40" stroke-width="1.4"/><rect x="70" y="33" width="7" height="14" rx="3.5" fill="#f5c04a" stroke="#2c2f40" stroke-width="1.4"/><path d="M27 45q-2 11 13 12" fill="none" stroke="#2c2f40" stroke-width="2" stroke-linecap="round"/><circle cx="40" cy="57" r="2.53" fill="#2c2f40"/><rect x="-20" y="62" width="140" height="33" fill="url(#night_32_desk)"/><rect x="-20" y="62" width="140" height="1.3" fill="#fff" opacity=".38"/><rect x="-20" y="63.3" width="140" height="2.6" fill="#000" opacity=".1"/><g transform="translate(-11.5 0)"><rect x="79" y="55.6" width="7.4" height="6.8" rx="1.6" fill="#f6f2ea"/><path d="M86.2 57q3.2 0 3.2 2.2t-3.2 2.2" fill="none" stroke="#f6f2ea" stroke-width="1.3"/><g fill="none" stroke="#fff" stroke-width=".8" stroke-linecap="round" opacity=".7"><path d="M81.5 53q-1.4-1.8 0-3.4M84.3 53q-1.4-1.8 0-3.4"/></g></g></g></g></svg>
package/public/index.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1">
6
6
  <title>Agent Standup</title>
7
- <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E🎥%3C/text%3E%3C/svg%3E">
7
+ <link rel="icon" href="favicon.svg" type="image/svg+xml">
8
8
  <style>
9
9
  :root { --bg:#12131a; --panel:#1b1d27; --panel2:#232634; --line:#2a2d3b; --text:#e9eaf0; --dim:#9aa0b6; --ease:cubic-bezier(.2,.8,.2,1); --accent:#7c9cff; --gold:#f5c04a; --ok:#58d68d; --warn:#f0a020; --red:#ff5a5a; --r:10px }
10
10
  * { box-sizing:border-box }
@@ -20,7 +20,7 @@
20
20
  .brand { display:flex; align-items:center; gap:9px; min-width:0; overflow:hidden } .brand .logo { flex:none } .brand > div { min-width:0 } .brand b { overflow:hidden; text-overflow:ellipsis }
21
21
  header > * { flex-shrink:0 } header .brand { flex-shrink:100 } header #board { flex-shrink:50 } .tok, .ctxp { flex-shrink:2; min-width:0; overflow:hidden; text-overflow:ellipsis } #pill { flex-shrink:.05; min-width:0; overflow:hidden; text-overflow:ellipsis } /* in a narrow window the buttons (the gear above all) stay whole: the name gives way first, then the counters */
22
22
  @media (max-width:720px) { .ctxp { display:none !important } } @media (max-width:620px) { .tok { display:none !important } } @media (max-width:560px) { #bCoffee { display:none !important } } @media (max-width:480px) { header { gap:6px; padding-left:8px; padding-right:8px } } /* narrow: the coffee link stays in ⚙ › Tools */
23
- .logo { width:26px; height:26px; border-radius:8px; background:#d97757; display:grid; place-items:center; font-size:15px; flex:none }
23
+ .logo { width:26px; height:26px; display:block; flex:none }
24
24
  .brand b { font:700 14px "Comic Sans MS","Chalkboard SE",system-ui,sans-serif; white-space:nowrap }
25
25
  .brand small { display:block; color:var(--dim); font-size:11px; max-width:260px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
26
26
  .sp { flex:1 }
@@ -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:block; top:auto; bottom:36px; 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:46px; font-size:13px } .tile.musing:not(.speaking):not(.bye) .bubble small { display:inline; margin-right:5px; font-style:normal }
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 } }
@@ -254,7 +255,7 @@
254
255
  </head>
255
256
  <body>
256
257
  <header>
257
- <div class="brand"><div class="logo">🎥</div><div><b>Agent Standup</b><small id="subtitle"></small></div></div>
258
+ <div class="brand"><img class="logo" src="icon.svg" alt=""><div><b>Agent Standup</b><small id="subtitle"></small></div></div>
258
259
  <span id="pill">REPLAY</span><button id="tok" class="tok" style="display:none"></button><button id="ctxp" class="ctxp" style="display:none"></button><span id="board" aria-live="polite"></span><span id="meet" class="meet"></span>
259
260
  <div class="sp"></div>
260
261
  <div class="seg" id="viewSeg"><button data-v="call" class="on" data-ic="👥" data-fr="Appel" data-en="Call">Appel</button><button data-v="tree" data-ic="🌳" data-fr="Arbre" data-en="Tree">Arbre</button><button data-v="board" data-ic="🗂" data-fr="Sessions" data-en="Sessions">Sessions</button></div>
@@ -451,10 +452,9 @@ const SCENES = [
451
452
  <circle cx="22" cy="26" r="11" fill="#ff9bb8"/><ellipse cx="22" cy="26" rx="18" ry="3.6" fill="none" stroke="#ffe0a8" stroke-width="1.6" transform="rotate(-18 22 26)"/><path d="M13 22q9 4 18 1" stroke="#e86f98" stroke-width="1.4" fill="none"/>
452
453
  <circle cx="80" cy="20" r="6" fill="#6fd3ff"/><circle cx="77.5" cy="18" r="1.4" fill="#4ab6e6"/>${star(60, 14, 3)}${star(90, 40, 2.2, '#cfc2ff')}` },
453
454
  { desk: ['#506b73', '#2f4850'], svg: () => `<defs>${grad('gs6', '#10282f', '#18444f')}</defs><rect width="100" height="75" fill="url(#gs6)"/>
454
- ${[[5, 8], [36, 6], [67, 8]].map(([x, y], i) => `<rect x="${x}" y="${y}" width="28" height="22" rx="2.5" fill="#0a1a1f" stroke="#2b6a78" stroke-width=".8"/>
455
- <polyline points="${x + 3},${y + 17} ${x + 9},${y + 11} ${x + 14},${y + 14} ${x + 20},${y + 6} ${x + 25},${y + 9}" fill="none" stroke="${['#ff9a6b', '#4fd1a5', '#ffd84a'][i]}" stroke-width="1.3" stroke-linejoin="round"/>
456
- <rect x="${x + 3}" y="${y + 3}" width="9" height="1.4" rx=".7" fill="#4cc3ff" opacity=".7"/>`).join('')}
457
- <g fill="#ffe9a8" opacity=".7"><circle cx="20" cy="36" r="1" class="pulse"/><circle cx="50" cy="38" r="1" class="pulse" style="animation-delay:-.6s"/><circle cx="80" cy="36" r="1" class="pulse" style="animation-delay:-1.1s"/></g>` },
455
+ <rect x="9" y="-6" width="82" height="42" rx="3" fill="#0a1a1f"/>
456
+ ${[[12, 22, 9], [22, 31, 8], [68, 27, 9], [78, 19, 9]].map(([x, h, w], i) => `<rect x="${x}" y="${36 - h}" width="${w}" height="${h}" fill="${i % 2 ? '#15373f' : '#123038'}"/>` + [0, 1, 2].map((r) => `<rect x="${x + 1.6 + ((i + r) % 2) * 3.4}" y="${39 - h + r * 6.4}" width="1.8" height="2.4" rx=".4" fill="#ffd84a" opacity="${r === 1 ? '.9' : '.5'}"${r === 1 ? ` class="tw" style="animation-delay:-${i * .8}s"` : ''}/>`).join('')).join('')}
457
+ <path d="M36.6 0V36M63.4 0V36" stroke="#2b6a78" stroke-width="1.2"/><rect x="9" y="-6" width="82" height="42" rx="3" fill="none" stroke="#2b6a78" stroke-width="1.2"/>` },
458
458
  { desk: ['#a87a56', '#7c5438'], svg: () => `<defs>${grad('gs7', '#3a2b52', '#6b4a68')}<radialGradient id="gl7"><stop offset="0" stop-color="#ffd98a" stop-opacity=".75"/><stop offset="1" stop-color="#ffd98a" stop-opacity="0"/></radialGradient></defs>
459
459
  <rect width="100" height="75" fill="url(#gs7)"/><circle cx="82" cy="30" r="26" fill="url(#gl7)"/>
460
460
  <path d="M82 54V36" stroke="#2a2233" stroke-width="1.6"/><path d="M75 36q7-9 14 0z" fill="#ffd98a"/>
@@ -545,6 +545,36 @@ function short(t, max = 230) {
545
545
  const cut = s.slice(0, max), end = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('! '), cut.lastIndexOf('? '));
546
546
  return end > 80 ? cut.slice(0, end + 1) : cut.replace(/\s+\S*$/, '') + '…';
547
547
  }
548
+ // 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.
549
+ // 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.
550
+ // 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).
551
+ function gist(md, heard = '', max = 280) { // max: 300 characters with the lead-in, 15 to 20 s of speech
552
+ 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
553
+ for (const raw of String(md).split('\n')) {
554
+ const l = raw.replace(/^\s*(?:>\s*)*/, '').trimEnd(); // a quoted line is read like any other
555
+ if (/^(?:```|~~~)/.test(l)) { fence = !fence; cur = null; blocks.push(null); continue; }
556
+ if (fence) continue; // code: for the eye only
557
+ if (!l || /^(?:[-*_=]\s*){3,}$/.test(l)) { cur = null; continue; } // an empty line or a rule ends the paragraph
558
+ if (l.split('|').length > 2) { cur = null; blocks.push(null); continue; } // a table row (null: something was skipped here)
559
+ 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
560
+ 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
561
+ wrapped = !h && l.length > 60 && !/[.!?…:;]["')»*_]*$/.test(l);
562
+ }
563
+ const end = (s) => (/[.!?…]["')»]*$/.test(s) ? s : s.replace(/[\s:;,–—-]+$/, '') + '.'); // every piece ends like a sentence: that is where the voice breathes
564
+ 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
565
+ for (const b of blocks) {
566
+ if (!b) { intro = intro.filter((x) => x.h); continue; } // "Here is the table:" has nothing left to introduce
567
+ 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
568
+ 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();
569
+ 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
570
+ 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);
571
+ 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
572
+ 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
573
+ }
574
+ let n = 0; const keep = new Set(), cost = (u) => u.pre.reduce((a, x) => a + x.s.length + 1, u.s.length + 1);
575
+ 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
576
+ 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
577
+ }
548
578
  // make code-ish text pronounceable
549
579
  const PER = /^(?:s|sec|ms|min|h|hr|j|jour|d|day|mois|mo|month|an|yr|year)$/i;
550
580
  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")
@@ -1149,11 +1179,11 @@ function watchDone(n) { // live: the team was busy for a while and has now bee
1149
1179
  if (EMBED) window.parent.postMessage({ agentStandup: 'notify', text: msg }, '*');
1150
1180
  else { try { if (document.hidden && 'Notification' in window && Notification.permission === 'granted') new Notification('Agent Standup', { body: msg }); } catch {} }
1151
1181
  }
1152
- const FAV0 = (document.querySelector('link[rel="icon"]') || {}).href;
1182
+ const FAV0 = (document.querySelector('link[rel="icon"]') || {}).href, FAVI = new Image(); FAVI.src = FAV0 || '';
1153
1183
  function setFav(color) {
1154
1184
  const l = document.querySelector('link[rel="icon"]'); if (!l || setFav.cur === color) return; setFav.cur = color;
1155
1185
  if (!color) { l.href = FAV0; return; }
1156
- const c = document.createElement('canvas'); c.width = c.height = 32; const g = c.getContext('2d'); g.font = '26px serif'; g.fillText('🎥', 0, 26); g.fillStyle = color; g.beginPath(); g.arc(24, 8, 8, 0, 7); g.fill(); g.strokeStyle = '#12131a'; g.lineWidth = 2; g.stroke(); l.href = c.toDataURL();
1186
+ const c = document.createElement('canvas'); c.width = c.height = 32; const g = c.getContext('2d'); try { g.drawImage(FAVI, 0, 0, 32, 32); } catch {} g.fillStyle = color; g.beginPath(); g.arc(24, 8, 8, 0, 7); g.fill(); g.strokeStyle = '#12131a'; g.lineWidth = 2; g.stroke(); l.href = c.toDataURL();
1157
1187
  }
1158
1188
  function updateTitle() {
1159
1189
  paintStates(); if (S.live && S.nBlocked && !S.wasBlocked && prefs.notify) { AUD.alert(); const msg = L('Un agent attend peut-être votre feu vert ✋', 'An agent may be waiting for you ✋'); if (EMBED) window.parent.postMessage({ agentStandup: 'notify', text: msg }, '*'); else { try { if (document.hidden && 'Notification' in window && Notification.permission === 'granted') new Notification('Agent Standup', { body: msg }); } catch {} } } S.wasBlocked = S.nBlocked > 0;
@@ -1325,7 +1355,7 @@ setInterval(() => {
1325
1355
  // ---------- speaking tiles ----------
1326
1356
  const mouths = new Map();
1327
1357
  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
1358
+ 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
1359
  if (S.present && S.present !== id) hidePresent();
1330
1360
  S.spokeAt = performance.now(); S.fill = 0; S.mused = false;
1331
1361
  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 +1399,37 @@ const clearSpeaking = () => [...S.speakers].forEach(stopTalk);
1369
1399
  const TH = { since: {}, next: {}, said: {}, last: 0, voice: null };
1370
1400
  const THINK = {
1371
1401
  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.'], many: ['{n} calls in. What do I know so far?'] },
1402
+ 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
1403
  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.'], many: ['{n} appels. Qu’est-ce que je sais, à ce stade ?'] },
1404
+ 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
1405
  };
1376
1406
  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*$/, '') + '…' : x).replace(/[.\s]+$/, ''); };
1407
+ 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
1408
  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 what = (e) => { const d = e.tool.d || {}; return cut(d.note || (d.kind === 'cmd' ? String(d.text || '').trim().split(/\s+/).slice(0, 3).join(' ') : base(d.file) || e.tool.name), 60); };
1409
+ 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
1410
+ 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
1411
+ 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
1412
  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
1413
  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];
1414
+ 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
1415
  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
1416
  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: cut(g.tool.d.pattern, 30) });
1417
+ 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
1418
  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] });
1419
+ 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
1420
  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
1421
  const line = pool[Math.floor(Math.random() * pool.length)]; seen.push(line); if (seen.length > 6) seen.shift(); return line;
1392
1422
  }
1393
1423
  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
1424
  const th = S.th[id]; if (!th || Date.now() - th.ts > 600000) return null;
1395
- if (!th.ss) { th.ss = (th.text.match(/[^.!?]+(?:[.!?]+|$)/g) || []).map((x) => x.trim()).filter((x) => x.length >= 12).map((x) => (x.length > 150 ? x.slice(0, 150).replace(/\s+\S*$/, '') + '…' : x)); th.i = 0; }
1425
+ 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
1426
+ 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
1427
+ lines.forEach((t, k) => {
1428
+ 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; }
1429
+ 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
1430
+ });
1431
+ th.ss = ss.filter((x) => x.length >= 12).map((x) => (x.length > 120 ? x.slice(0, 120).replace(/\s+\S*$/, '') + '…' : x)); th.i = 0;
1432
+ }
1396
1433
  return th.i < th.ss.length ? (peek ? th.ss[th.i] : th.ss[th.i++]) : null;
1397
1434
  }
1398
1435
  function showThought(id, text) {
@@ -1407,7 +1444,7 @@ const clearThoughts = () => { document.querySelectorAll('.tile.musing').forEach(
1407
1444
  function stopMumble() { const v = TH.voice; if (v) { TH.voice = null; try { v.src.stop(); } catch {} } }
1408
1445
  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
1446
  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; }
1447
+ 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
1448
  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
1449
  if (token !== S.token || S.speakers.size || TH.voice || !t.classList.contains('musing')) return; // someone spoke meanwhile, or the thought is gone
1413
1450
  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 +1457,8 @@ setInterval(() => { // who gets a thought: someone at work for a while and sil
1420
1457
  const ready = [];
1421
1458
  for (const p of Object.values(S.agents)) {
1422
1459
  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);
1460
+ 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
1461
+ 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
1462
  }
1426
1463
  if (!ready.length || now - TH.last < 5000 || document.querySelectorAll('.tile.musing').length >= 2) return;
1427
1464
  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 +1781,8 @@ async function wrapUp(token) { // live, once the lead is done: the part of its
1744
1781
  if (!S.wrap || S.speakers.size || !prefs.voice && !prefs.banter) return false; S.wrap = false;
1745
1782
  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
1783
  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 full = plain(e.text); let rest = m && m.ev === e ? full.slice(m.said).trim() : full; if (m && m.ev === e && m.full) return false; if (!m || m.ev !== e) rest = full;
1748
- rest = rest.replace(/^[^\w\p{L}]+/u, ''); if (rest.length < 25) return false; const line = (m && m.ev === e ? L('Pour résumer : ', 'To sum up: ') : '') + short(rest, 520);
1784
+ 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
1785
+ 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
1786
  await sayLine('main', line, '🎉', token); return true;
1750
1787
  }
1751
1788
  async function smallTalk(token) {
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(/\s+/g, ' ').trim(), 900);
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 ? 'kokoro' : 'system', voice, rate, text].join('|')).digest('hex'), file = path.join(ttsDir, key + '.wav');
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 });