nio-cat 0.9.5 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,15 +8,16 @@ Els teus agents (Claude Code i Codex) als teus ordinadors, controlats des del m
8
8
  curl -fsSL https://www.nio.cat/install.sh | sh
9
9
  ```
10
10
 
11
- Instal·la nio i `cloudflared` al teu usuari, el deixa com a servei (systemd o launchd) i mostra un codi QR. Escaneja’l amb el mòbil a [nio.cat/app](https://www.nio.cat/app). Per provar-lo sense instal·lar res: `npx nio-cat`.
11
+ Instal·la nio i `cloudflared` al teu usuari, el deixa com a servei (systemd o launchd) i obre nio al navegador amb un codi QR. Escaneja’l amb el mòbil. Per provar-lo sense instal·lar res: `npx nio-cat`, o la demo de [nio.cat/app](https://www.nio.cat/app), amb un ordinador simulat.
12
12
 
13
13
  ## Què fa
14
14
 
15
15
  - **Sessions** de Claude Code i Codex en directe, a qualsevol dels teus ordinadors.
16
16
  - **Aprovacions** amb el motiu, al mòbil, al push o a Telegram, i **registre** de totes les accions (`~/.nio/audit.jsonl`).
17
+ - **Notes** com Google Keep: targetes, llistes, colors i etiquetes. Grava amb el mòbil i Whisper les transcriu a l’ordinador. Un model local (Ollama), Claude Code o Codex les resumeix i les organitza. Es desen a `~/.nio/notes`.
17
18
  - **Feines**: cua, cadenes («quan acabi, que Codex ho revisi») i programació (cron).
18
19
  - **Memòria compartida** a `~/.nio/memory`, amb un servidor MCP (`nio mcp`) registrat a Claude Code i Codex.
19
- - **Telegram** (el teu bot) i **veu** (ElevenLabs o OpenAI), amb les claus a l’ordinador.
20
+ - **Telegram** (el teu bot) i **veu**: dictat gratis amb Whisper a l’ordinador (whisper.cpp + ffmpeg; l’àudio no en surt), o ElevenLabs i OpenAI amb les teves claus.
20
21
  - **Navegador i escriptori** amb agent-browser i agent-desktop: mirar passa sol, actuar sempre demana permís.
21
22
 
22
23
  ## Arquitectura
package/bin/nio.js CHANGED
@@ -14,6 +14,7 @@ const service = require('../src/service');
14
14
  const { Auth } = require('../src/auth');
15
15
  const { Audit } = require('../src/audit');
16
16
  const memory = require('../src/memory');
17
+ const { Notes } = require('../src/notes');
17
18
  const mcp = require('../src/mcp');
18
19
  const directory = require('../src/directory');
19
20
  const voice = require('../src/voice');
@@ -22,7 +23,7 @@ const { Control } = require('../src/control');
22
23
  const { Remote } = require('../src/remote');
23
24
  const { Jobs } = require('../src/jobs');
24
25
  const { addServer } = require('../src/ssh');
25
- const { pairLink, terminalQr, writePairPage, makeCode, codeLink, terminalQrSize } = require('../src/pair');
26
+ const { pairLink, writePairPage, makeCode, codeLink } = require('../src/pair');
26
27
  const { which } = require('../src/proc');
27
28
 
28
29
  const argv = process.argv.slice(2);
@@ -111,17 +112,10 @@ async function showPair(publicUrl) {
111
112
  const { code: secret } = auth.createCode({ secret: true });
112
113
  const link = pairLink(app, publicUrl, secret), code = makeCode(publicUrl, secret), qrLink = codeLink(app, code);
113
114
  const file = writePairPage(link, code, qrLink);
114
- // El QR sempre al terminal, el més gran que hi càpiga: gran (2 caràcters per mòdul), compacte (mig bloc) i,
115
- // si ni així hi cap d'ample, cap QR i només el codi. Amb poques files, el marge passa de 2 a 1 mòdul.
116
- const rows = process.stdout.rows || 999, cols = process.stdout.columns || 999;
117
- const fits = (m, big) => { const z = terminalQrSize(qrLink, m, big); return z.rows + 4 <= rows && z.cols <= cols; };
118
- const opt = [[2, true], [2, false], [1, false]].find(([m, big]) => fits(m, big)) || (terminalQrSize(qrLink, 1).cols <= cols ? [1, false] : null);
115
+ // El QR no surt al terminal (no s'hi veu bé): és al panell que s'obre al navegador.
119
116
  console.log(`\n ${c.b('Connecta el mòbil')}\n`);
120
- if (opt) {
121
- console.log(` Escaneja aquest codi amb la càmera${panelOpened ? ' (també el tens al navegador)' : ''}:\n`);
122
- console.log(terminalQr(qrLink, { margin: opt[0], big: opt[1] }));
123
- } else console.log(` La finestra és massa estreta per al QR${panelOpened ? '; el tens al navegador' : '. Fes-la més ampla i torna-ho a provar: nio pair'}.`);
124
- console.log(`\n ${opt ? 'O copia' : 'Copia'} aquest codi i enganxa'l a Sessions → Tria un ordinador:\n`);
117
+ if (panelOpened) console.log(' He obert nio al navegador: escaneja el QR que hi surt amb la càmera del mòbil.\n O, al mòbil, obre nio.cat/app → Ordinadors → Afegeix i enganxa aquest codi:\n');
118
+ else console.log(' Al mòbil, obre nio.cat/app → Ordinadors → Afegeix i enganxa aquest codi:\n');
125
119
  console.log(` ${code}\n`);
