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 +3 -2
- package/bin/nio.js +8 -13
- package/package.json +1 -1
- package/src/agents/claude.js +4 -1
- package/src/agents/ollama.js +3 -2
- package/src/hub.js +2 -0
- package/src/notes.js +293 -0
- package/src/pair.js +3 -50
- package/src/server.js +19 -2
- package/src/usage.js +79 -0
- package/src/voice.js +21 -3
- package/src/whisper.js +148 -0
- package/web/app/app.js +256 -53
- package/web/app/demo.js +413 -0
- package/web/app/index.html +4 -2
- package/web/app/notes.css +154 -0
- package/web/app/notes.js +504 -0
- package/web/app/style.css +32 -0
- package/web/index.html +8 -3
- package/web/sw.js +2 -2
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
|
|
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
|
|
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,
|
|
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
|
|
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 (
|
|
121
|
-
|
|
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
|
|
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
|
|
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
package/src/agents/claude.js
CHANGED
|
@@ -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;
|
|
117
|
+
break;
|
|
115
118
|
}
|
|
116
119
|
}
|
package/src/agents/ollama.js
CHANGED
|
@@ -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.
|
|
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>
|
|
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,
|
|
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
|
-
|
|
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 };
|