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 +1 -0
- package/bin/nio.js +3 -1
- 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/server.js +19 -2
- package/src/usage.js +79 -0
- package/web/app/app.js +194 -43
- package/web/app/demo.js +202 -10
- package/web/app/index.html +3 -2
- package/web/app/notes.css +154 -0
- package/web/app/notes.js +504 -0
- package/web/app/style.css +26 -0
- package/web/index.html +7 -2
- package/web/sw.js +2 -2
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
|
|
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
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/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 };
|