126
120
  console.log(c.dim(' Serveix per a un sol dispositiu i caduca en 15 minuts. Per a un altre: nio pair.\n'));
127
121
  if (flags.open) {
@@ -220,7 +214,8 @@ async function start() {
220
214
  if (cfg.telegram?.token) row('Telegram', 'actiu', `(${(cfg.telegram.allowed || []).length} xats autoritzats)`);
221
215
  const control = new Control({ cfg, hub, audit });
222
216
  const remote = new Remote({ cfg, audit });
223
- const routes = [...memory.routes(), ...jobs.routes(), ...voice.routes(cfg), ...tg.routes(), ...control.routes(), ...remote.routes()];
217
+ const notes = new Notes({ cfg, hub });
218
+ const routes = [...memory.routes(), ...notes.routes(), ...jobs.routes(), ...voice.routes(cfg), ...tg.routes(), ...control.routes(), ...remote.routes()];
224
219
  const server = createServer({ cfg, hub, auth, audit, routes, beforeApproval: (t, i) => control.beforeApproval(t, i), info: async () => ({ control: await control.caps().catch(() => null) }), getPublicUrl: () => publicUrl, getApp: () => flags.app || cfg.app });
225
220
 
226
221
  server.on('error', (e) => {
@@ -414,7 +409,7 @@ async function start() {
414
409
  --quick Túnel ràpid encara que n'hi hagi un amb nom
415
410
  --local Obre la còpia del panell d'aquest ordinador (si el navegador bloqueja nio.cat, p. ex. Safari)
416
411
  --open Obre al navegador la pàgina del codi QR
417
- nio pair Mostra el codi QR una altra vegada
412
+ nio pair Obre el navegador amb el QR per connectar el mòbil (i mostra el codi)
418
413
 
419
414
  nio ssh usuari@servidor Afegeix un servidor (VPS) per SSH: hi engega nio amb un túnel i el pots fer servir des del mòbil.
420
415
  --port <n> Port de nio al servidor (7717)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
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",
@@ -110,7 +110,10 @@ function handle(ctx, line) {
110
110
  error: ev.is_error ? ev.result || ev.subtype : null,
111
111
  });
112
112
  break;
113
+ case 'rate_limit_event':
114
+ ctx.limits?.(ev.rate_limit_info);
115
+ break;
113
116
  default:
114
- break; // rate_limit_event, etc.
117
+ break;
115
118
  }
116
119
  }
@@ -70,11 +70,12 @@ 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 }) {
73
+ async function chat({ model, messages, format, think, maxTokens, 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 } : {}), options: { temperature: 0.2 } }),
77
+ body: JSON.stringify({ model, messages, stream: false, ...(format ? { format } : {}), ...(think === false ? { think: false } : {}), options: { temperature: 0.2, ...(maxTokens ? { num_predict: maxTokens } : {}) } }),
78
+ ...(signal ? { signal } : {}),
78
79
  });
79
80
  if (!res.ok) throw new Error(`Ollama ha tornat un error ${res.status}: ${await res.text()}`);
80
81
  const data = await res.json();
package/src/hub.js CHANGED
@@ -7,6 +7,7 @@ const { EventEmitter } = require('events');
7
7
  const { byId, binPath } = require('./agents');
8
8
  const { HOME, save } = require('./config');
9
9
  const push = require('./push');
10
+ const usage = require('./usage');
10
11
 
11
12
  const MAX_EVENTS = 3000;
12
13
  const MAX_OUT = 4000;
@@ -269,6 +270,7 @@ class Hub extends EventEmitter {
269
270
  },
270
271
  toolResult: ({ id: tid, output, error }) =>
271
272
  this.push(s, 'tool_result', { id: tid, name: toolNames.get(tid), output: clip(output), error: !!error }),
