nio-cat 0.9.10 → 0.9.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.9.10",
3
+ "version": "0.9.13",
4
4
  "description": "nio: fes servir Claude Code, Codex, Grok, Antigravity i Ollama del teu ordinador des del mòbil.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "f.cat",
@@ -23,10 +23,9 @@ module.exports = {
23
23
  // Que l'agent posi un títol que resumeixi la feina (eina MCP de nio; el hub el desa).
24
24
  args.push('--append-system-prompt', 'Treballes dins una sessió de nio. Quan entenguis la tasca, crida l’eina mcp__nio__sessio_titol amb un títol de 3-7 paraules que la resumeixi, en l’idioma de l’usuari; torna-la a cridar només si la feina canvia de tema.');
25
25
 
26
- if (session.mode === 'tot' && process.getuid?.() === 0) {
27
- ctx.fail('Claude Code no deixa el mode «Via lliure» quan s’executa com a root. Canvia els permisos a «Demana permís» o engega nio amb un usuari normal.');
28
- return { kill() {} };
29
- }
26
+ // Com a root (un VPS), Claude Code no accepta bypassPermissions: «Via lliure» hi fa servir el mode auto,
27
+ // que també treballa sol però fa revisar cada acció a un classificador (el mateix que el Claude Code remot).
28
+ const root = process.getuid?.() === 0;
30
29
 
31
30
  const env = {};
32
31
  const gate = (extra = {}) => {
@@ -38,7 +37,7 @@ module.exports = {
38
37
  if (session.mode === 'llegir') {
39
38
  args.push('--permission-mode', 'plan');
40
39
  } else if (session.mode === 'tot') {
41
- args.push('--permission-mode', 'bypassPermissions');
40
+ args.push('--permission-mode', root ? 'auto' : 'bypassPermissions');
42
41
  // Amb el control del navegador o l'escriptori actiu, les ordres que hi actuen sempre es pregunten.
43
42
  if (control) gate({ NIO_GATE: 'tot' });
44
43
  } else {
@@ -70,11 +70,11 @@ async function pull(name, onProgress, signal) {
70
70
  }
71
71
 
72
72
  // Una resposta d'un sol cop (sense flux), per exemple per a la pissarra.
73
- async function chat({ model, messages, format, think, maxTokens, signal }) {
73
+ async function chat({ model, messages, format, think, maxTokens, numCtx, signal }) {
74
74
  const res = await fetch(`${base()}/api/chat`, {
75
75
  method: 'POST',
76
76
  headers: { 'content-type': 'application/json' },
77
- body: JSON.stringify({ model, messages, stream: false, ...(format ? { format } : {}), ...(think === false ? { think: false } : {}), options: { temperature: 0.2, ...(maxTokens ? { num_predict: maxTokens } : {}) } }),
77
+ body: JSON.stringify({ model, messages, stream: false, ...(format ? { format } : {}), ...(think === false ? { think: false } : {}), options: { temperature: 0.2, ...(maxTokens ? { num_predict: maxTokens } : {}), ...(numCtx ? { num_ctx: numCtx } : {}) } }),
78
78
  ...(signal ? { signal } : {}),
79
79
  });
80
80
  if (!res.ok) throw new Error(`Ollama ha tornat un error ${res.status}: ${await res.text()}`);
package/src/call.js CHANGED
@@ -65,7 +65,7 @@ class Call {
65
65
  if (!r.ok) {
66
66
  const d = j?.detail;
67
67
  const msg = (typeof d === 'string' ? d : d?.message || (Array.isArray(d) ? d.map((x) => x.msg).join('; ') : '') || j?.message || t || `Error ${r.status}`);
68
- throw Object.assign(httpErr(r.status === 401 ? 400 : 502, `ElevenLabs: ${clip(msg, 220)}`), { upstream: r.status });
68
+ throw Object.assign(httpErr(r.status === 401 ? 400 : 502, require('./voice').elPermHint(msg) || `ElevenLabs: ${clip(msg, 220)}`), { upstream: r.status });
69
69
  }
70
70
  return j;
71
71
  }
package/src/notes.js CHANGED
@@ -49,6 +49,16 @@ class Notes {
49
49
  try { this.notes = JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch { /* encara no n'hi ha */ }
50
50
  const old = Date.now() - TRASH_DAYS * 864e5;
51
51
  this.notes = this.notes.filter((n) => !n.trashed || n.trashed > old);
52
+ this.queues = new Map(); // cues per nota: «t:<id>» transcripció en ordre, «s:<id>» apunts i resum
53
+ this.parting = new Map();
54
+ // Si nio es va aturar enmig d'una classe: es reprèn el que quedava a mitges.
55
+ setImmediate(() => {
56
+ for (const n of this.notes) {
57
+ if (!n.live) continue;
58
+ for (const sg of n.live.segs) if (sg.state === 'transcribing') this.queue(`t:${n.id}`, () => this.liveTranscribe(n, sg));
59
+ if (n.live.state === 'closing' || n.live.state === 'summarizing') this.liveFinish(n);
60
+ }
61
+ });
52
62
  }
53
63
 
54
64
  save() {
@@ -85,6 +95,7 @@ class Notes {
85
95
  const n = this.get(id);
86
96
  if (!n.trashed && !forever) return this.patch(id, { trashed: true });
87
97
  for (const a of n.audio) fs.rm(this.audioFile(n, a), { force: true }, () => {});
98
+ for (const sg of n.live?.segs || []) fs.rm(this.liveFile(n, sg), { force: true }, () => {});
88
99
  this.notes = this.notes.filter((x) => x !== n);
89
100
  this.save();
90
101
  this.hub.emit('out', { type: 'note-gone', id });
@@ -136,6 +147,179 @@ class Notes {
136
147
  if (this.notes.includes(n)) { n.updated = Date.now(); this.save(); this.emit(n); }
137
148
  }
138
149
 
150
+ // ---------- Classe o reunió en directe
151
+ // El mòbil grava trossos de ~20 s i els envia en ordre. Cada tros es transcriu (en ordre, un darrere l'altre);
152
+ // cada ~4-5 minuts de veu, un model en fa apunts mentre la gravació continua; en acabar, el resum de tot.
153
+ queue(key, fn) {
154
+ const p = (this.queues.get(key) || Promise.resolve()).then(fn).catch(() => {});
155
+ this.queues.set(key, p);
156
+ return p;
157
+ }
158
+ liveFile(n, sg) { return path.join(AUDIO, `${n.id}-live-${sg.seq}.${extOf(sg.mime)}`); }
159
+
160
+ liveStart(b = {}) {
161
+ const kind = LIVE_KINDS[b.kind] ? b.kind : 'classe';
162
+ const when = new Date().toLocaleString('ca-ES', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
163
+ const title = `${LIVE_KINDS[kind]} · ${when}`;
164
+ const n = this.create({ title, labels: [kind === 'classe' ? 'classe' : 'reunió'] });
165
+ n.live = { kind, engine: str(b.engine, 80), lang: LIVE_LANGS.includes(b.lang) ? b.lang : 'ca', state: 'rec', started: Date.now(), ended: 0, segs: [], parts: [], error: '', autoTitle: title };
166
+ this.save(); this.emit(n);
167
+ return n;
168
+ }
169
+
170
+ liveResume(id) {
171
+ const n = this.get(id), L = n.live;
172
+ if (!L) throw httpErr(400, 'Aquesta nota no és d’una classe ni d’una reunió');
173
+ if (L.state === 'closing' || L.state === 'summarizing') throw httpErr(409, 'Ara mateix l’estic resumint. Espera que acabi.');
174
+ L.state = 'rec'; L.error = ''; n.updated = Date.now();
175
+ this.save(); this.emit(n);
176
+ return n;
177
+ }
178
+
179
+ liveSeg(id, buf, mime, q) {
180
+ const n = this.get(id), L = n.live;
181
+ if (!L) throw httpErr(400, 'Aquesta nota no és d’una classe ni d’una reunió');
182
+ const seq = Math.max(0, Math.floor(Number(q.seq) || 0));
183
+ if (L.segs.some((x) => x.seq === seq)) return { ok: true, seq, dup: true }; // el mòbil l'ha tornat a enviar
184
+ if (!buf?.length) throw httpErr(400, 'El tros d’àudio és buit');
185
+ if (L.segs.length >= 2000) throw httpErr(413, 'Aquesta gravació ja és massa llarga');
186
+ const sg = { seq, at: Math.max(0, Number(q.at) || 0), dur: Math.max(0, Number(q.dur) || 0), mime, state: 'transcribing', text: '', error: '' };
187
+ fs.mkdirSync(AUDIO, { recursive: true, mode: 0o700 });
188
+ fs.writeFileSync(this.liveFile(n, sg), buf);
189
+ L.segs.push(sg); L.segs.sort((a, b) => a.seq - b.seq);
190
+ n.updated = Date.now(); this.save(); this.emit(n);
191
+ this.queue(`t:${n.id}`, () => this.liveTranscribe(n, sg));
192
+ return { ok: true, seq };
193
+ }
194
+
195
+ async liveTranscribe(n, sg) {
196
+ if (!this.notes.includes(n)) return;
197
+ try {
198
+ // El final del tros anterior fa de context: Whisper hi enganxa millor les frases i hi escriu igual els noms.
199
+ const prev = n.live.segs.filter((x) => x.seq < sg.seq && x.text).pop()?.text || '';
200
+ const { text } = await voice.transcribe(this.cfg, fs.readFileSync(this.liveFile(n, sg)), sg.mime, n.live.lang, { prompt: prev.slice(-200) });
201
+ const t = unloop(String(text || '').replace(/\s+/g, ' ').trim());
202
+ sg.text = HALLUCINATION.test(t) || (prev && t === prev) ? '' : t; sg.state = 'ok'; sg.error = '';
203
+ } catch (e) {
204
+ sg.state = 'error'; sg.error = String(e.message || e).slice(0, 300);
205
+ }
206
+ if (!this.notes.includes(n)) return;
207
+ n.updated = Date.now(); this.save(); this.emit(n);
208
+ this.liveParts(n);
209
+ }
210
+
211
+ // Apunts dels trossos ja transcrits, en ordre. Amb «final», també el tros que queda encara que sigui curt.
212
+ liveParts(n, final = false) {
213
+ if (this.parting.has(n.id)) return final ? this.parting.get(n.id).then(() => this.liveParts(n, true)) : this.parting.get(n.id);
214
+ const run = (async () => {
215
+ for (;;) {
216
+ const L = n.live, last = L.parts.length ? L.parts[L.parts.length - 1].to : -1, ready = [];
217
+ for (const sg of L.segs) { if (sg.seq <= last) continue; if (sg.state === 'transcribing') break; ready.push(sg); }
218
+ const text = ready.map((x) => x.text).filter(Boolean).join(' ');
219
+ if (!ready.length || (!final && text.length < PART_CHARS) || (final && ready.some((x) => x.state === 'transcribing'))) return;
220
+ const pick = await this.pickEngine(L.engine);
221
+ if (!pick) return; // sense model: al final es deixa la transcripció
222
+ const part = { from: ready[0].seq, to: ready[ready.length - 1].seq, text: '', error: '' };
223
+ if (words(text) >= MIN_WORDS) {
224
+ try { part.text = cleanOut((await this.ask(pick, LIVE_TASKS.part[L.kind], text, false)).text); }
225
+ catch (e) { part.error = String(e.message || e).slice(0, 200); }
226
+ }
227
+ if (!this.notes.includes(n)) return;
228
+ L.parts.push(part); n.updated = Date.now(); this.save(); this.emit(n);
229
+ }
230
+ })().finally(() => this.parting.delete(n.id));
231
+ this.parting.set(n.id, run);
232
+ return run;
233
+ }
234
+
235
+ liveStop(id) {
236
+ const n = this.get(id), L = n.live;
237
+ if (!L) throw httpErr(400, 'Aquesta nota no és d’una classe ni d’una reunió');
238
+ if (L.state === 'rec' || L.state === 'error') { L.state = 'closing'; L.ended = Date.now(); n.updated = Date.now(); this.save(); this.emit(n); this.liveFinish(n); }
239
+ return n;
240
+ }
241
+
242
+ liveSummary(id, b = {}) {
243
+ const n = this.get(id), L = n.live;
244
+ if (!L) throw httpErr(400, 'Aquesta nota no és d’una classe ni d’una reunió');
245
+ if (L.state === 'rec') throw httpErr(409, 'Primer atura la gravació');
246
+ if (L.state === 'closing' || L.state === 'summarizing') return n;
247
+ if (b.engine) L.engine = str(b.engine, 80);
248
+ L.parts = L.parts.filter((p) => !p.error); // els apunts que havien fallat es tornen a fer
249
+ L.state = 'closing'; this.save(); this.emit(n);
250
+ this.liveFinish(n);
251
+ return n;
252
+ }
253
+
254
+ // Espera la transcripció i els apunts pendents i en fa el resum final.
255
+ liveFinish(n) {
256
+ return this.queue(`s:${n.id}`, async () => {
257
+ const L = n.live;
258
+ await (this.queues.get(`t:${n.id}`) || Promise.resolve());
259
+ if (!this.notes.includes(n)) return;
260
+ L.state = 'summarizing'; L.error = ''; this.emit(n);
261
+ try {
262
+ await this.liveParts(n, true);
263
+ const transcript = L.segs.map((x) => x.text).filter(Boolean).join(' ').trim();
264
+ if (!transcript) throw new Error('No s’ha transcrit res: no hi ha res a resumir.');
265
+ // Amb quatre paraules, el model no té res a resumir i acaba resumint les seves instruccions.
266
+ if (words(transcript) < MIN_WORDS) {
267
+ if (!n.text.trim()) n.text = transcript;
268
+ throw new Error('Hi ha massa poca transcripció per fer-ne un resum: l’he deixada a la nota. Comprova que el micròfon capti bé la veu.');
269
+ }
270
+ const pick = await this.pickEngine(L.engine);
271
+ if (!pick) {
272
+ if (!n.text.trim()) n.text = transcript;
273
+ throw new Error('No hi ha cap model per resumir. He deixat la transcripció a la nota. Instal·la Ollama amb un model (ollama pull qwen3:4b) o Claude Code.');
274
+ }
275
+ const max = pick.id.startsWith('ollama:') ? 9000 : 60000; // els models locals petits tenen poc context
276
+ const rangeText = (p) => L.segs.filter((x) => x.seq >= p.from && x.seq <= p.to).map((x) => x.text).filter(Boolean).join(' ');
277
+ let blocks = transcript.length <= max ? [transcript] : L.parts.map((p) => p.text || rangeText(p)).filter(Boolean);
278
+ // Massa llarg per a un sol cop: es condensen els apunts per grups fins que hi caben.
279
+ while (blocks.join('\n\n').length > max && blocks.length > 1) {
280
+ const groups = [];
281
+ for (const b of blocks) { const g = groups[groups.length - 1]; if (g && g.join('\n\n').length + b.length < max * 0.8) g.push(b); else groups.push([b]); }
282
+ if (groups.length === blocks.length) groups.splice(0, groups.length, ...Array.from({ length: Math.ceil(blocks.length / 2) }, (_, i) => blocks.slice(i * 2, i * 2 + 2)));
283
+ blocks = [];
284
+ for (const g of groups) blocks.push(cleanOut((await this.ask(pick, LIVE_TASKS.condense, g.join('\n\n'), false)).text) || g.join('\n\n').slice(0, max / 2));
285
+ }
286
+ let r = await this.ask(pick, LIVE_TASKS.final[L.kind], blocks.join('\n\n').slice(0, max), true);
287
+ if (echoes(r.text) || echoes(r.title)) r = await this.ask(pick, LIVE_TASKS.final[L.kind], blocks.join('\n\n').slice(0, max), true); // un segon intent
288
+ if (echoes(r.text) || echoes(r.title)) {
289
+ if (!n.text.trim()) n.text = transcript;
290
+ throw new Error('El model ha resumit les instruccions en lloc de la classe. He deixat la transcripció a la nota: torna-ho a provar amb un model més gran.');
291
+ }
292
+ const text = str(r.text, 200000).trim();
293
+ if (!text) {
294
+ if (!n.text.trim()) n.text = transcript;
295
+ throw new Error('El model diu que la transcripció no té prou contingut per resumir-la. L’he deixada a la nota.');
296
+ }
297
+ if (!this.notes.includes(n)) return;
298
+ const title = str(r.title, 120).trim();
299
+ if (title && n.title === L.autoTitle) { n.title = `${title}`; L.autoTitle = n.title; }
300
+ n.text = text; L.state = 'done'; L.summaryEngine = pick.id;
301
+ } catch (e) {
302
+ if (!this.notes.includes(n)) return;
303
+ L.state = 'error'; L.error = String(e.message || e).slice(0, 300);
304
+ }
305
+ n.updated = Date.now(); this.save(); this.emit(n);
306
+ });
307
+ }
308
+
309
+ async pickEngine(engine) {
310
+ const list = await this.engines();
311
+ // Per defecte: el model local més petit de 3 mil milions de paràmetres o més; si no, Claude ràpid; si no, el primer.
312
+ const auto = list.filter((e) => e.local && e.id.startsWith('ollama:') && e.params >= 3)[0] || list.find((e) => e.id === 'claude:haiku') || list[0];
313
+ return list.find((e) => e.id === engine) || auto || null;
314
+ }
315
+
316
+ async ask(pick, task, body, json) {
317
+ const local = pick.id.startsWith('ollama:'), wrap = !json && local;
318
+ const user = `--- TRANSCRIPCIÓ ---\n${body}\n--- FI ---\n\n${task}\n\nFes-ho només amb el que es diu entre «--- TRANSCRIPCIÓ ---» i «--- FI ---». No parlis d’aquestes instruccions ni de tu mateix. Si la transcripció no té prou contingut, torna el text buit.${wrap ? '\n\nTorna només JSON amb aquest format: {"text":"…"}. El text pot tenir salts de línia.' : ''}`;
319
+ const raw = await runModel(this.cfg, pick.id, LIVE_SYSTEM, user, json || wrap, { numCtx: local ? 8192 : 0 });
320
+ return json ? parseJson(raw) : wrap ? { text: String(parseJson(raw).text ?? '') } : { text: raw };
321
+ }
322
+
139
323
  // ---------- Motors per resumir i organitzar
140
324
  async engines() {
141
325
  const { binPath } = require('./agents');
@@ -179,6 +363,9 @@ class Notes {
179
363
  return [
180
364
  R('GET', '/api/notes', () => ({ notes: this.notes })),
181
365
  R('POST', '/api/notes', ({ body }) => this.create(body), { limit: 1024 * 1024 }),
366
+ R('POST', '/api/notes/live', ({ body }) => this.liveStart(body || {})),
367
+ R('POST', /^\/api\/notes\/([\w-]+)\/live\/seg$/, async ({ req, params, url }) => this.liveSeg(params[0], await readRaw(req), String(req.headers['content-type'] || 'audio/webm').split(';')[0], Object.fromEntries(url.searchParams)), { raw: true }),
368
+ R('POST', /^\/api\/notes\/([\w-]+)\/live\/(stop|resume|summary)$/, ({ params, body }) => (params[1] === 'stop' ? this.liveStop(params[0]) : params[1] === 'resume' ? this.liveResume(params[0]) : this.liveSummary(params[0], body || {}))),
182
369
  R('GET', '/api/notes/engines', () => this.engines().then((engines) => ({ engines }))),
183
370
  R('POST', '/api/notes/ai', ({ body }) => this.ai(body || {})),
184
371
  R('PATCH', /^\/api\/notes\/([\w-]+)$/, ({ params, body }) => this.patch(params[0], body), { limit: 1024 * 1024 }),
@@ -241,6 +428,37 @@ const ACTIONS = {
241
428
  },
242
429
  };
243
430
 
431
+ // ---------- Classe o reunió
432
+ const LIVE_KINDS = { classe: 'Classe', reunio: 'Reunió' };
433
+ const LIVE_LANGS = ['ca', 'es', 'en'];
434
+ const PART_CHARS = 3500; // ≈ 4-5 minuts de veu per tros d'apunts
435
+ // Whisper s'inventa aquestes frases quan sent silenci o soroll.
436
+ const HALLUCINATION = /^[\s.…]*$|^(gràcies( per (veure|mirar|escoltar)[^.!]*)?|subtítols[^.!]*|amara\.org[^.!]*|gracias( por (ver|mirar)[^.!]*)?|subtítulos[^.!]*|thank you( for watching)?|thanks for watching)[.!…\s]*$/i;
437
+ const MIN_WORDS = 25; // per sota, no hi ha res a apuntar ni a resumir
438
+ const words = (t) => (String(t || '').match(/[\p{L}\p{N}]+/gu) || []).length;
439
+ // Whisper de vegades es queda encallat repetint la mateixa frase: se'n deixa una.
440
+ function unloop(t) {
441
+ let out = t;
442
+ for (let n = 1; n <= 12; n++) out = out.replace(new RegExp(`((?:\\S+\\s+){${n - 1}}\\S+)(?:\\s+\\1(?![\\p{L}\\p{N}])){3,}`, 'gu'), '$1');
443
+ return out;
444
+ }
445
+ // La resposta parla de les instruccions (el «system prompt») en lloc de la transcripció.
446
+ const ECHO = /assistent de notes|transcripci[óo] autom[àa]tica|no t[’']inventis|no facis servir cap eina|---\s*(TRANSCRIPCI[ÓO]|FI)\s*---|torna nom[ée]s json|en l[’']idioma de la transcripci[óo]|les meves instruccions|system prompt/i;
447
+ const echoes = (t) => ECHO.test(String(t || ''));
448
+ const cleanOut = (t) => { const v = String(t || '').trim(); return echoes(v) ? '' : v; };
449
+ const LIVE_SYSTEM = 'Ets l’assistent de notes de nio. Treballes amb la transcripció automàtica d’una classe o d’una reunió: pot tenir paraules mal transcrites, que pots corregir quan el context ho deixa clar. No t’inventis res que no s’hi digui. Respon en l’idioma de la transcripció (si dubtes, en català). No facis servir cap eina.';
450
+ const LIVE_TASKS = {
451
+ part: {
452
+ classe: 'Escriu els apunts d’aquest tros de classe: de 3 a 8 punts curts amb els conceptes, definicions, exemples, fórmules i dades que s’hi expliquen. Cada punt comença amb «- ». Només els punts, sense introducció.',
453
+ reunio: 'Escriu les notes d’aquest tros de reunió: de 3 a 8 punts curts amb els temes tractats, les decisions i les tasques (amb qui se’n fa càrrec, si es diu). Cada punt comença amb «- ». Només els punts, sense introducció.',
454
+ },
455
+ condense: 'Són apunts seguits d’una mateixa classe o reunió. Fes-ne uns de sols, sense repeticions i amb tot el que és important: com a màxim 12 punts. Cada punt comença amb «- ». Només els punts.',
456
+ final: {
457
+ classe: 'Fes el resum de tota la classe. Torna només JSON amb aquest format: {"title":"…","text":"…"}. «title»: el tema de la classe, en menys de 8 paraules. «text»: Markdown amb aquests apartats: «## Resum» (un paràgraf de 4 a 6 frases), «## Conceptes clau» (llista «- **concepte**: explicació curta»), «## Punts importants» (llista), «## Per repassar» (de 3 a 5 preguntes que podrien sortir a l’examen) i, només si se’n parla, «## Deures i dates».',
458
+ reunio: 'Fes el resum de tota la reunió. Torna només JSON amb aquest format: {"title":"…","text":"…"}. «title»: el tema principal, en menys de 8 paraules. «text»: Markdown amb aquests apartats: «## Resum» (de 3 a 5 frases), «## Decisions» (llista), «## Tasques» (llista «- [ ] qui: què»; si no es diu qui, només què) i «## Temes oberts» (llista). Deixa fora els apartats que quedarien buits.',
459
+ },
460
+ };
461
+
244
462
  function parseJson(raw) {
245
463
  const t = String(raw || '').replace(/^```(?:json)?\s*|\s*```$/g, '').trim();
246
464
  try { return JSON.parse(t); } catch { /* potser hi ha text al voltant */ }
@@ -264,14 +482,14 @@ function runCli(bin, args, input, { cwd, timeout = 180000 } = {}) {
264
482
  });
265
483
  }
266
484
 
267
- async function runModel(cfg, engine, system, user, json) {
485
+ async function runModel(cfg, engine, system, user, json, { numCtx = 0 } = {}) {
268
486
  const { binPath } = require('./agents');
269
487
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'nio-nota-'));
270
488
  try {
271
489
  if (engine.startsWith('ollama:')) {
272
490
  // Qwen3 «pensa» abans de respondre (minuts en un model petit): «/no_think» ho desactiva. Si la resposta porta un bloc <think>, es treu.
273
491
  const model = engine.slice(7), soft = /qwen3/i.test(model) ? '\n\n/no_think' : '';
274
- const out = await ollama.chat({ model, messages: [{ role: 'system', content: system }, { role: 'user', content: user + soft }], format: json ? 'json' : undefined, think: false, maxTokens: 3000, signal: AbortSignal.timeout(150000) }).catch((e) => { throw e.name === 'TimeoutError' ? httpErr(504, 'El model ha trigat massa. Prova-ho amb un de més petit o ràpid.') : e; });
492
+ const out = await ollama.chat({ model, messages: [{ role: 'system', content: system }, { role: 'user', content: user + soft }], format: json ? 'json' : undefined, think: false, maxTokens: 3000, numCtx: numCtx || undefined, signal: AbortSignal.timeout(150000) }).catch((e) => { throw e.name === 'TimeoutError' ? httpErr(504, 'El model ha trigat massa. Prova-ho amb un de més petit o ràpid.') : e; });
275
493
  return out.replace(/^[\s\S]*?<\/think>\s*/i, '').trim();
276
494
  }
277
495
  if (engine === 'claude' || engine.startsWith('claude:')) {
package/src/voice.js CHANGED
@@ -58,11 +58,21 @@ const PROVIDERS = {
58
58
  function ext(mime) {
59
59
  return /ogg/.test(mime || '') ? 'ogg' : /mp4|m4a|aac/.test(mime || '') ? 'm4a' : /mpeg|mp3/.test(mime || '') ? 'mp3' : /wav/.test(mime || '') ? 'wav' : 'webm';
60
60
  }
61
+ // Una clau restringida a la qual falta un permís: es diu quin interruptor cal activar a ElevenLabs.
62
+ const EL_PERMS = { text_to_speech: 'Text to Speech → Access', speech_to_text: 'Speech to Text → Access', convai_write: 'ElevenAgents → Write', convai_read: 'ElevenAgents → Write' };
63
+ function elPermHint(msg) {
64
+ const m = String(msg || '').match(/missing the permission (\w+)/i);
65
+ if (!m) return null;
66
+ const what = EL_PERMS[m[1]] || m[1];
67
+ return `A la clau d’ElevenLabs li falta el permís «${what}». Edita-la a elevenlabs.io (Developers → API Keys) i activa’l, o crea’n una de nova amb aquest permís.`;
68
+ }
69
+
61
70
  async function errText(r) {
62
71
  const t = await r.text().catch(() => '');
63
72
  try {
64
73
  const j = JSON.parse(t);
65
- return (j.detail?.message || j.detail || j.error?.message || j.error || t).toString().slice(0, 200);
74
+ const msg = (j.detail?.message || j.detail || j.error?.message || j.error || t).toString();
75
+ return elPermHint(msg) || msg.slice(0, 200);
66
76
  } catch {
67
77
  return `${r.status} ${t.slice(0, 160)}`;
68
78
  }
@@ -93,12 +103,14 @@ function status(cfg) {
93
103
  };
94
104
  }
95
105
 
96
- async function transcribe(cfg, audio, mime) {
106
+ async function transcribe(cfg, audio, mime, lang, opts = {}) {
97
107
  const p = pick(cfg, 'stt');
108
+ lang = lang || cfgOf(cfg).lang || 'ca';
109
+ const l = lang === 'auto' ? '' : lang; // «auto»: que el proveïdor ho endevini
98
110
  if (!p) throw httpErr(400, 'Aquest ordinador encara no té veu. Activa Whisper (gratis) o posa una clau a Ajustos → Veu.');
99
111
  if (!audio?.length) throw httpErr(400, 'No hi ha àudio');
100
- if (p === 'whisper') return { text: await whisper.transcribe(cfg, audio, mime, cfgOf(cfg).lang || 'ca'), provider: p };
101
- return { text: (await PROVIDERS[p].stt(cfg.voice.keys[p], audio, mime, cfgOf(cfg).lang || 'ca')).trim(), provider: p };
112
+ if (p === 'whisper') return { text: await whisper.transcribe(cfg, audio, mime, lang, opts), provider: p };
113
+ return { text: (await PROVIDERS[p].stt(cfg.voice.keys[p], audio, mime, l)).trim(), provider: p };
102
114
  }
103
115
 
104
116
  async function speak(cfg, text) {
@@ -179,4 +191,4 @@ function routes(cfg) {
179
191
  ];
180
192
  }
181
193
 
182
- module.exports = { routes, transcribe, speak, status, pick, PROVIDERS };
194
+ module.exports = { routes, transcribe, speak, status, pick, PROVIDERS, elPermHint };
package/src/whisper.js CHANGED
@@ -120,7 +120,7 @@ function ext(mime) {
120
120
  return /ogg/.test(mime || '') ? 'ogg' : /mp4|m4a|aac/.test(mime || '') ? 'm4a' : /mpeg|mp3/.test(mime || '') ? 'mp3' : /wav/.test(mime || '') ? 'wav' : 'webm';
121
121
  }
122
122
 
123
- async function transcribe(cfg, audio, mime, lang = 'ca') {
123
+ async function transcribe(cfg, audio, mime, lang = 'ca', { prompt = '' } = {}) {
124
124
  const e = engine();
125
125
  if (!e) throw httpErr(400, `Falta Whisper en aquest ordinador. Instal·la’l: ${installHint()}`);
126
126
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'nio-veu-'));
@@ -128,7 +128,7 @@ async function transcribe(cfg, audio, mime, lang = 'ca') {
128
128
  const input = path.join(tmp, `audio.${ext(mime)}`);
129
129
  fs.writeFileSync(input, audio);
130
130
  if (e === 'python') {
131
- await run(pyBin(), [input, '--model', 'turbo', '--language', lang, '--output_format', 'txt', '--output_dir', tmp, '--fp16', 'False', '--verbose', 'False']);
131
+ await run(pyBin(), [input, '--model', 'turbo', ...(lang === 'auto' ? [] : ['--language', lang]), '--output_format', 'txt', '--output_dir', tmp, '--fp16', 'False', '--verbose', 'False', ...(prompt ? ['--initial_prompt', prompt] : [])]);
132
132
  return fs.readFileSync(path.join(tmp, 'audio.txt'), 'utf8').trim();
133
133
  }
134
134
  if (!fs.existsSync(modelPath(cfg))) {
@@ -138,7 +138,7 @@ async function transcribe(cfg, audio, mime, lang = 'ca') {
138
138
  const wav = path.join(tmp, 'audio.wav');
139
139
  await run(which('ffmpeg'), ['-nostdin', '-loglevel', 'error', '-i', input, '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', wav], 60000);
140
140
  const threads = String(Math.max(1, Math.min(8, os.cpus().length - 1)));
141
- const out = await run(cppBin(), ['-m', modelPath(cfg), '-f', wav, '-l', lang, '-t', threads, '-nt', '-np']);
141
+ const out = await run(cppBin(), ['-m', modelPath(cfg), '-f', wav, '-l', lang, '-t', threads, '-nt', '-np', ...(prompt ? ['--prompt', prompt] : [])]);
142
142
  return out.split('\n').map((l) => l.trim()).filter((l) => l && !/^\[(BLANK_AUDIO|Música|Music)\]$/i.test(l)).join(' ').trim();
143
143
  } finally {
144
144
  fs.rm(tmp, { recursive: true, force: true }, () => {});