@double_geste/agent-standup 1.22.1 → 1.23.1

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.
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ // Kokoro worker: loads the Kokoro-82M model once, then reads one JSON request per stdin line ({ id, text, voice, speed, out }),
3
+ // writes a 16-bit WAV to `out` and answers { id, ok, seconds } or { id, ok: false, error }. Started by kokoro.js.
4
+ const fs = require('fs'), path = require('path'), readline = require('readline'), { createRequire } = require('module');
5
+ const MODEL = 'onnx-community/Kokoro-82M-v1.0-ONNX';
6
+ const send = (o) => fs.writeSync(1, JSON.stringify(o) + '\n');
7
+ const finite = (samples) => { for (let i = 0; i < samples.length; i++) if (samples[i] !== samples[i]) return false; return true; }; // a quantised model can return NaN for a whole line, which would be written as silence
8
+ function wav(samples, rate) { // Float32 in [-1, 1] → 16-bit mono WAV
9
+ const n = samples.length, buf = Buffer.alloc(44 + n * 2);
10
+ buf.write('RIFF', 0); buf.writeUInt32LE(36 + n * 2, 4); buf.write('WAVEfmt ', 8); buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); buf.writeUInt16LE(1, 22); buf.writeUInt32LE(rate, 24); buf.writeUInt32LE(rate * 2, 28); buf.writeUInt16LE(2, 32); buf.writeUInt16LE(16, 34); buf.write('data', 36); buf.writeUInt32LE(n * 2, 40);
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
+ return buf;
13
+ }
14
+ async function main() {
15
+ const dir = process.env.AGENT_STANDUP_KOKORO_DIR, dtype = process.env.AGENT_STANDUP_KOKORO_DTYPE || 'q4f16';
16
+ const req = createRequire(path.join(dir, 'node_modules', 'x.js')); // resolve from the install folder, not from the extension
17
+ const tf = req('@huggingface/transformers'); tf.env.cacheDir = path.join(dir, 'models');
18
+ const { KokoroTTS } = req('kokoro-js'); const tts = await KokoroTTS.from_pretrained(MODEL, { dtype, device: 'cpu' });
19
+ send({ ready: true });
20
+ for await (const line of readline.createInterface({ input: process.stdin })) {
21
+ let m; try { m = JSON.parse(line); } catch { continue; }
22
+ try {
23
+ let a = await tts.generate(String(m.text), { voice: m.voice, speed: +m.speed || 1 });
24
+ 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
25
+ fs.writeFileSync(m.out, wav(a.audio, a.sampling_rate)); send({ id: m.id, ok: true, seconds: a.audio.length / a.sampling_rate });
26
+ }
27
+ catch (e) { send({ id: m.id, ok: false, error: String((e && e.message) || e).slice(0, 200) }); }
28
+ }
29
+ }
30
+ main().catch((e) => { console.error(String((e && e.stack) || e)); process.exit(1); });
package/kokoro.js ADDED
@@ -0,0 +1,82 @@
1
+ // Natural voices with Kokoro-82M (kokoro-js on ONNX Runtime), installed on demand outside the extension.
2
+ // Nothing here runs until the person clicks Install in ⚙ → Sound: the packages (about 430 MB) go to one user folder, the model (about 155 MB) is fetched on first use.
3
+ const fs = require('fs'), os = require('os'), path = require('path'), { spawn } = require('child_process');
4
+
5
+ const WIN = process.platform === 'win32';
6
+ const DIR = process.env.AGENT_STANDUP_KOKORO_DIR || (WIN ? path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'agent-standup', 'kokoro') : path.join(os.homedir(), '.cache', 'agent-standup', 'kokoro'));
7
+ const PKG = 'kokoro-js@1.2.1', DTYPE = 'q4', SIZE_MB = 730, MAX_RESTARTS = 3;
8
+ const WORKER = process.env.AGENT_STANDUP_KOKORO_WORKER || path.join(__dirname, 'kokoro-worker.js'), LOG = path.join(DIR, 'install.log');
9
+
10
+ // In the order agents get them (the lead takes the first), so successive agents sound distinct. French has a single voice in Kokoro.
11
+ const VOICES = [['af_heart', 'Heart', 'en_US'], ['am_fenrir', 'Fenrir', 'en_US'], ['bf_emma', 'Emma', 'en_GB'], ['am_michael', 'Michael', 'en_US'], ['af_bella', 'Bella', 'en_US'], ['bm_george', 'George', 'en_GB'], ['af_sarah', 'Sarah', 'en_US'], ['am_puck', 'Puck', 'en_US'], ['ff_siwis', 'Siwis', 'fr_FR']].map(([name, label, locale]) => ({ name, label, locale }));
12
+ const voices = () => VOICES, hasVoice = (n) => VOICES.some((v) => v.name === n);
13
+ const speedFor = (wpm) => Math.max(0.5, Math.min(2, Math.round((wpm / 178) * 20) / 20)); // 178 words a minute is about Kokoro's natural pace
14
+ const exists = (f) => { try { fs.statSync(f); return true; } catch { return false; } };
15
+ const installed = () => exists(path.join(DIR, 'node_modules', 'kokoro-js', 'package.json'));
16
+
17
+ function findNpm() {
18
+ const home = os.homedir(), names = WIN ? ['npm.cmd', 'npm.bat', 'npm'] : ['npm'], cands = [];
19
+ for (const d of (process.env.PATH || '').split(path.delimiter)) for (const n of names) cands.push(path.join(d, n));
20
+ const dirs = WIN ? [path.join(process.env.APPDATA || '', 'npm'), path.join(process.env.ProgramFiles || 'C:\\Program Files', 'nodejs')] : ['/opt/homebrew/bin', '/usr/local/bin', path.join(home, '.local/bin'), path.join(home, '.npm-global/bin'), path.join(home, '.volta/bin')];
21
+ try { const nvm = path.join(home, '.nvm', 'versions', 'node'); for (const v of fs.readdirSync(nvm)) dirs.push(path.join(nvm, v, 'bin')); } catch {}
22
+ for (const d of dirs) for (const n of names) cands.push(path.join(d, n));
23
+ return cands.find((c) => c && exists(c)) || null;
24
+ }
25
+
26
+ // ---- install: `npm install --prefix DIR kokoro-js@x`, output in DIR/install.log, one job at a time
27
+ let job = null, lastError = '';
28
+ function install() {
29
+ if (job || installed()) return status();
30
+ const npm = findNpm(); if (!npm) { lastError = 'npm not found'; return status(); }
31
+ fs.mkdirSync(DIR, { recursive: true }); lastError = '';
32
+ const out = fs.openSync(LOG, 'w'), args = ['install', '--prefix', DIR, '--no-audit', '--no-fund', PKG];
33
+ job = WIN && /\.(cmd|bat)$/i.test(npm) ? spawn('"' + npm + '"', args.map((a) => (a === DIR ? '"' + a + '"' : a)), { shell: true, stdio: ['ignore', out, out], windowsHide: true }) : spawn(npm, args, { stdio: ['ignore', out, out], windowsHide: true });
34
+ const done = (why) => { job = null; try { fs.closeSync(out); } catch {} if (why) lastError = why; else warm(); };
35
+ job.on('error', (e) => done('npm: ' + e.message));
36
+ job.on('close', (code) => done(code === 0 && installed() ? '' : 'npm install failed (exit ' + code + ')'));
37
+ return status();
38
+ }
39
+
40
+ // ---- worker: one child process that holds the model, started on first use, restarted after a crash up to MAX_RESTARTS times; one line at a time
41
+ let worker = null, restarts = 0, seq = 0; const queue = []; let busy = false;
42
+ function startWorker() {
43
+ if (worker) return worker;
44
+ if (restarts >= MAX_RESTARTS) throw new Error('kokoro worker gave up');
45
+ const p = spawn(process.execPath, [WORKER], { env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', AGENT_STANDUP_KOKORO_DIR: DIR, AGENT_STANDUP_KOKORO_DTYPE: DTYPE }, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
46
+ const w = (worker = { p, ready: false, pending: new Map(), err: '' }); let buf = '';
47
+ p.stdout.on('data', (d) => {
48
+ buf += d; let i;
49
+ while ((i = buf.indexOf('\n')) >= 0) {
50
+ const line = buf.slice(0, i); buf = buf.slice(i + 1); let m; try { m = JSON.parse(line); } catch { continue; } // library chatter is ignored
51
+ if (m.ready) { w.ready = true; lastError = ''; continue; }
52
+ const r = w.pending.get(m.id); if (!r) continue; w.pending.delete(m.id); m.ok ? r.resolve(m) : r.reject(new Error(m.error || 'tts failed'));
53
+ }
54
+ });
55
+ p.stderr.on('data', (d) => { w.err = (w.err + d).slice(-600); }); p.stdin.on('error', () => {});
56
+ const gone = (why) => { if (worker !== w) return; worker = null; restarts++; lastError = why; const jobs = [...w.pending.values()]; w.pending.clear(); busy = false; for (const r of jobs) r.reject(new Error(why)); };
57
+ p.on('error', (e) => gone('kokoro worker: ' + e.message)); p.on('close', (code) => gone('kokoro worker exited (' + code + ') ' + (w.err.trim().split('\n').pop() || '')));
58
+ return w;
59
+ }
60
+ function warm() { if (installed() && !worker && restarts < MAX_RESTARTS) try { startWorker(); } catch {} }
61
+ function pump() {
62
+ if (busy || !queue.length) return;
63
+ const j = queue.shift(); let w; try { w = startWorker(); } catch (e) { j.reject(e); return pump(); }
64
+ const id = ++seq, next = (fn) => (v) => { busy = false; fn(v); pump(); }; busy = true;
65
+ w.pending.set(id, { resolve: next(j.resolve), reject: next(j.reject) });
66
+ w.p.stdin.write(JSON.stringify({ id, text: j.text, voice: j.voice, speed: j.speed, out: j.out }) + '\n');
67
+ }
68
+ function synth(text, voice, speed, out) {
69
+ if (!installed()) return Promise.reject(new Error('kokoro not installed'));
70
+ if (!hasVoice(voice)) return Promise.reject(new Error('unknown voice'));
71
+ return new Promise((resolve, reject) => { queue.push({ text: String(text).slice(0, 2500), voice, speed, out, resolve, reject }); pump(); });
72
+ }
73
+ function status() {
74
+ let log = ''; try { log = fs.readFileSync(LOG, 'utf8').slice(-400); } catch {}
75
+ return { dir: DIR, sizeMb: SIZE_MB, installed: installed(), installing: !!job, ready: !!(worker && worker.ready), gaveUp: restarts >= MAX_RESTARTS, error: lastError || null, npm: !!findNpm(), log };
76
+ }
77
+ function shutdown() {
78
+ for (const j of queue.splice(0)) j.reject(new Error('kokoro shut down'));
79
+ if (worker) { const w = worker; worker = null; try { w.p.kill(); } catch {} for (const r of w.pending.values()) r.reject(new Error('kokoro shut down')); w.pending.clear(); }
80
+ busy = false; if (job) { try { job.kill(); } catch {} job = null; }
81
+ }
82
+ module.exports = { DIR, voices, hasVoice, speedFor, status, install, warm, synth, shutdown };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@double_geste/agent-standup",
3
- "version": "1.22.1",
3
+ "version": "1.23.1",
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"
@@ -9,6 +9,8 @@
9
9
  "files": [
10
10
  "bin",
11
11
  "server.js",
12
+ "kokoro.js",
13
+ "kokoro-worker.js",
12
14
  "demo.js",
13
15
  "public",
14
16
  "media",
package/public/index.html CHANGED
@@ -242,6 +242,7 @@
242
242
  <button class="mi" data-t="music"><span data-fr="Musique d’ambiance (radio FIP)" data-en="Office music (FIP radio)">Musique d’ambiance (radio FIP)</span><i class="sw"></i></button>
243
243
  <div class="box" id="musicBox" style="display:none"><select id="station"></select><input id="musicVol" type="range" min="0" max="1" step="0.05" style="width:100%" aria-label="Music volume"></div>
244
244
  <div class="box" id="vstyleBox"><select id="vstyle"></select></div>
245
+ <div class="box" id="kokoroBox" style="display:none"><button class="btn" id="kokoroInstall" style="width:100%"></button><div class="hint" id="kokoroHint" style="padding:2px 0 6px"></div></div>
245
246
  <button class="mi" data-t="roster"><span data-fr="Liste des agents (à gauche)" data-en="Agents list (left)">Liste des agents (à gauche)</span><i class="sw"></i></button>
246
247
  <button class="mi" data-t="reactions"><span data-fr="Barre de réactions (👍 👏 ☕)" data-en="Reactions bar (👍 👏 ☕)">Barre de réactions (👍 👏 ☕)</span><i class="sw"></i></button>
247
248
  <button class="mi" data-t="banter"><span data-fr="Petites répliques et animations" data-en="Little lines & animations">Petites répliques et animations</span><i class="sw"></i></button>
@@ -282,6 +283,7 @@
282
283
  </footer>
283
284
  <div id="card"></div><div id="recap"><div class="box"></div></div>
284
285
 
286
+ <script src="/api/prefs.js"></script>
285
287
  <script>
286
288
  // =====================================================================
287
289
  // helpers, prefs, i18n
@@ -297,10 +299,12 @@ const EMBED = Q.get('embed') === '1' || window.parent !== window;
297
299
  if (Q.get('pin')) S.pin = Q.get('pin');
298
300
 
299
301
  let prefs = { lang: 'en', voice: true, roster: true, banter: true, reactions: false, office: false, officeVol: 0.8, vstyle: 'cartoon', notify: false, epi: false, vanish: true, music: false, station: 'fip', musicVol: 0.35, view: 'call' };
300
- try { Object.assign(prefs, JSON.parse(localStorage.getItem('cc.prefs') || '{}')); } catch {}
302
+ { let loc = {}; try { loc = JSON.parse(localStorage.getItem('cc.prefs') || '{}') || {}; } catch {} // the settings file (shared by every window) and this origin's copy: the more recent one wins
303
+ const sv = window.SAVED_PREFS && typeof window.SAVED_PREFS === 'object' ? window.SAVED_PREFS : {}; Object.assign(prefs, (sv._t || 0) > (loc._t || 0) ? sv : loc); }
301
304
  if (prefs.vstyle === 'babble') prefs.vstyle = 'animal', prefs.vstyleChosen = true;
302
305
  if (!prefs.vstyleChosen && (prefs.vstyle === 'animal' || prefs.vstyle === 'sims')) prefs.vstyle = 'cartoon'; // the gibberish styles are opt-in: understandable voices by default
303
- const savePrefs = () => { try { localStorage.setItem('cc.prefs', JSON.stringify(prefs)); } catch {} };
306
+ let prefsTimer = null;
307
+ const savePrefs = () => { prefs._t = Date.now(); try { localStorage.setItem('cc.prefs', JSON.stringify(prefs)); } catch {} clearTimeout(prefsTimer); prefsTimer = setTimeout(() => { fetch('/api/prefs', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(prefs) }).catch(() => {}); }, 400); };
304
308
  // Languages: French and English live in the code as pairs L(french, english); every other language is a catalogue public/locales/<code>.json mapping the English text to its translation.
305
309
  // A missing string falls back to English, so a half-translated language still works. See public/locales/README.md to add one.
306
310
  const LANGS = [['en', 'English', 'en'], ['fr', 'Français', 'fr'], ['zh-TW', '繁體中文', 'zh-Hant-TW'], ['zh-CN', '简体中文', 'zh-Hans-CN'], ['es', 'Español', 'es'], ['de', 'Deutsch', 'de'], ['pt-BR', 'Português (Brasil)', 'pt-BR'], ['ja', '日本語', 'ja']];
@@ -534,7 +538,7 @@ const AUD = {
534
538
  const src = c.createBufferSource(); src.buffer = buf; src.loop = true; const lp = c.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 500;
535
539
  const g = c.createGain(); g.gain.value = 0.3; src.connect(lp).connect(g).connect(this.sfx); src.start(); this.apply(); OFFICE.start();
536
540
  },
537
- apply() { if (!this.ctx) return; this.sfx.gain.value = prefs.office && !this.off() ? 1 : 0; this.voice.gain.value = prefs.voice && !this.off() ? 1 : 0; this.applyMusic(); RADIO.sync(); },
541
+ apply() { if (!this.ctx) return; this.sfx.gain.value = prefs.office && !this.off() ? 1 : 0; this.voice.gain.value = prefs.voice && !this.off() ? 1 : 0; this.applyMusic(); RADIO.sync(); },
538
542
  // Music fades down quickly when an agent starts talking and comes back slowly once the room is quiet.
539
543
  applyMusic() { if (!this.ctx) return; this.amb.gain.setTargetAtTime((this.ducked ? 0.35 : 1) * (prefs.officeVol ?? 0.8), this.ctx.currentTime, 0.25); const v = prefs.music && !this.off() ? (prefs.musicVol ?? 0.35) * (this.ducked ? 0.16 : 1) : 0; this.music.gain.setTargetAtTime(v, this.ctx.currentTime, this.ducked ? 0.12 : 0.7); },
540
544
  blip(f) { if (!this.ctx || this.off()) return; const c = this.ctx, t = c.currentTime, o = c.createOscillator(), g = c.createGain(); o.type = 'sine'; o.frequency.setValueAtTime(f, t); o.frequency.exponentialRampToValueAtTime(f * 1.5, t + 0.1); g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(0.12, t + 0.015); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.22); o.connect(g).connect(c.destination); o.start(t); o.stop(t + 0.25); },
@@ -652,17 +656,23 @@ function rankVoices(list) { // list of names → best distinct voices first
652
656
  return out.slice(0, 5);
653
657
  }
654
658
  const TTS = {
655
- server: false, platform: '', pool: { fr: [], en: [] }, cache: new Map(),
659
+ server: false, engine: 'system', platform: '', pool: { fr: [], en: [] }, cache: new Map(),
656
660
  async init() {
657
- try { const d = await (await fetch('/api/voices')).json(); this.platform = d.platform || ''; if (!d.available) return; this.server = true;
661
+ this.engine = 'system'; this.server = false; this.pool = { fr: [], en: [] }; this.cache.clear();
662
+ try {
663
+ if (prefs.vstyle === 'kokoro') { // natural voices installed on demand; until they are, the system voices stay in use
664
+ const k = await (await fetch('/api/voices?engine=kokoro')).json();
665
+ if (k.available && k.ready) { this.server = true; this.engine = 'kokoro'; this.platform = k.platform || ''; for (const l of ['fr', 'en']) this.pool[l] = k.voices.filter((v) => v.locale.startsWith(l + '_')).map((v) => v.name); return; }
666
+ }
667
+ const d = await (await fetch('/api/voices')).json(); this.platform = d.platform || ''; if (!d.available) return; this.server = true;
658
668
  for (const l of ['fr', 'en']) this.pool[l] = rankVoices(d.voices.filter((v) => v.locale.startsWith(l + '_')).map((v) => v.name)); } catch {}
659
669
  },
660
670
  voiceFor(p, fr = FR()) { const pool = this.pool[fr ? 'fr' : 'en']; return pool.length ? (p.id === 'main' ? pool[0] : pool[(p.vi >>> 2) % pool.length]) : null; },
661
671
  rate() { return Math.round(Math.max(120, Math.min(330, 178 * (S.live ? 1.12 : +$('#speed').value))) / 10) * 10; },
662
672
  buf(text, voice, rate) {
663
- const key = voice + '|' + rate + '|' + text;
673
+ const key = this.engine + '|' + voice + '|' + rate + '|' + text;
664
674
  if (!this.cache.has(key)) {
665
- this.cache.set(key, fetch(`/api/tts?voice=${encodeURIComponent(voice)}&rate=${rate}&text=${encodeURIComponent(text)}`).then((r) => { if (!r.ok) throw new Error('tts'); return r.arrayBuffer(); }).then((b) => AUD.ctx.decodeAudioData(b)));
675
+ this.cache.set(key, fetch(`/api/tts?engine=${this.engine}&voice=${encodeURIComponent(voice)}&rate=${rate}&text=${encodeURIComponent(text)}`).then((r) => { if (!r.ok) throw new Error('tts'); return r.arrayBuffer(); }).then((b) => AUD.ctx.decodeAudioData(b)));
666
676
  if (this.cache.size > 60) this.cache.delete(this.cache.keys().next().value);
667
677
  this.cache.get(key).catch(() => this.cache.delete(key));
668
678
  }
@@ -745,12 +755,12 @@ async function speakServer(text, p, gain, token, fr = FR()) {
745
755
  const buf = await TTS.buf(speakable(text), voice, TTS.rate()); if (token !== S.token) return;
746
756
  const c = AUD.ctx, src = c.createBufferSource(); src.buffer = buf;
747
757
  const an = c.createAnalyser(); an.fftSize = 256; const g = c.createGain(); g.gain.value = gain; let stops = [];
748
- if (prefs.vstyle === 'natural') src.connect(an); else { const ch = fxChain(c, src, fxFor(p)); stops = ch.stops; ch.node.connect(an); }
758
+ if (prefs.vstyle === 'natural' || prefs.vstyle === 'kokoro') src.connect(an); else { const ch = fxChain(c, src, fxFor(p)); stops = ch.stops; ch.node.connect(an); }
749
759
  an.connect(g); g.connect(AUD.voice);
750
760
  const data = new Uint8Array(an.fftSize);
751
761
  S.level[p.id] = () => { an.getByteTimeDomainData(data); let s = 0; for (const v of data) { const x = (v - 128) / 128; s += x * x; } return Math.sqrt(s / data.length); }; // drives the mouth
752
762
  AUD.live.add(src);
753
- await new Promise((res) => { src.onended = res; setTimeout(res, buf.duration * 1000 + 800); src.start(); });
763
+ captionRun(p.id, text, buf.duration * 1000); await new Promise((res) => { src.onended = res; setTimeout(res, buf.duration * 1000 + 800); src.start(); });
754
764
  AUD.live.delete(src); delete S.level[p.id]; stops.forEach((o) => { try { o.stop(); } catch {} });
755
765
  }
756
766
  function speakBrowser(text, p, token, fr = FR()) { // fallback where no server voice exists (Windows/Linux)
@@ -770,7 +780,32 @@ function speakBrowser(text, p, token, fr = FR()) { // fallback where no server
770
780
  next();
771
781
  });
772
782
  }
783
+ // Subtitles: a long line is shown one piece (sentence, or clause of a long sentence) at a time, the piece being said. The split is by characters, spread over the audio's real length when known.
784
+ function pieces(text) {
785
+ const out = [];
786
+ for (const sen of String(text).split(/(?<=[.!?…]["')\]]*)\s+/)) {
787
+ const t = sen.trim(); if (!t) continue;
788
+ if (t.length <= 110) { out.push(t); continue; }
789
+ let cur = '';
790
+ for (const part of t.split(/(?<=[,;:—])\s+/)) { if (cur && cur.length + part.length > 90) { out.push(cur); cur = part; } else cur = cur ? cur + ' ' + part : part; }
791
+ if (cur) out.push(cur);
792
+ }
793
+ return out;
794
+ }
795
+ const capTimers = new Map(); // agent id → timers that advance its subtitle
796
+ function captionClear(id) { (capTimers.get(id) || []).forEach(clearTimeout); capTimers.delete(id); }
797
+ function setCaption(id, text) {
798
+ const t = $('#t-' + id), b = t && t.querySelector('.bubble'), say = S.say[id]; if (!b || !say) return;
799
+ b.innerHTML = (say.label ? `<small>${esc(say.label)}</small>` : '') + esc(text); if (S.present === id) $('#pcap').textContent = text;
800
+ }
801
+ function captionRun(id, text, totalMs) {
802
+ captionClear(id); const ps = pieces(text); if (ps.length < 2 || !(totalMs > 0)) return;
803
+ const n = ps.reduce((a, x) => a + x.length, 0), timers = []; let at = 0; setCaption(id, ps[0]); at += totalMs * ps[0].length / n;
804
+ for (let i = 1; i < ps.length; i++) { const x = ps[i], when = at; timers.push(setTimeout(() => setCaption(id, x), when)); at += totalMs * x.length / n; }
805
+ capTimers.set(id, timers);
806
+ }
773
807
  async function speakLine(text, p, primary, token, fr = FR()) {
808
+ captionRun(p.id, text, estMs(text));
774
809
  if (AUD.off() || !prefs.voice) { if (!AUD.off() && prefs.office) AUD.murmur(0.5 + (p.vi % 5) / 10, estMs(text) / 1000); return sleep(estMs(text)); }
775
810
  if (S.hurry) return sleep(estMs(text));
776
811
  if ((prefs.vstyle === 'animal' || prefs.vstyle === 'sims') && AUD.ctx) { try { await speakBabble(text, p, primary ? 1 : 0.5, token); return; } catch {} }
@@ -1143,8 +1178,8 @@ function startTalk(id, text, label) {
1143
1178
  const t = $('#t-' + id); if (!t) return;
1144
1179
  if (S.present && S.present !== id) hidePresent();
1145
1180
  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);
1146
- const b = t.querySelector('.bubble'); b.innerHTML = (label ? `<small>${esc(label)}</small>` : '') + esc(text);
1147
- if (S.present === id) { $('#pcap').textContent = text; $('#pcam').classList.add('speaking'); }
1181
+ const shown0 = pieces(text), first = shown0.length > 1 ? shown0[0] : text; const b = t.querySelector('.bubble'); b.innerHTML = (label ? `<small>${esc(label)}</small>` : '') + esc(first);
1182
+ if (S.present === id) { $('#pcap').textContent = first; $('#pcam').classList.add('speaking'); }
1148
1183
  const m = t.querySelector('.mouthE'); let open = false; clearInterval(mouths.get(id));
1149
1184
  mouths.set(id, setInterval(() => {
1150
1185
  if (!m) { const ct = t.querySelector('.cimg-talk'); if (ct) { const lv0 = S.level[id], op = lv0 ? lv0() > 0.05 : (open = !open); ct.style.display = op ? '' : 'none'; t.querySelector('.cimg').style.display = op ? 'none' : ''; if (S.present === id) { const pc = $('#pcam .cimg-talk'), pi = $('#pcam .cimg'); if (pc && pi) { pc.style.display = op ? '' : 'none'; pi.style.display = op ? 'none' : ''; } } } return; } // custom character: swap between its two frames
@@ -1165,6 +1200,7 @@ function confetti() {
1165
1200
  for (let i = 0; i < 26; i++) { const c = document.createElement('i'); c.className = 'confetti'; c.style.left = Math.random() * 100 + '%'; c.style.background = cols[i % cols.length]; c.style.animationDelay = Math.random() * 0.6 + 's'; c.style.animationDuration = 1.8 + Math.random() * 1.4 + 's'; box.appendChild(c); setTimeout(() => c.remove(), 4200); }
1166
1201
  }
1167
1202
  function stopTalk(id) {
1203
+ captionClear(id);
1168
1204
  clearInterval(mouths.get(id)); mouths.delete(id); S.speakers.delete(id); if (!S.speakers.size) AUD.duck(false); if (S.focus === id && S.speakers.size) setFocus([...S.speakers][0]);
1169
1205
  if (S.present === id) { $('#pcap').textContent = ''; $('#pcam').classList.remove('speaking'); const pm = $('#pcam .mouthE'); if (pm) { pm.setAttribute('ry', .9); pm.setAttribute('rx', 4.6); } }
1170
1206
  const t = $('#t-' + id); if (!t) return;
@@ -1342,6 +1378,7 @@ async function react(ev, token) {
1342
1378
  if (!S.agents[who]) return; startTalk(who, line, ev.kind === 'order' && prefs.banter ? '💬' : '🎬'); await speakLine(line, S.agents[who], true, token); stopTalk(who);
1343
1379
  }
1344
1380
  async function card(text, sub, ms = 2500) {
1381
+
1345
1382
  const c = $('#card'); c.innerHTML = `<div><h1>${esc(text)}</h1><p>${esc(sub || '')}</p></div>`; c.classList.add('on'); AUD.tone(392, .5, 'triangle', .12); AUD.tone(523, .6, 'triangle', .12, .25);
1346
1383
  await sleep(ms / Math.min(2, spd())); c.classList.remove('on');
1347
1384
  }
@@ -1386,6 +1423,7 @@ async function run(i) {
1386
1423
  }
1387
1424
  }
1388
1425
  if (FR() && !S.hurry) { prefetchTr(i); await Promise.race([ensureTr(group.map((g) => baseText(g))), sleep(S.live ? 7000 : 14000)]); if (token !== S.token) return; }
1426
+ if (group.length >= 3)
1389
1427
  prefetchQuips(i); warmTTS(i);
1390
1428
  const playOne = (g) => {
1391
1429
  applyStatus(g);
@@ -1450,7 +1488,7 @@ async function fillProjects() { // live view: follow the latest activity anywh
1450
1488
  }
1451
1489
  $('#liveProj').onchange = () => { Q.delete('proj'); prefs.proj = $('#liveProj').value; S.pin = ''; savePrefs(); S.cur = null; followCurrent(); };
1452
1490
  async function followCurrent() {
1453
- const cur = await (await fetch('/api/current?proj=' + encodeURIComponent(Q.get('proj') || prefs.proj || '') + (S.pin ? '&id=' + encodeURIComponent(S.pin) : ''))).json();
1491
+ const cur = await (await fetch('/api/current?proj=' + encodeURIComponent(Q.get('proj') || prefs.proj || '') + (S.pin ? '&id=' + encodeURIComponent(S.pin) : '') + (S.cur && !S.pin ? '&sproj=' + encodeURIComponent(S.cur.proj) + '&sid=' + encodeURIComponent(S.cur.id) : ''))).json();
1454
1492
  if (!cur.id) { $('#empty').classList.add('on'); return false; } $('#empty').classList.remove('on');
1455
1493
  if (S.pin && cur.id !== S.pin) S.pin = ''; // the pinned session is gone: follow the latest one instead of staying silently pinned
1456
1494
  if (S.cur && S.cur.id === cur.id && S.cur.proj === cur.proj) return false;
@@ -1638,7 +1676,23 @@ $('#menu').onclick = (e) => { const b = e.target.closest('.mi[data-t]'); if (!b)
1638
1676
  $('#station').innerHTML = STATIONS.map((s) => `<option value="${s.id}">📻 ${s.name}</option>`).join('');
1639
1677
  $('#station').value = prefs.station; $('#musicVol').value = prefs.musicVol; $('#musicBox').style.display = prefs.music ? '' : 'none';
1640
1678
  $('#station').onchange = (e) => { prefs.station = e.target.value; savePrefs(); AUD.init(); RADIO.sync(); };
1641
- $('#vstyle').innerHTML = [['animal', 'Animal Crossing (babillage)', 'Animal Crossing (babble)'], ['sims', 'Simlish (babillage)', 'Simlish (babble)'], ['cartoon', 'Voix cartoon (voix réelle)', 'Cartoon voices (real voice)'], ['natural', 'Voix naturelles', 'Natural voices']].map(([v, fr, en]) => `<option value="${v}" data-fr="${fr}" data-en="${en}">${L(fr, en)}</option>`).join(''); $('#vstyle').value = prefs.vstyle; $('#vstyle').onchange = () => { prefs.vstyle = $('#vstyle').value; prefs.vstyleChosen = true; savePrefs(); };
1679
+ $('#vstyle').innerHTML = [['animal', 'Animal Crossing (babillage)', 'Animal Crossing (babble)'], ['sims', 'Simlish (babillage)', 'Simlish (babble)'], ['cartoon', 'Voix cartoon (voix réelle)', 'Cartoon voices (real voice)'], ['natural', 'Voix naturelles', 'Natural voices'], ['kokoro', 'Voix très naturelles (à installer)', 'Very natural voices (installs on demand)']].map(([v, fr, en]) => `<option value="${v}" data-fr="${fr}" data-en="${en}">${L(fr, en)}</option>`).join(''); $('#vstyle').value = prefs.vstyle; $('#vstyle').onchange = () => { prefs.vstyle = $('#vstyle').value; prefs.vstyleChosen = true; savePrefs(); TTS.init().then(kokoroUI); kokoroUI(); };
1680
+ // ---- ⚙ → very natural voices (Kokoro): install box and status, polled while installing
1681
+ let kokoroTimer = null;
1682
+ async function kokoroUI() {
1683
+ const box = $('#kokoroBox'), on = prefs.vstyle === 'kokoro'; box.style.display = on ? '' : 'none'; if (!on) { clearInterval(kokoroTimer); kokoroTimer = null; return; }
1684
+ let st; try { st = await (await fetch('/api/kokoro')).json(); } catch { return; }
1685
+ const b = $('#kokoroInstall'), h = $('#kokoroHint'); b.style.display = st.installed ? 'none' : '';
1686
+ b.disabled = st.installing || !st.npm; b.textContent = st.installing ? L('Installation…', 'Installing…') : L(`Installer (${st.sizeMb} Mo)`, `Install (${st.sizeMb} MB)`);
1687
+ h.textContent = st.installing ? L('Téléchargement en cours, vous pouvez continuer : les voix du système servent en attendant.', 'Downloading, you can carry on: system voices are used meanwhile.')
1688
+ : st.error ? st.error : !st.npm && !st.installed ? L('npm est introuvable : installez Node.js pour activer cette option.', 'npm not found: install Node.js to enable this option.')
1689
+ : st.installed ? (st.gaveUp ? L('Le moteur s’est arrêté : les voix du système servent à la place.', 'The engine stopped: system voices are used instead.') : st.ready ? L('Voix prêtes.', 'Voices ready.') : L('Chargement du modèle (téléchargé au premier usage, environ 305 Mo)…', 'Loading the model (downloaded on first use, about 305 MB)…'))
1690
+ : L('Voix locales de bonne qualité, en anglais surtout (une seule voix française). Gratuit, rien n’est envoyé nulle part.', 'Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.');
1691
+ if ((st.installing || (st.installed && !st.ready && !st.gaveUp)) && !kokoroTimer) kokoroTimer = setInterval(async () => { await kokoroUI(); }, 2500);
1692
+ if (!st.installing && (st.ready || st.gaveUp || !st.installed) && kokoroTimer) { clearInterval(kokoroTimer); kokoroTimer = null; if (st.ready && TTS.engine !== 'kokoro') TTS.init(); }
1693
+ }
1694
+ $('#kokoroInstall').onclick = async () => { try { await fetch('/api/kokoro/install', { method: 'POST' }); } catch {} kokoroUI(); };
1695
+ kokoroUI();
1642
1696
  $('#officeVol').value = prefs.officeVol; $('#officeBox').style.display = prefs.office ? '' : 'none';
1643
1697
  $('#officeVol').oninput = (e) => { prefs.officeVol = +e.target.value; savePrefs(); AUD.applyMusic(); };
1644
1698
  $('#musicVol').oninput = (e) => { prefs.musicVol = +e.target.value; savePrefs(); AUD.applyMusic(); };
@@ -1672,7 +1726,7 @@ if (EMBED) $('#mBrowser').style.display = '';
1672
1726
  if (!prefs.langChosen && FR() && h.claude === null && !h.apiKey) { prefs.lang = 'en'; applyLang(); }
1673
1727
  if (FR()) fetch('/api/warm').catch(() => {});
1674
1728
  });
1675
- if (Q.get('lang')) { await setLang(Q.get('lang'), false); } // not saved: lets a link force a language
1729
+ if (Q.get('lang')) { await setLang(Q.get('lang'), false); }
1676
1730
  if (Q.get('demo')) {
1677
1731
  if (Q.get('view') === 'tree') setView('tree');
1678
1732
  await setMode('demo'); if (Q.get('speed')) $('#speed').value = Q.get('speed');
@@ -165,5 +165,13 @@
165
165
  "Open folder": "Ordner öffnen",
166
166
  "custom character(s) loaded": "eigene Figur(en) geladen",
167
167
  "Drop your pixel art (PNG) in this folder to replace the characters:": "Leg deine Pixel-Art (PNG) in diesen Ordner, um die Figuren zu ersetzen:",
168
- "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = Hauptagent · you.png = du · andere = Subagenten · name-talk.png = offener Mund"
168
+ "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = Hauptagent · you.png = du · andere = Subagenten · name-talk.png = offener Mund",
169
+ "Downloading, you can carry on: system voices are used meanwhile.": "Wird heruntergeladen, du kannst weitermachen: bis dahin kommen die Systemstimmen zum Einsatz.",
170
+ "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "Lokale Stimmen in guter Qualität, vor allem Englisch (nur eine französische Stimme). Kostenlos, nichts verlässt deinen Rechner.",
171
+ "Installing…": "Installation läuft…",
172
+ "Loading the model (downloaded on first use, about 305 MB)…": "Modell wird geladen (beim ersten Mal heruntergeladen, etwa 305 MB)…",
173
+ "npm not found: install Node.js to enable this option.": "npm nicht gefunden: Installiere Node.js, um diese Option zu nutzen.",
174
+ "The engine stopped: system voices are used instead.": "Die Engine wurde beendet: stattdessen kommen die Systemstimmen zum Einsatz.",
175
+ "Very natural voices (installs on demand)": "Sehr natürliche Stimmen (werden bei Bedarf installiert)",
176
+ "Voices ready.": "Stimmen bereit."
169
177
  }
@@ -41,6 +41,7 @@
41
41
  "done": "done",
42
42
  "Done": "Done",
43
43
  "Done. ": "Done. ",
44
+ "Downloading, you can carry on: system voices are used meanwhile.": "Downloading, you can carry on: system voices are used meanwhile.",
44
45
  "Drop your pixel art (PNG) in this folder to replace the characters:": "Drop your pixel art (PNG) in this folder to replace the characters:",
45
46
  "duration": "duration",
46
47
  "Editing ": "Editing ",
@@ -56,10 +57,12 @@
56
57
  "Files touched": "Files touched",
57
58
  "Final report": "Final report",
58
59
  "first error": "first error",
60
+ "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.",
59
61
  "Highlights": "Highlights",
60
62
  "Human": "Human",
61
63
  "idle": "idle",
62
64
  "input": "input",
65
+ "Installing…": "Installing…",
63
66
  "is sharing their screen": "is sharing their screen",
64
67
  "Join the call": "Join the call",
65
68
  "Language": "Language",
@@ -70,6 +73,7 @@
70
73
  "lines": "lines",
71
74
  "Listing ": "Listing ",
72
75
  "Little lines & animations": "Little lines & animations",
76
+ "Loading the model (downloaded on first use, about 305 MB)…": "Loading the model (downloaded on first use, about 305 MB)…",
73
77
  "longest wait ": "longest wait ",
74
78
  "Looking for a tool": "Looking for a tool",
75
79
  "looping?": "looping?",
@@ -84,6 +88,7 @@
84
88
  "No recent session": "No recent session",
85
89
  "No system voice found: agents will mumble.": "No system voice found: agents will mumble.",
86
90
  "Notify me when agents finish": "Notify me when agents finish",
91
+ "npm not found: install Node.js to enable this option.": "npm not found: install Node.js to enable this option.",
87
92
  "Office ambience (keys, coffee…)": "Office ambience (keys, coffee…)",
88
93
  "Office music (FIP radio)": "Office music (FIP radio)",
89
94
  "OFFLINE": "OFFLINE",
@@ -134,6 +139,7 @@
134
139
  "task": "task",
135
140
  "Tasks": "Tasks",
136
141
  "THE END": "THE END",
142
+ "The engine stopped: system voices are used instead.": "The engine stopped: system voices are used instead.",
137
143
  "This dev was replaced by his own agents. Spare a coffee for his career change? ☕": "This dev was replaced by his own agents. Spare a coffee for his career change? ☕",
138
144
  "Time": "Time",
139
145
  "to": "to",
@@ -149,6 +155,8 @@
149
155
  "Untitled session": "Untitled session",
150
156
  "Updating todo list": "Updating todo list",
151
157
  "Using skill ": "Using skill ",
158
+ "Very natural voices (installs on demand)": "Very natural voices (installs on demand)",
159
+ "Voices ready.": "Voices ready.",
152
160
  "VS Code was started from inside a Claude Code session: restart VS Code normally (Dock, Spotlight) to enable translation.": "VS Code was started from inside a Claude Code session: restart VS Code normally (Dock, Spotlight) to enable translation.",
153
161
  "waiting": "waiting",
154
162
  "WAITING": "WAITING",
@@ -165,5 +165,13 @@
165
165
  "Open folder": "Abrir carpeta",
166
166
  "custom character(s) loaded": "personaje(s) propio(s) cargado(s)",
167
167
  "Drop your pixel art (PNG) in this folder to replace the characters:": "Suelta tu pixel art (PNG) en esta carpeta para reemplazar los personajes:",
168
- "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = el agente principal · you.png = tú · el resto = los subagentes · name-talk.png = boca abierta"
168
+ "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = el agente principal · you.png = tú · el resto = los subagentes · name-talk.png = boca abierta",
169
+ "Downloading, you can carry on: system voices are used meanwhile.": "Descargando, puedes seguir: mientras tanto se usan las voces del sistema.",
170
+ "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "Voces locales de buena calidad, sobre todo en inglés (una sola voz en francés). Gratis, nada sale de tu equipo.",
171
+ "Installing…": "Instalando…",
172
+ "Loading the model (downloaded on first use, about 305 MB)…": "Cargando el modelo (se descarga la primera vez, unos 305 MB)…",
173
+ "npm not found: install Node.js to enable this option.": "No se encontró npm: instala Node.js para activar esta opción.",
174
+ "The engine stopped: system voices are used instead.": "El motor se detuvo: se usan las voces del sistema.",
175
+ "Very natural voices (installs on demand)": "Voces muy naturales (se instalan bajo demanda)",
176
+ "Voices ready.": "Voces listas."
169
177
  }
@@ -165,5 +165,13 @@
165
165
  "Open folder": "フォルダを開く",
166
166
  "custom character(s) loaded": "件のカスタムキャラクターを読み込みました",
167
167
  "Drop your pixel art (PNG) in this folder to replace the characters:": "このフォルダにドット絵(PNG)を入れると、キャラクターを差し替えられます:",
168
- "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = メインエージェント · you.png = あなた · それ以外 = サブエージェント · name-talk.png = 口を開けた状態"
168
+ "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = メインエージェント · you.png = あなた · それ以外 = サブエージェント · name-talk.png = 口を開けた状態",
169
+ "Downloading, you can carry on: system voices are used meanwhile.": "ダウンロード中です。そのまま作業を続けられます。それまではシステム音声を使います。",
170
+ "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "品質の良いローカル音声(主に英語、フランス語は1種類のみ)。無料で、データがPCの外に出ることはありません。",
171
+ "Installing…": "インストール中…",
172
+ "Loading the model (downloaded on first use, about 305 MB)…": "モデルを読み込み中(初回使用時にダウンロード、約305 MB)…",
173
+ "npm not found: install Node.js to enable this option.": "npm が見つかりません。このオプションを使うには Node.js をインストールしてください。",
174
+ "The engine stopped: system voices are used instead.": "エンジンが停止しました。代わりにシステム音声を使います。",
175
+ "Very natural voices (installs on demand)": "とても自然な音声(必要時にインストール)",
176
+ "Voices ready.": "音声の準備ができました。"
169
177
  }
@@ -1,5 +1,9 @@
1
1
  {
2
- "_meta": {"language": "pt-BR", "reviewed": false, "note": "AI draft, native review welcome"},
2
+ "_meta": {
3
+ "language": "pt-BR",
4
+ "reviewed": false,
5
+ "note": "AI draft, native review welcome"
6
+ },
3
7
  "Characters": "Personagens",
4
8
  "Reload": "Recarregar",
5
9
  "Open folder": "Abrir pasta",
@@ -161,5 +165,13 @@
161
165
  "Writing ": "Escrevendo ",
162
166
  "You": "Você",
163
167
  "Your agents are done 🎉": "Seus agentes terminaram 🎉",
164
- "your turn": "sua vez"
168
+ "your turn": "sua vez",
169
+ "Downloading, you can carry on: system voices are used meanwhile.": "Baixando, pode continuar: enquanto isso, as vozes do sistema são usadas.",
170
+ "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "Vozes locais de boa qualidade, principalmente em inglês (só uma voz em francês). Grátis, nada sai do seu computador.",
171
+ "Installing…": "Instalando…",
172
+ "Loading the model (downloaded on first use, about 305 MB)…": "Carregando o modelo (baixado no primeiro uso, cerca de 305 MB)…",
173
+ "npm not found: install Node.js to enable this option.": "npm não encontrado: instale o Node.js para ativar esta opção.",
174
+ "The engine stopped: system voices are used instead.": "O mecanismo parou: as vozes do sistema são usadas no lugar.",
175
+ "Very natural voices (installs on demand)": "Vozes muito naturais (instaladas sob demanda)",
176
+ "Voices ready.": "Vozes prontas."
165
177
  }
@@ -165,5 +165,13 @@
165
165
  "Open folder": "打开文件夹",
166
166
  "custom character(s) loaded": "个自定义角色已加载",
167
167
  "Drop your pixel art (PNG) in this folder to replace the characters:": "把你的像素画(PNG)放进此文件夹即可替换角色:",
168
- "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = 主代理 · you.png = 你 · 其他 = 子代理 · name-talk.png = 张嘴"
168
+ "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = 主代理 · you.png = 你 · 其他 = 子代理 · name-talk.png = 张嘴",
169
+ "Downloading, you can carry on: system voices are used meanwhile.": "正在下载,你可以继续使用:期间会使用系统语音。",
170
+ "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "高质量的本地语音,以英语为主(法语只有一种声音)。免费,数据不会离开你的电脑。",
171
+ "Installing…": "正在安装…",
172
+ "Loading the model (downloaded on first use, about 305 MB)…": "正在加载模型(首次使用时下载,约 305 MB)…",
173
+ "npm not found: install Node.js to enable this option.": "未找到 npm:请安装 Node.js 以启用此选项。",
174
+ "The engine stopped: system voices are used instead.": "引擎已停止:改用系统语音。",
175
+ "Very natural voices (installs on demand)": "超自然语音(按需安装)",
176
+ "Voices ready.": "语音已就绪。"
169
177
  }
@@ -18,8 +18,8 @@
18
18
  "Agents list (left)": "代理清單(左側)",
19
19
  "All projects (most recent)": "所有專案(最近優先)",
20
20
  "Ambience volume": "環境音量",
21
- "An agent may be waiting for you ✋": "有代理可能在等你 ✋",
22
- "Animal Crossing (babble)": "Animal Crossing(咿呀語)",
21
+ "An agent may be waiting for you ✋": "可能有代理在等你 ✋",
22
+ "Animal Crossing (babble)": "動物森友會說話聲",
23
23
  "Asking a question": "正在提問",
24
24
  "brief": "簡述",
25
25
  "Busiest agents": "最忙碌的代理",
@@ -79,7 +79,7 @@
79
79
  "Little lines & animations": "小對白與動畫",
80
80
  "longest wait ": "最長等待 ",
81
81
  "Looking for a tool": "正在找工具",
82
- "looping?": "原地打轉?",
82
+ "looping?": "鬼打牆?",
83
83
  "Music volume": "音樂音量",
84
84
  "Natural voices": "自然語音",
85
85
  "need you": "需要你",
@@ -101,7 +101,7 @@
101
101
  "Project to follow": "要追蹤的專案",
102
102
  "Prompt from": "提示來自",
103
103
  "Radio unreachable: check your connection or pick another station.": "無法連上電台:請檢查網路連線或換一個電台。",
104
- "Reactions": "回應",
104
+ "Reactions": "表情回應",
105
105
  "Reactions & jokes": "回應與玩笑",
106
106
  "Reactions bar (👍 👏 ☕)": "回應列(👍 👏 ☕)",
107
107
  "read-only": "唯讀",
@@ -124,7 +124,7 @@
124
124
  "session": "工作階段",
125
125
  "Sessions": "工作階段",
126
126
  "Settings": "設定",
127
- "Simlish (babble)": "Simlish(咿呀語)",
127
+ "Simlish (babble)": "模擬市民說話聲",
128
128
  "Sound": "聲音",
129
129
  "Sound (M key)": "聲音(M 鍵)",
130
130
  "Sound is playing in another Agent Standup window. Click 🔈 to take it over here.": "聲音正在另一個 Agent Standup 視窗播放。點擊 🔈 即可改在此處播放。",
@@ -165,5 +165,13 @@
165
165
  "Writing ": "撰寫 ",
166
166
  "You": "你",
167
167
  "Your agents are done 🎉": "你的代理都完成了 🎉",
168
- "your turn": "換你了"
168
+ "your turn": "換你了",
169
+ "Downloading, you can carry on: system voices are used meanwhile.": "下載中,你可以繼續使用:這段時間會先用系統語音。",
170
+ "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "品質不錯的本機語音,以英文為主(法文只有一種聲音)。免費,資料不會離開你的電腦。",
171
+ "Installing…": "安裝中…",
172
+ "Loading the model (downloaded on first use, about 305 MB)…": "正在載入模型(第一次使用時下載,約 305 MB)…",
173
+ "npm not found: install Node.js to enable this option.": "找不到 npm:請安裝 Node.js 才能啟用此選項。",
174
+ "The engine stopped: system voices are used instead.": "引擎已停止:改用系統語音。",
175
+ "Very natural voices (installs on demand)": "超自然語音(需要時才安裝)",
176
+ "Voices ready.": "語音已就緒。"
169
177
  }
package/server.js CHANGED
@@ -6,6 +6,7 @@ const fs = require('fs');
6
6
  const path = require('path');
7
7
  const os = require('os');
8
8
  const crypto = require('crypto');
9
+ const kokoro = require('./kokoro.js'); // natural voices, installed on demand (see kokoro.js)
9
10
  const zlib = require('zlib');
10
11
  const { execFileSync } = require('child_process');
11
12
 
@@ -47,6 +48,8 @@ const toolLabel = (b, ts) => {
47
48
  const resultText = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.map((x) => x.text || '').join('\n') : '');
48
49
  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 };
49
50
  const NO_RESULT = new Set(['Agent', 'Task', 'SendMessage', 'TodoWrite']); // internal bookkeeping, nothing to show
51
+ const HARNESS = /<(system-reminder|browser_instruction|ide_selection|ide_opened_file|task-notification|command-name|command-message|command-args|local-command-stdout|local-command-caveat)>[\s\S]*?<\/\1>\s*/g; // blocks the harness wraps around what the human typed
52
+
50
53
 
51
54
  let sessionsMemo = { at: 0, rows: [] };
52
55
  function listSessions() {
@@ -155,14 +158,24 @@ function boardRows0() {
155
158
  return { proj, id, title: p.title || id, mtime: st.mtimeMs, state, tool, ageMs, subActive, subTotal };
156
159
  });
157
160
  }
161
+ // Settings live in a file, not only in the page's localStorage: the port changes between VS Code windows and reloads, and localStorage is per origin (port included).
162
+ const PREFS = process.env.AGENT_STANDUP_PREFS || (process.platform === 'win32' ? path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'agent-standup', 'prefs.json') : path.join(os.homedir(), '.config', 'agent-standup', 'prefs.json'));
163
+ function readPrefs() { try { const v = JSON.parse(fs.readFileSync(PREFS, 'utf8')); return v && typeof v === 'object' && !Array.isArray(v) ? v : null; } catch { return null; } }
164
+ function writePrefs(v) { fs.mkdirSync(path.dirname(PREFS), { recursive: true }); const tmp = PREFS + '.' + process.pid + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(v, null, 1)); fs.renameSync(tmp, PREFS); }
158
165
  // Most recently active session (optionally limited to one project dir) — drives the live feed.
159
- function currentSession(projFilter, idFilter) {
166
+ 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
167
+ function sessionMtime(proj, id) {
168
+ try { let m = fs.statSync(path.join(ROOT, proj, id + '.jsonl')).mtimeMs; const sd = path.join(ROOT, proj, id, 'subagents'); if (fs.existsSync(sd)) for (const x of fs.readdirSync(sd)) m = Math.max(m, fs.statSync(path.join(sd, x)).mtimeMs); return m; } catch { return 0; }
169
+ }
170
+ function currentSession(projFilter, idFilter, stick) {
171
+ const safe = (x) => typeof x === 'string' && /^[\w.\-]+$/.test(x) && x !== '..';
172
+ if (stick && safe(stick.id) && safe(stick.proj) && (!projFilter || stick.proj === projFilter || stick.proj.startsWith(projFilter + '-')) && Date.now() - sessionMtime(stick.proj, stick.id) < STICK_MS) return { proj: stick.proj, id: stick.id, mtime: sessionMtime(stick.proj, stick.id) };
160
173
  if (idFilter && projFilter && projFilter !== '..' && idFilter !== '..' && /^[\w.\-]+$/.test(idFilter) && /^[\w.\-]+$/.test(projFilter) && fs.existsSync(path.join(ROOT, projFilter, idFilter + '.jsonl'))) return { proj: projFilter, id: idFilter, mtime: Date.now() }; // pinned from the board
161
174
  let best = null;
162
175
  if (!fs.existsSync(ROOT)) return {};
163
176
  const consider = (proj, id, m) => { if (!best || m > best.mtime) best = { proj, id, mtime: m }; };
164
177
  for (const proj of fs.readdirSync(ROOT)) {
165
- if (projFilter && proj !== projFilter) continue;
178
+ if (projFilter && proj !== projFilter && !proj.startsWith(projFilter + '-')) continue; // the folder itself, or a folder inside it
166
179
  const dir = path.join(ROOT, proj); let files;
167
180
  try { files = fs.readdirSync(dir); } catch { continue; }
168
181
  for (const f of files) {
@@ -173,7 +186,7 @@ function currentSession(projFilter, idFilter) {
173
186
  consider(proj, id, m);
174
187
  }
175
188
  }
176
- if (!best && projFilter) return currentSession('');
189
+ if (!best && projFilter) return currentSession('', '', stick);
177
190
  return best || {};
178
191
  }
179
192
 
@@ -217,7 +230,7 @@ function parseRecord(st, r) {
217
230
  }
218
231
  if (items.length) st.raw.push({ ts, agent, kind: 'results', items });
219
232
  if (agent !== 'main') return; // a sub-agent's first user message is the order, emitted from the parent's side
220
- const txt = blocks(r).filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim();
233
+ const txt = blocks(r).filter((b) => b.type === 'text').map((b) => b.text).join('\n').replace(HARNESS, '').replace(/^\s*\[Request interrupted[^\]]*\]\s*/i, '').trim(); // what the human typed, without the blocks the harness wraps around it
221
234
  if (txt && !/^<(system-reminder|command-|local-command|task-notification)/.test(txt) && !r.isMeta) st.raw.push({ ts, agent: 'user', kind: 'user', text: txt });
222
235
  } else if (r.type === 'assistant') {
223
236
  const m = r.message || {};
@@ -387,7 +400,7 @@ class TrWorker {
387
400
  }
388
401
  }
389
402
  const trWorkers = [new TrWorker(), new TrWorker()];
390
- const shutdown = () => trWorkers.forEach((w) => w.kill());
403
+ const shutdown = () => { trWorkers.forEach((w) => w.kill()); kokoro.shutdown(); };
391
404
  process.on('exit', shutdown);
392
405
  const pickWorker = () => trWorkers.reduce((a, b) => (a.q.length + a.busy <= b.q.length + b.busy ? a : b));
393
406
  async function translateFast(texts) {
@@ -438,16 +451,18 @@ function synthesize(voice, wpm, text, out) {
438
451
  p.on('error', reject); p.on('close', (code) => (code === 0 && fs.existsSync(out) ? resolve() : reject(new Error('tts failed'))));
439
452
  });
440
453
  }
441
- function tts(text, voice, rate) {
442
- const vs = listVoices();
443
- if (!vs.available || !vs.voices.some((v) => v.name === voice)) return Promise.reject(new Error('unknown voice'));
444
- text = String(text).slice(0, 700); rate = Math.max(90, Math.min(360, Math.round(+rate || 180)));
445
- const key = crypto.createHash('sha1').update([voice, rate, text].join('|')).digest('hex'), file = path.join(ttsDir, key + '.wav');
454
+ function tts(text, voice, rate, engine) {
455
+ const kk = engine === 'kokoro';
456
+ if (kk) { if (!kokoro.hasVoice(voice)) return Promise.reject(new Error('unknown voice')); }
457
+ else { const vs = listVoices(); if (!vs.available || !vs.voices.some((v) => v.name === voice)) return Promise.reject(new Error('unknown voice')); }
458
+ text = String(text).slice(0, kk ? 1500 : 700); rate = Math.max(90, Math.min(360, Math.round(+rate || 180)));
459
+ const key = crypto.createHash('sha1').update([kk ? 'kokoro' : 'system', voice, rate, text].join('|')).digest('hex'), file = path.join(ttsDir, key + '.wav');
446
460
  if (fs.existsSync(file)) return Promise.resolve(file);
447
461
  if (ttsJobs.has(key)) return ttsJobs.get(key);
448
462
  fs.mkdirSync(ttsDir, { recursive: true });
449
463
  const tmp = file + '.part.wav';
450
- const job = synthesize(voice, rate, text, tmp).then(() => { fs.renameSync(tmp, file); return file; }).finally(() => ttsJobs.delete(key));
464
+ const job = (kk ? kokoro.synth(text, voice, kokoro.speedFor(rate), tmp).catch((e) => { const vs = listVoices(), v = vs.voices.find((x) => x.locale.startsWith((voice.startsWith('ff_') ? 'fr' : 'en') + '_')); if (!v) throw e; return synthesize(v.name, rate, text, tmp); }) : synthesize(voice, rate, text, tmp)) // a line Kokoro cannot make goes to a system voice instead of being dropped
465
+ .then(() => { fs.renameSync(tmp, file); return file; }).finally(() => ttsJobs.delete(key));
451
466
  ttsJobs.set(key, job); return job;
452
467
  }
453
468
 
@@ -477,7 +492,7 @@ function start(port = PORT) {
477
492
  const json = (o, code = 200) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
478
493
  // Endpoints with side effects (they spawn programs or claim the sound) refuse requests that a browser marks as coming from another site, e.g. an <img> on a web page.
479
494
  const sfs = req.headers['sec-fetch-site'];
480
- if (sfs && sfs !== 'same-origin' && sfs !== 'none' && ['/api/translate', '/api/quips', '/api/warm', '/api/audio', '/api/tts'].includes(url.pathname)) { res.writeHead(403); return res.end('forbidden'); }
495
+ if (sfs && sfs !== 'same-origin' && sfs !== 'none' && ['/api/translate', '/api/quips', '/api/warm', '/api/audio', '/api/tts', '/api/prefs', '/api/voices', '/api/kokoro', '/api/kokoro/install'].includes(url.pathname)) { res.writeHead(403); return res.end('forbidden'); }
481
496
  const readBody = (cb) => { // request bodies are small JSON: stop reading beyond 256 KB and give up after 10 s
482
497
  let body = '', over = false; req.setTimeout(10000, () => req.destroy());
483
498
  req.on('data', (d) => { if (over) return; body += d; if (body.length > 262144) { over = true; res.writeHead(413); res.end('too large'); req.destroy(); } });
@@ -501,13 +516,21 @@ function start(port = PORT) {
501
516
  }
502
517
  if (url.pathname === '/api/health') return json({ claude: findClaude(), apiKey: !!process.env.ANTHROPIC_API_KEY, trError: lastTrError, platform: process.platform });
503
518
  if (url.pathname === '/api/warm') { trWorkers.forEach((w) => { if (!w.p) w.start(); }); return json({ ok: true }); }
504
- if (url.pathname === '/api/voices') return json(listVoices());
519
+ if (url.pathname === '/api/voices') {
520
+ if (url.searchParams.get('engine') === 'kokoro') { const st = kokoro.status(); if (st.installed && !st.gaveUp) kokoro.warm(); return json({ available: st.installed && !st.gaveUp, ready: st.ready, voices: kokoro.voices(), platform: process.platform, engine: 'kokoro' }); }
521
+ return json(listVoices());
522
+ }
523
+ if (url.pathname === '/api/kokoro/install' && req.method === 'POST') return json(kokoro.install());
524
+ if (url.pathname === '/api/kokoro') return json(kokoro.status());
505
525
  if (url.pathname === '/api/tts') {
506
- return tts(url.searchParams.get('text') || '', url.searchParams.get('voice'), url.searchParams.get('rate'))
526
+ return tts(url.searchParams.get('text') || '', url.searchParams.get('voice'), url.searchParams.get('rate'), url.searchParams.get('engine'))
507
527
  .then((f) => { res.writeHead(200, { 'content-type': 'audio/wav', 'cache-control': 'max-age=86400' }); fs.createReadStream(f).pipe(res); })
508
528
  .catch((e) => json({ error: String(e.message) }, 400));
509
529
  }
510
- if (url.pathname === '/api/current') return json(currentSession(url.searchParams.get('proj') || '', url.searchParams.get('id') || ''));
530
+ 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') + ';'); }
531
+ if (url.pathname === '/api/prefs' && req.method === 'PUT') return readBody((body) => { try { const v = JSON.parse(body); if (!v || typeof v !== 'object' || Array.isArray(v)) throw new Error('prefs must be an object'); writePrefs(v); json({ ok: true }); } catch (e) { json({ error: String(e.message || e) }, 400); } });
532
+ if (url.pathname === '/api/prefs') return json(readPrefs() || {});
533
+ if (url.pathname === '/api/current') return json(currentSession(url.searchParams.get('proj') || '', url.searchParams.get('id') || '', { proj: url.searchParams.get('sproj') || '', id: url.searchParams.get('sid') || '' }));
511
534
  if (url.pathname === '/api/board') return json(boardRows());
512
535
  if (url.pathname === '/api/characters') return json(listCharacters());
513
536
  if (url.pathname === '/api/session') {