273
+ limits: (info) => { if (usage.setClaude(info)) this.emit('out', { type: 'usage', usage: usage.all() }); },
272
274
  result: (r) => {
273
275
  if (typeof r.cost === 'number') s.cost = (s.cost || 0) + r.cost;
274
276
  this.push(s, 'result', r);
package/src/notes.js ADDED
@@ -0,0 +1,293 @@
1
+ 'use strict';
2
+ // Notes: com Google Keep, però a l'ordinador. Es desen a ~/.nio/notes/notes.json i l'àudio a ~/.nio/notes/audio.
3
+ // · Transcripció: el mòbil (o el navegador) grava i l'ordinador transcriu amb Whisper (gratis i local).
4
+ // · Resums i ordre: amb un model local d'Ollama, o amb Claude Code o Codex d'aquest ordinador.
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const crypto = require('crypto');
9
+ const { spawn } = require('child_process');
10
+ const { HOME } = require('./config');
11
+ const { childEnv } = require('./proc');
12
+ const voice = require('./voice');
13
+ const ollama = require('./agents/ollama');
14
+
15
+ const DIR = path.join(HOME, 'notes');
16
+ const FILE = path.join(DIR, 'notes.json');
17
+ const AUDIO = path.join(DIR, 'audio');
18
+ const MAX_AUDIO = 25 * 1024 * 1024;
19
+ const TRASH_DAYS = 30;
20
+ const COLORS = ['', 'lemon', 'peach', 'moss', 'sky', 'lilac', 'rose', 'sand'];
21
+ const rid = () => crypto.randomBytes(6).toString('base64url');
22
+ const httpErr = (status, message) => Object.assign(new Error(message), { status });
23
+ const extOf = (mime) => (/ogg/.test(mime) ? 'ogg' : /mp4|m4a|aac/.test(mime) ? 'm4a' : /mpeg|mp3/.test(mime) ? 'mp3' : /wav/.test(mime) ? 'wav' : 'webm');
24
+ // «751.63M» → 0,75 · «8.2B» → 8,2 (mil milions de paràmetres)
25
+ const billions = (p) => { const m = String(p || '').match(/([\d.]+)\s*([MB])/i); return m ? parseFloat(m[1]) / (m[2].toUpperCase() === 'M' ? 1000 : 1) : 0; };
26
+ const str = (v, max) => String(v ?? '').slice(0, max);
27
+
28
+ // Només es deixen passar els camps coneguts, amb límits.
29
+ function clean(b = {}) {
30
+ const o = {};
31
+ if ('title' in b) o.title = str(b.title, 200);
32
+ if ('text' in b) o.text = str(b.text, 200000);
33
+ if ('items' in b) {
34
+ o.items = b.items === null ? null : (Array.isArray(b.items) ? b.items : []).slice(0, 500).map((i) => ({ id: str(i?.id, 20) || rid(), text: str(i?.text, 500), done: !!i?.done }));
35
+ }
36
+ if ('color' in b) o.color = COLORS.includes(b.color) ? b.color : '';
37
+ for (const k of ['pinned', 'archived']) if (k in b) o[k] = !!b[k];
38
+ if ('labels' in b) o.labels = [...new Set((Array.isArray(b.labels) ? b.labels : []).map((l) => str(l, 30).trim()).filter(Boolean))].slice(0, 12);
39
+ if ('trashed' in b) o.trashed = b.trashed ? Date.now() : 0;
40
+ return o;
41
+ }
42
+
43
+ class Notes {
44
+ constructor({ cfg, hub }) {
45
+ this.cfg = cfg;
46
+ this.hub = hub;
47
+ this.notes = [];
48
+ this.timer = null;
49
+ try { this.notes = JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch { /* encara no n'hi ha */ }
50
+ const old = Date.now() - TRASH_DAYS * 864e5;
51
+ this.notes = this.notes.filter((n) => !n.trashed || n.trashed > old);
52
+ }
53
+
54
+ save() {
55
+ clearTimeout(this.timer);
56
+ this.timer = setTimeout(() => {
57
+ fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
58
+ const tmp = `${FILE}.tmp`;
59
+ fs.writeFile(tmp, JSON.stringify(this.notes), (e) => { if (!e) fs.rename(tmp, FILE, () => {}); });
60
+ }, 250);
61
+ this.timer.unref?.();
62
+ }
63
+
64
+ emit(note) { this.hub.emit('out', { type: 'note', note }); }
65
+ get(id) { const n = this.notes.find((x) => x.id === id); if (!n) throw httpErr(404, 'Aquesta nota no existeix'); return n; }
66
+
67
+ create(body) {
68
+ const now = Date.now();
69
+ const n = { id: rid(), title: '', text: '', items: null, color: '', pinned: false, archived: false, trashed: 0, labels: [], audio: [], created: now, updated: now, ...clean(body) };
70
+ this.notes.unshift(n);
71
+ this.save();
72
+ this.emit(n);
73
+ return n;
74
+ }
75
+
76
+ patch(id, body) {
77
+ const n = this.get(id);
78
+ Object.assign(n, clean(body), { updated: Date.now() });
79
+ this.save();
80
+ this.emit(n);
81
+ return n;
82
+ }
83
+
84
+ remove(id, forever) {
85
+ const n = this.get(id);
86
+ if (!n.trashed && !forever) return this.patch(id, { trashed: true });
87
+ for (const a of n.audio) fs.rm(this.audioFile(n, a), { force: true }, () => {});
88
+ this.notes = this.notes.filter((x) => x !== n);
89
+ this.save();
90
+ this.hub.emit('out', { type: 'note-gone', id });
91
+ return { ok: true };
92
+ }
93
+
94
+ audioFile(n, a) { return path.join(AUDIO, `${n.id}-${a.id}.${extOf(a.mime)}`); }
95
+
96
+ // Desa l'àudio i el transcriu en segon pla; el resultat arriba per «note» (flux d'esdeveniments).
97
+ addAudio(id, buf, mime, dur) {
98
+ const n = this.get(id);
99
+ if (!buf?.length || buf.length < 1500) throw httpErr(400, 'No he sentit res. Prova-ho una altra vegada.');
100
+ const a = { id: rid(), mime, size: buf.length, dur: Number(dur) > 0 ? Math.round(Number(dur)) : 0, at: Date.now(), state: 'transcribing', text: '', error: '' };
101
+ fs.mkdirSync(AUDIO, { recursive: true, mode: 0o700 });
102
+ fs.writeFileSync(this.audioFile(n, a), buf);
103
+ n.audio.push(a);
104
+ n.updated = Date.now();
105
+ this.save();
106
+ this.emit(n);
107
+ this.transcribe(n, a, buf);
108
+ return n;
109
+ }
110
+
111
+ retry(id, aid) {
112
+ const n = this.get(id), a = n.audio.find((x) => x.id === aid);
113
+ if (!a) throw httpErr(404, 'Aquest àudio no existeix');
114
+ if (a.state === 'transcribing') return n;
115
+ a.state = 'transcribing'; a.error = '';
116
+ this.emit(n);
117
+ this.transcribe(n, a, fs.readFileSync(this.audioFile(n, a)));
118
+ return n;
119
+ }
120
+
121
+ async transcribe(n, a, buf) {
122
+ try {
123
+ const { text } = await voice.transcribe(this.cfg, buf, a.mime);
124
+ const t = String(text || '').trim();
125
+ if (!t) throw new Error('No he entès res en aquest àudio.');
126
+ a.text = t; a.state = 'ok'; a.error = '';
127
+ if (n.items) {
128
+ const parts = t.split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter(Boolean);
129
+ n.items.push(...parts.map((s) => ({ id: rid(), text: s.slice(0, 500), done: false })));
130
+ } else {
131
+ n.text = n.text ? `${n.text.trimEnd()}\n\n${t}` : t;
132
+ }
133
+ } catch (e) {
134
+ a.state = 'error'; a.error = String(e.message || e).slice(0, 300);
135
+ }
136
+ if (this.notes.includes(n)) { n.updated = Date.now(); this.save(); this.emit(n); }
137
+ }
138
+
139
+ // ---------- Motors per resumir i organitzar
140
+ async engines() {
141
+ const { binPath } = require('./agents');
142
+ const out = [];
143
+ const models = await ollama.listModels();
144
+ // Un model «:cloud» d'Ollama s'executa als seus servidors: no compta com a local. Els locals, de petit a gran.
145
+ for (const m of [...(models || [])].sort((a, b) => (/cloud/.test(a.name) || !a.size) - (/cloud/.test(b.name) || !b.size) || a.size - b.size)) {
146
+ const cloud = /:cloud$|-cloud$/.test(m.name) || !m.size;
147
+ out.push({ id: `ollama:${m.name}`, label: `${m.name}${m.params ? ` · ${m.params}` : ''}${cloud ? ' · núvol' : ''}`, local: !cloud, group: cloud ? 'Ollama (núvol)' : 'Local (Ollama)', size: m.size, params: billions(m.params) });
148
+ }
149
+ if (binPath(this.cfg, 'claude')) out.push({ id: 'claude:haiku', label: 'Claude Code · ràpid', local: false, group: 'Claude Code' }, { id: 'claude', label: 'Claude Code · model per defecte', local: false, group: 'Claude Code' });
150
+ if (binPath(this.cfg, 'codex')) out.push({ id: 'codex', label: 'Codex', local: false, group: 'Codex' });
151
+ return out;
152
+ }
153
+
154
+ async ai({ id, action, engine, prompt }) {
155
+ const n = this.get(id);
156
+ const body = n.items ? `${n.text ? `${n.text}\n\n` : ''}${n.items.map((i) => `- [${i.done ? 'x' : ' '}] ${i.text}`).join('\n')}` : n.text;
157
+ if (!`${n.title}${body}`.trim()) throw httpErr(400, 'La nota és buida');
158
+ const spec = ACTIONS[action];
159
+ if (!spec) throw httpErr(400, 'Acció desconeguda');
160
+ if (action === 'custom' && !String(prompt || '').trim()) throw httpErr(400, 'Escriu què vols que faci');
161
+ const list = await this.engines();
162
+ // 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.
163
+ const auto = list.filter((e) => e.local && e.id.startsWith('ollama:') && e.params >= 3)[0] || list.find((e) => e.id === 'claude:haiku') || list[0];
164
+ const pick = list.find((e) => e.id === engine) || auto;
165
+ if (!pick) throw httpErr(400, 'No hi ha cap model en aquest ordinador. Instal·la Ollama amb un model (ollama pull qwen3:4b) o Claude Code.');
166
+ // La nota va primer i la instrucció al final: els models petits segueixen millor l'última cosa que llegeixen.
167
+ const user = `--- NOTA ---\nTítol: ${n.title || '(sense títol)'}\n\n${body.slice(0, 40000)}\n--- FI DE LA NOTA ---\n\n${spec.task(String(prompt || '').slice(0, 1000))}`;
168
+ // Els models locals petits «pensen» en veu alta quan responen en text lliure (minuts). En mode JSON surten directes.
169
+ const local = pick.id.startsWith('ollama:'), wrap = !spec.json && local;
170
+ const raw = await runModel(this.cfg, pick.id, SYSTEM, wrap ? `${user}\n\nTorna només JSON amb aquest format: {"text":"…"}. El text pot tenir salts de línia.` : user, spec.json || wrap);
171
+ const result = spec.json ? parseJson(raw) : wrap ? { text: String(parseJson(raw).text ?? '').trim() } : { text: raw.trim() };
172
+ if (typeof result.text === 'string' && /--- (FI DE LA )?NOTA ---|Torna només JSON/.test(result.text)) throw httpErr(502, 'El model ha respost malament. Torna-ho a provar o tria un model més gran.');
173
+ if (typeof result.text === 'string') result.text = result.text.replace(/^\s*T[ií]tol:[^\n]*\n+/i, '').trim(); // els models petits repeteixen el títol
174
+ return { engine: pick.id, local: pick.local, action, result: spec.shape(result, n) };
175
+ }
176
+
177
+ routes() {
178
+ const R = (method, path, handler, extra = {}) => ({ method, path, handler, ...extra });
179
+ return [
180
+ R('GET', '/api/notes', () => ({ notes: this.notes })),
181
+ R('POST', '/api/notes', ({ body }) => this.create(body), { limit: 1024 * 1024 }),
182
+ R('GET', '/api/notes/engines', () => this.engines().then((engines) => ({ engines }))),
183
+ R('POST', '/api/notes/ai', ({ body }) => this.ai(body || {})),
184
+ R('PATCH', /^\/api\/notes\/([\w-]+)$/, ({ params, body }) => this.patch(params[0], body), { limit: 1024 * 1024 }),
185
+ R('DELETE', /^\/api\/notes\/([\w-]+)$/, ({ params, url }) => this.remove(params[0], url.searchParams.get('forever') === '1')),
186
+ R('POST', /^\/api\/notes\/([\w-]+)\/audio$/, async ({ req, params, url }) => this.addAudio(params[0], await readRaw(req), String(req.headers['content-type'] || 'audio/webm').split(';')[0], url.searchParams.get('dur')), { raw: true }),
187
+ R('POST', /^\/api\/notes\/([\w-]+)\/audio\/([\w-]+)\/retry$/, ({ params }) => this.retry(params[0], params[1])),
188
+ R('GET', /^\/api\/notes\/([\w-]+)\/audio\/([\w-]+)$/, ({ res, params }) => {
189
+ const n = this.get(params[0]), a = n.audio.find((x) => x.id === params[1]);
190
+ if (!a) throw httpErr(404, 'Aquest àudio no existeix');
191
+ const buf = fs.readFileSync(this.audioFile(n, a));
192
+ res.writeHead(200, { 'content-type': a.mime, 'content-length': buf.length, 'cache-control': 'no-store' });
193
+ res.end(buf);
194
+ }),
195
+ ];
196
+ }
197
+ }
198
+
199
+ function readRaw(req, limit = MAX_AUDIO) {
200
+ return new Promise((resolve, reject) => {
201
+ const chunks = [];
202
+ let size = 0;
203
+ req.on('data', (c) => { size += c.length; if (size > limit) { reject(httpErr(413, 'L’àudio és massa llarg')); req.destroy(); } else chunks.push(c); });
204
+ req.on('end', () => resolve(Buffer.concat(chunks)));
205
+ req.on('error', reject);
206
+ });
207
+ }
208
+
209
+ // ---------- Instruccions per a cada acció
210
+ const SYSTEM = 'Ets l’assistent de notes de nio. Treballes només amb el text de la nota que et donen. No hi afegeixes res que no hi sigui. Respon en l’idioma de la nota (si dubtes, en català). No facis servir cap eina.';
211
+ const ACTIONS = {
212
+ summary: {
213
+ json: false,
214
+ task: () => 'Escriu un resum de 3 a 5 punts curts amb les idees principals de la nota, amb altres paraules (no copiïs el text). Cada punt comença amb «- ». Només els punts, sense introducció ni títol.',
215
+ shape: (r) => ({ text: r.text }),
216
+ },
217
+ clean: {
218
+ json: false,
219
+ task: () => 'La nota és una transcripció de veu. Posa-hi puntuació i majúscules, divideix-la en paràgrafs i treu els «eh», repeticions i falsos començaments. No canviïs el sentit ni resumeixis. Torna només el text net, sense el títol.',
220
+ shape: (r) => ({ text: r.text }),
221
+ },
222
+ tasks: {
223
+ json: true,
224
+ task: () => 'Extreu les tasques pendents de la nota. Torna només JSON amb aquest format: {"items":["tasca 1","tasca 2"]}. Cada tasca és una frase curta i en forma d’acció. Si no n’hi ha cap, {"items":[]}.',
225
+ shape: (r) => ({ items: (Array.isArray(r.items) ? r.items : []).map((s) => str(s, 300).trim()).filter(Boolean).slice(0, 50) }),
226
+ },
227
+ organize: {
228
+ json: true,
229
+ task: () => 'Organitza la nota: dona-li un títol curt, ordena el contingut en paràgrafs o llistes amb Markdown senzill i proposa 1 a 3 etiquetes d’una paraula en minúscula. Conserva tot el contingut important. Torna només JSON amb aquest format: {"title":"…","text":"…","labels":["…"]}.',
230
+ shape: (r, n) => ({ title: str(r.title, 200).trim() || n.title, text: str(r.text, 200000).trim(), labels: (Array.isArray(r.labels) ? r.labels : []).map((l) => str(l, 30).trim().toLowerCase()).filter(Boolean).slice(0, 3) }),
231
+ },
232
+ title: {
233
+ json: true,
234
+ task: () => 'Proposa un títol de menys de 8 paraules per a la nota. Torna només JSON amb aquest format: {"title":"…"}.',
235
+ shape: (r) => ({ title: str(r.title, 200).trim() }),
236
+ },
237
+ custom: {
238
+ json: false,
239
+ task: (p) => `Fes això amb la nota: ${p}\nTorna només el resultat, en text.`,
240
+ shape: (r) => ({ text: r.text }),
241
+ },
242
+ };
243
+
244
+ function parseJson(raw) {
245
+ const t = String(raw || '').replace(/^```(?:json)?\s*|\s*```$/g, '').trim();
246
+ try { return JSON.parse(t); } catch { /* potser hi ha text al voltant */ }
247
+ const i = t.indexOf('{'), j = t.lastIndexOf('}');
248
+ if (i >= 0 && j > i) { try { return JSON.parse(t.slice(i, j + 1)); } catch { /* res */ } }
249
+ throw httpErr(502, 'El model no ha tornat una resposta que pugui entendre. Prova-ho amb un altre model.');
250
+ }
251
+
252
+ // ---------- Execució d'un model, d'un sol cop
253
+ function runCli(bin, args, input, { cwd, timeout = 180000 } = {}) {
254
+ return new Promise((resolve, reject) => {
255
+ const p = spawn(bin, args, { cwd, env: childEnv(), stdio: ['pipe', 'pipe', 'pipe'] });
256
+ let out = '', err = '';
257
+ const t = setTimeout(() => { p.kill('SIGKILL'); reject(httpErr(504, 'El model ha trigat massa. Prova-ho amb un de més petit o ràpid.')); }, timeout);
258
+ p.stdout.on('data', (d) => { out += d; });
259
+ p.stderr.on('data', (d) => { err += d; });
260
+ p.on('error', (e) => { clearTimeout(t); reject(httpErr(500, `No he pogut engegar ${path.basename(bin)}: ${e.message}`)); });
261
+ p.on('close', (code) => { clearTimeout(t); code === 0 ? resolve(out) : reject(httpErr(502, (err || out || `Error ${code}`).trim().slice(-300))); });
262
+ p.stdin.on('error', () => {});
263
+ p.stdin.end(input);
264
+ });
265
+ }
266
+
267
+ async function runModel(cfg, engine, system, user, json) {
268
+ const { binPath } = require('./agents');
269
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'nio-nota-'));
270
+ try {
271
+ if (engine.startsWith('ollama:')) {
272
+ // Qwen3 «pensa» abans de respondre (minuts en un model petit): «/no_think» ho desactiva. Si la resposta porta un bloc <think>, es treu.
273
+ 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; });
275
+ return out.replace(/^[\s\S]*?<\/think>\s*/i, '').trim();
276
+ }
277
+ if (engine === 'claude' || engine.startsWith('claude:')) {
278
+ const args = ['-p', '--tools', '', '--no-session-persistence', '--output-format', 'text', '--system-prompt', system];
279
+ if (engine.startsWith('claude:')) args.push('--model', engine.slice(7));
280
+ return await runCli(binPath(cfg, 'claude'), args, user, { cwd: tmp });
281
+ }
282
+ if (engine === 'codex') {
283
+ const outFile = path.join(tmp, 'resposta.txt');
284
+ await runCli(binPath(cfg, 'codex'), ['exec', '--skip-git-repo-check', '--ephemeral', '-s', 'read-only', '-C', tmp, '-o', outFile, '-'], `${system}\n\n${user}`, { cwd: tmp });
285
+ return fs.readFileSync(outFile, 'utf8');
286
+ }
287
+ throw httpErr(400, 'Motor desconegut');
288
+ } finally {
289
+ fs.rm(tmp, { recursive: true, force: true }, () => {});
290
+ }
291
+ }
292
+
293
+ module.exports = { Notes, DIR, clean, parseJson, ACTIONS, COLORS };
package/src/pair.js CHANGED
@@ -25,7 +25,7 @@ function makeCode(url, token) {
25
25
  else bytes = Buffer.concat([Buffer.from([0]), Buffer.from(`${url}\n${token}`)]);
26
26
  return `nio-${bytes.toString('base64url')}`;
27
27
  }
