nio-cat 0.9.6 → 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
@@ -14,6 +14,7 @@ Instal·la nio i `cloudflared` al teu usuari, el deixa com a servei (systemd o l
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
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.
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');
@@ -213,7 +214,8 @@ async function start() {
213
214
  if (cfg.telegram?.token) row('Telegram', 'actiu', `(${(cfg.telegram.allowed || []).length} xats autoritzats)`);
214
215
  const control = new Control({ cfg, hub, audit });
215
216
  const remote = new Remote({ cfg, audit });
216
- 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()];
217
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 });
218
220
 
219
221
  server.on('error', (e) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.9.6",
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/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 };