28
- // Enllaç curt per al QR: una adreça de l'app amb el codi al fragment. Un QR més petit cap millor al terminal.
28
+ // Enllaç curt per al QR: una adreça de l'app amb el codi al fragment. Com més curt, més fàcil d'escanejar.
29
29
  const codeLink = (app, code) => `${app.replace(/\/$/, '')}/app#${code}`;
30
30
 
31
31
  function qr(text) {
@@ -35,47 +35,6 @@ function qr(text) {
35
35
  return q;
36
36
  }
37
37
 
38
- // QR per al terminal, compacte: cada caràcter «▀» pinta dos mòduls (dalt amb el color de la lletra, baix amb el del fons).
39
- // Ocupa la meitat d'alt i d'ample que amb dos espais per mòdul. Sempre negre sobre blanc, amb tema clar o fosc.
40
- function terminalQr(text, { color = !process.env.NO_COLOR, margin = 2, big = false } = {}) {
41
- if (big) return terminalQrBig(text, { color, margin });
42
- const q = qr(text);
43
- const n = q.getModuleCount();
44
- const M = margin;
45
- const dark = (r, c) => r >= 0 && c >= 0 && r < n && c < n && q.isDark(r, c);
46
- const lines = [];
47
- for (let r = -M; r < n + M; r += 2) {
48
- let line = '';
49
- for (let c = -M; c < n + M; c++) {
50
- const t = dark(r, c), b = r + 1 < n + M ? dark(r + 1, c) : false;
51
- if (color) line += `\x1b[${t ? 30 : 97};${b ? 40 : 107}m▀`;
52
- else line += t && b ? ' ' : t ? '▄' : b ? '▀' : '█';
53
- }
54
- lines.push(` ${line}${color ? '\x1b[0m' : ''}`);
55
- }
56
- return lines.join('\n');
57
- }
58
-
59
- // QR gran: dos espais amb color de fons per mòdul. Quadrat i molt fàcil d'escanejar, però ocupa el doble.
60
- function terminalQrBig(text, { color, margin: M }) {
61
- const q = qr(text), n = q.getModuleCount();
62
- const dark = (r, c) => r >= 0 && c >= 0 && r < n && c < n && q.isDark(r, c);
63
- const lines = [];
64
- for (let r = -M; r < n + M; r++) {
65
- let line = '', prev = null;
66
- for (let c = -M; c < n + M; c++) {
67
- const d = dark(r, c);
68
- if (color) { if (d !== prev) line += d ? '\x1b[40m' : '\x1b[107m'; line += ' '; }
69
- else line += d ? ' ' : '██';
70
- prev = d;
71
- }
72
- lines.push(` ${line}${color ? '\x1b[0m' : ''}`);
73
- }
74
- return lines.join('\n');
75
- }
76
-
77
- // SVG propi: una sola forma amb tires horitzontals de mòduls, vora de 4 mòduls i vores nítides.
78
- // (El de la llibreria dibuixa cada mòdul per separat i, en escalar-lo, deixa línies fines entre mòduls.)
79
38
  function qrSvg(text) {
80
39
  const q = qr(text), n = q.getModuleCount(), M = 4;
81
40
  let d = '';
@@ -102,17 +61,11 @@ main{width:min(420px,100% - 40px);padding:40px 0}h1{font-size:30px;font-weight:5
102
61
  .qr{background:#fffefd;border:1px solid #cec7bd;padding:18px;border-radius:16px;margin:24px 0}.qr svg{width:100%;height:auto;display:block}
103
62
  code{display:block;word-break:break-all;font:12px/1.5 ui-monospace,Menlo,monospace;background:#e6e1d8;padding:12px 14px;border-radius:12px;color:#272224}small{display:block;margin-top:20px;font-size:13px;color:#69615c}</style></head>
104
63
  <body><main><h1>Connecta el mòbil</h1><p>Escaneja el codi amb la càmera. S’obrirà nio ja connectat a aquest ordinador.</p><div class="qr">${svg}</div>
105
- <p>O copia aquest codi i enganxa'l a <b>Sessions → Tria un ordinador</b>:</p><code>${code}</code><small>Aquest codi dona accés als teus agents. Guarda'l com una contrasenya.</small></main></body></html>`;
64
+ <p>O copia aquest codi i enganxa'l a <b>nio.cat/app → Ordinadors → Afegeix</b>:</p><code>${code}</code><small>Aquest codi dona accés als teus agents. Guarda'l com una contrasenya.</small></main></body></html>`;
106
65
  const file = path.join(HOME, 'pair.html');
107
66
  fs.writeFileSync(file, html, { mode: 0o600 });
108
67
  return file;
109
68
  }
110
69
 
111
- module.exports = { pairLink, terminalQr, writePairPage, qrSvg, makeCode, codeLink };
70
+ module.exports = { pairLink, writePairPage, qrSvg, makeCode, codeLink };
112
71
 
113
- // Mida del QR al terminal: files i columnes que ocupa amb un marge donat.
114
- function terminalQrSize(text, margin = 2, big = false) {
115
- const n = qr(text).getModuleCount() + margin * 2;
116
- return big ? { rows: n, cols: n * 2 + 2 } : { rows: Math.ceil(n / 2), cols: n + 2 };
117
- }
118
- module.exports.terminalQrSize = terminalQrSize;
package/src/server.js CHANGED
@@ -14,6 +14,7 @@ const { addServer, knownHosts } = require('./ssh');
14
14
  const { agentCommands } = require('./commands');
15
15
  const { pairLink, qrSvg, makeCode, codeLink } = require('./pair');
16
16
  const directory = require('./directory');
17
+ const usage = require('./usage');
17
18
 
18
19
  // L'app web: copiada dins del paquet en publicar-lo, o l'arrel del repositori en desenvolupament.
19
20
  const WEB_DIRS = [path.join(__dirname, '..', 'web'), path.join(__dirname, '..', '..')];
@@ -160,9 +161,24 @@ function createServer({ cfg, hub, auth, audit, getPublicUrl, getApp, routes = []
160
161
  const seenDevices = new Map(); // mòbils que s'han connectat (per avisar l'ordinador que mostra el QR)
161
162
  const pulls = new Map(); // descàrregues de models d'Ollama en curs
162
163
 
163
- hub.on('out', (msg) => {
164
+ // Els trossos de text surten de seguida el primer cop i després s'agrupen cada 40 ms: menys paquets i menys repintats al mòbil.
165
+ const writeOut = (msg) => {
164
166
  const data = `data: ${JSON.stringify(msg)}\n\n`;
165
167
  for (const res of streams.keys()) res.write(data);
168
+ };
169
+ let pendDelta = null, deltaWin = 0;
170
+ const flushDelta = () => { if (pendDelta) { const d = pendDelta; pendDelta = null; writeOut(d); } };
171
+ hub.on('out', (msg) => {
172
+ if (msg.type === 'delta') {
173
+ if (pendDelta && (pendDelta.session !== msg.session || pendDelta.seq !== msg.seq)) flushDelta();
174
+ if (deltaWin) { if (pendDelta) pendDelta.text += msg.text; else pendDelta = { ...msg }; return; }
175
+ writeOut(msg);
176
+ deltaWin = setTimeout(() => { deltaWin = 0; flushDelta(); }, 40);
177
+ deltaWin.unref?.();
178
+ return;
179
+ }
180
+ flushDelta();
181
+ writeOut(msg);
166
182
  });
167
183
  setInterval(() => {
168
184
  for (const res of streams.keys()) res.write(': ping\n\n');
@@ -181,6 +197,7 @@ function createServer({ cfg, hub, auth, audit, getPublicUrl, getApp, routes = []
181
197
  return hit.r.handler({ req, res, url, params: hit.params, body, device });
182
198
  }
183
199
 
200
+ if (p === '/api/usage' && method === 'GET') return usage.all();
184
201
  if (p === '/api/info' && method === 'GET') {
185
202
  return {
186
203
  name: 'nio',
@@ -453,7 +470,7 @@ function createServer({ cfg, hub, auth, audit, getPublicUrl, getApp, routes = []
453
470
  connection: 'keep-alive',
454
471
  });
455
472
  req.socket?.setNoDelay(true);
456
- res.write(`data: ${JSON.stringify({ type: 'hello', sessions: hub.list(), approvals: hub.pendingApprovals() })}\n\n`);
473
+ res.write(`data: ${JSON.stringify({ type: 'hello', sessions: hub.list(), approvals: hub.pendingApprovals(), usage: usage.all() })}\n\n`);
457
474
  const dev = String(url.searchParams.get('d') || '').slice(0, 40), kind = url.searchParams.get('k') === 'phone' ? 'phone' : 'desktop';
458
475
  if (dev && kind === 'phone' && Date.now() - (seenDevices.get(dev) || 0) > 10 * 60 * 1000) {
459
476
  const data = `data: ${JSON.stringify({ type: 'device', device: dev, kind })}\n\n`;
package/src/usage.js ADDED
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+ // Ús de la subscripció (finestra de 5 h i de la setmana). Claude el diu al flux (rate_limit_event);
3
+ // Codex el desa als seus registres (~/.codex/sessions). No es llegeix cap clau ni cap compte.
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { HOME } = require('./config');
8
+
9
+ const FILE = path.join(HOME, 'usage.json');
10
+ const CODEX_DIR = path.join(os.homedir(), '.codex', 'sessions');
11
+ const saved = {}; // agent -> { windows: [{ label, used, resetsAt }], at }
12
+ try { Object.assign(saved, JSON.parse(fs.readFileSync(FILE, 'utf8'))); } catch { /* encara res */ }
13
+
14
+ const LABELS = { five_hour: '5 h', seven_day: 'setmana' };
15
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
16
+ const label = (min) => (min === 300 ? '5 h' : min === 10080 ? 'setmana' : min % 60 === 0 ? `${min / 60} h` : `${min} min`);
17
+
18
+ // Claude: { unifiedWindows: { five_hour: { utilization: 0..1, resetsAt: segons }, seven_day: … } }
19
+ function setClaude(info) {
20
+ const windows = [];
21
+ for (const [k, w] of Object.entries(info?.unifiedWindows || {})) {
22
+ const used = num(w?.utilization);
23
+ if (used === null || !LABELS[k]) continue;
24
+ windows.push({ label: LABELS[k], used: Math.min(1, Math.max(0, used)), resetsAt: num(w.resetsAt) });
25
+ }
26
+ if (!windows.length) return false;
27
+ windows.sort((a, b) => (a.label === '5 h' ? -1 : 1) - (b.label === '5 h' ? -1 : 1));
28
+ const prev = JSON.stringify(saved.claude?.windows);
29
+ saved.claude = { windows, at: Date.now() };
30
+ if (JSON.stringify(windows) === prev) return false;
31
+ fs.writeFile(FILE, JSON.stringify({ claude: saved.claude }), () => {});
32
+ return true;
33
+ }
34
+
35
+ // Codex: l'últim `token_count` de l'últim registre porta rate_limits.{primary,secondary}.
36
+ const desc = (dir) => { try { return fs.readdirSync(dir).sort().reverse(); } catch { return []; } };
37
+ function codexFiles(max = 3) {
38
+ const out = [];
39
+ for (const y of desc(CODEX_DIR)) for (const m of desc(path.join(CODEX_DIR, y))) for (const d of desc(path.join(CODEX_DIR, y, m))) {
40
+ for (const f of desc(path.join(CODEX_DIR, y, m, d))) { if (f.endsWith('.jsonl')) out.push(path.join(CODEX_DIR, y, m, d, f)); if (out.length >= max) return out; }
41
+ }
42
+ return out;
43
+ }
44
+ function tailLimits(file) {
45
+ let fd;
46
+ try {
47
+ fd = fs.openSync(file, 'r');
48
+ const size = fs.fstatSync(fd).size, len = Math.min(size, 256 * 1024), buf = Buffer.alloc(len);
49
+ fs.readSync(fd, buf, 0, len, size - len);
50
+ const lines = buf.toString('utf8').split('\n');
51
+ for (let i = lines.length - 1; i >= 0; i--) {
52
+ if (!lines[i].includes('"rate_limits"')) continue;
53
+ let o; try { o = JSON.parse(lines[i]); } catch { continue; }
54
+ const rl = o?.payload?.rate_limits; if (!rl) continue;
55
+ const windows = ['primary', 'secondary'].map((k) => rl[k]).filter(Boolean).map((w) => ({ label: label(w.window_minutes), used: Math.min(1, Math.max(0, (num(w.used_percent) || 0) / 100)), resetsAt: num(w.resets_at) }));
56
+ if (windows.length) return { windows, at: Date.parse(o.timestamp) || Date.now() };
57
+ }
58
+ } catch { /* sense registre */ } finally { if (fd !== undefined) fs.closeSync(fd); }
59
+ return null;
60
+ }
61
+ let codexCache = { t: 0, v: null };
62
+ function codex() {
63
+ if (Date.now() - codexCache.t < 5000) return codexCache.v;
64
+ let v = null;
65
+ for (const f of codexFiles()) if ((v = tailLimits(f))) break;
66
+ codexCache = { t: Date.now(), v };
67
+ return v;
68
+ }
69
+
70
+ // Una finestra que ja s'ha reiniciat torna a 0 fins que l'agent n'informi de nou.
71
+ const fresh = (u) => u && { windows: u.windows.map((w) => (w.resetsAt && w.resetsAt * 1000 <= Date.now() ? { label: w.label, used: 0, resetsAt: null } : w)), at: u.at };
72
+ function all() {
73
+ const out = {}, c = fresh(saved.claude), x = fresh(codex());
74
+ if (c) out.claude = c;
75
+ if (x) out.codex = x;
76
+ return out;
77
+ }
78
+
79
+ module.exports = { setClaude, all };