nio-cat 0.5.1 → 0.6.1
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/bin/nio.js +150 -9
- package/package.json +2 -1
- package/src/audit.js +55 -0
- package/src/auth.js +175 -0
- package/src/directory.js +56 -0
- package/src/hub.js +33 -12
- package/src/jobs.js +287 -0
- package/src/mcp.js +142 -0
- package/src/memory.js +131 -0
- package/src/proc.js +1 -1
- package/src/push.js +52 -18
- package/src/server.js +93 -31
- package/src/service.js +123 -0
- package/src/tunnel.js +150 -34
- package/web/app/app.js +534 -44
- package/web/app/style.css +63 -7
- package/web/board.css +3 -2
- package/web/board.js +4 -3
- package/web/sw.js +13 -9
package/src/jobs.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Orquestrador lleuger: cua de feines, cadenes i programació. No fa la feina: obre sessions de
|
|
3
|
+
// Claude Code o Codex (via el hub) i les va seguint. Tot es desa a ~/.nio/jobs.json.
|
|
4
|
+
//
|
|
5
|
+
// Estats: pendent → en curs → feta | fallada (o cancel·lada).
|
|
6
|
+
// · Cadena: `after` = id d'una altra feina. Comença quan aquella acaba bé (o sempre, amb `always`).
|
|
7
|
+
// El text «{{anterior}}» del missatge es canvia per l'última resposta de la feina anterior.
|
|
8
|
+
// · Programació: `at` (una vegada) o `cron` (periòdica, 5 camps). Una programació periòdica és una
|
|
9
|
+
// plantilla: cada vegada que toca, crea una feina nova a la cua.
|
|
10
|
+
// · Encaminament: amb agent «auto», la primera regla de cfg.routing que coincideix tria l'agent.
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const crypto = require('crypto');
|
|
14
|
+
const { HOME } = require('./config');
|
|
15
|
+
|
|
16
|
+
const FILE = path.join(HOME, 'jobs.json');
|
|
17
|
+
const ACTIVE = new Set(['pendent', 'en curs']);
|
|
18
|
+
const rid = () => crypto.randomBytes(6).toString('base64url');
|
|
19
|
+
const httpErr = (status, message) => Object.assign(new Error(message), { status });
|
|
20
|
+
|
|
21
|
+
// ---------- cron (minut hora dia-del-mes mes dia-de-la-setmana)
|
|
22
|
+
function field(spec, min, max) {
|
|
23
|
+
const out = new Set();
|
|
24
|
+
for (const part of String(spec).split(',')) {
|
|
25
|
+
const m = part.match(/^(\*|(\d+)(?:-(\d+))?)(?:\/(\d+))?$/);
|
|
26
|
+
if (!m) throw httpErr(400, `Programació no vàlida: «${part}»`);
|
|
27
|
+
let lo = m[1] === '*' ? min : Number(m[2]);
|
|
28
|
+
let hi = m[1] === '*' ? max : m[3] != null ? Number(m[3]) : m[4] ? max : lo;
|
|
29
|
+
const step = m[4] ? Number(m[4]) : 1;
|
|
30
|
+
if (lo < min || hi > max || lo > hi || step < 1) throw httpErr(400, `Programació fora de límits: «${part}»`);
|
|
31
|
+
for (let v = lo; v <= hi; v += step) out.add(v);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
function parseCron(expr) {
|
|
36
|
+
const f = String(expr || '').trim().split(/\s+/);
|
|
37
|
+
if (f.length !== 5) throw httpErr(400, 'La programació ha de tenir 5 camps: minut hora dia mes dia-de-la-setmana');
|
|
38
|
+
const dow = field(f[4], 0, 7);
|
|
39
|
+
if (dow.has(7)) dow.add(0);
|
|
40
|
+
return { min: field(f[0], 0, 59), hour: field(f[1], 0, 23), dom: field(f[2], 1, 31), mon: field(f[3], 1, 12), dow, domAny: f[2] === '*', dowAny: f[4] === '*' };
|
|
41
|
+
}
|
|
42
|
+
// Pròxima hora (hora local de l'ordinador) estrictament posterior a `from`.
|
|
43
|
+
function nextCron(expr, from = Date.now()) {
|
|
44
|
+
const c = typeof expr === 'string' ? parseCron(expr) : expr;
|
|
45
|
+
const d = new Date(from);
|
|
46
|
+
d.setSeconds(0, 0);
|
|
47
|
+
d.setMinutes(d.getMinutes() + 1);
|
|
48
|
+
for (let i = 0; i < 60 * 24 * 366; i++) {
|
|
49
|
+
const dayOk = c.domAny && c.dowAny ? true : c.domAny ? c.dow.has(d.getDay()) : c.dowAny ? c.dom.has(d.getDate()) : c.dom.has(d.getDate()) || c.dow.has(d.getDay());
|
|
50
|
+
if (!c.mon.has(d.getMonth() + 1) || !dayOk) { d.setHours(24, 0, 0, 0); continue; }
|
|
51
|
+
if (!c.hour.has(d.getHours())) { d.setHours(d.getHours() + 1, 0, 0, 0); continue; }
|
|
52
|
+
if (c.min.has(d.getMinutes())) return d.getTime();
|
|
53
|
+
d.setMinutes(d.getMinutes() + 1);
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------- encaminament
|
|
59
|
+
function route(cfg, prompt, available) {
|
|
60
|
+
for (const r of cfg.routing || []) {
|
|
61
|
+
try {
|
|
62
|
+
if (r.agent && available.includes(r.agent) && new RegExp(r.match, 'i').test(prompt)) return { agent: r.agent, cwd: r.cwd, rule: r.match };
|
|
63
|
+
} catch {
|
|
64
|
+
/* regla mal escrita: la saltem */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const def = ['claude', 'codex'].find((a) => available.includes(a)) || available[0];
|
|
68
|
+
return { agent: def, rule: null };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
class Jobs {
|
|
72
|
+
constructor({ cfg, hub, audit, available = () => ['claude', 'codex'], file = FILE }) {
|
|
73
|
+
this.cfg = cfg;
|
|
74
|
+
this.hub = hub;
|
|
75
|
+
this.audit = audit || { add() {} };
|
|
76
|
+
this.available = available;
|
|
77
|
+
this.file = file;
|
|
78
|
+
this.jobs = [];
|
|
79
|
+
try {
|
|
80
|
+
this.jobs = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
81
|
+
} catch {
|
|
82
|
+
/* primer cop */
|
|
83
|
+
}
|
|
84
|
+
// Si nio s'ha reiniciat amb feines en curs, la sessió ja no corre: les donem per fallades.
|
|
85
|
+
for (const j of this.jobs) if (j.state === 'en curs') this.finish(j, 'fallada', 'nio s’ha reiniciat mentre treballava', true);
|
|
86
|
+
hub.on('out', (m) => this.onHub(m));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
save() {
|
|
90
|
+
const tmp = `${this.file}.tmp`;
|
|
91
|
+
fs.writeFileSync(tmp, JSON.stringify(this.jobs.slice(-500), null, 2), { mode: 0o600 });
|
|
92
|
+
fs.renameSync(tmp, this.file);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
emit(j) {
|
|
96
|
+
this.hub.emit('out', { type: 'job', job: j });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
list() {
|
|
100
|
+
return [...this.jobs].sort((a, b) => b.created - a.created);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
get(id) {
|
|
104
|
+
return this.jobs.find((j) => j.id === id);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
add(b) {
|
|
108
|
+
const prompt = String(b.prompt || '').trim();
|
|
109
|
+
if (!prompt) throw httpErr(400, 'Explica què ha de fer la feina');
|
|
110
|
+
const avail = this.available();
|
|
111
|
+
let agent = b.agent || 'auto', cwd = b.cwd, rule = null;
|
|
112
|
+
if (agent === 'auto') {
|
|
113
|
+
const r = route(this.cfg, prompt, avail);
|
|
114
|
+
agent = r.agent;
|
|
115
|
+
cwd = cwd || r.cwd;
|
|
116
|
+
rule = r.rule;
|
|
117
|
+
}
|
|
118
|
+
if (!['claude', 'codex', 'demo', 'grok', 'antigravity', 'ollama'].includes(agent)) throw httpErr(400, 'Tria Claude Code, Codex o «automàtic»');
|
|
119
|
+
if (!cwd) cwd = this.cfg.roots?.[0];
|
|
120
|
+
if (!cwd || !fs.existsSync(cwd)) throw httpErr(400, 'Aquesta carpeta no existeix');
|
|
121
|
+
if (b.after && !this.get(b.after)) throw httpErr(400, 'La feina anterior no existeix');
|
|
122
|
+
let at = b.at ? Number(new Date(b.at)) : null;
|
|
123
|
+
if (b.at && !Number.isFinite(at)) throw httpErr(400, 'La data no és vàlida');
|
|
124
|
+
if (b.cron) parseCron(b.cron);
|
|
125
|
+
const j = {
|
|
126
|
+
id: rid(),
|
|
127
|
+
title: String(b.title || prompt.replace(/\s+/g, ' ').slice(0, 70)),
|
|
128
|
+
prompt: prompt.slice(0, 20000),
|
|
129
|
+
agent,
|
|
130
|
+
cwd: path.resolve(cwd),
|
|
131
|
+
mode: ['llegir', 'preguntar', 'tot'].includes(b.mode) ? b.mode : 'preguntar',
|
|
132
|
+
model: String(b.model || '').slice(0, 80),
|
|
133
|
+
rule,
|
|
134
|
+
after: b.after || null,
|
|
135
|
+
always: !!b.always,
|
|
136
|
+
cron: b.cron || null,
|
|
137
|
+
state: b.cron ? 'programada' : 'pendent',
|
|
138
|
+
runAt: b.cron ? nextCron(b.cron) : at,
|
|
139
|
+
created: Date.now(),
|
|
140
|
+
};
|
|
141
|
+
this.jobs.push(j);
|
|
142
|
+
this.save();
|
|
143
|
+
this.emit(j);
|
|
144
|
+
this.audit.add({ type: 'job', what: `Feina ${j.cron ? 'programada' : 'a la cua'}: ${j.title}`, agent: j.agent, why: rule ? `Regla «${rule}»` : undefined, job: j.id });
|
|
145
|
+
this.tick();
|
|
146
|
+
return j;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
cancel(id) {
|
|
150
|
+
const j = this.get(id);
|
|
151
|
+
if (!j) throw httpErr(404, 'Aquesta feina no existeix');
|
|
152
|
+
if (j.state === 'en curs' && j.session) this.hub.stop(j.session);
|
|
153
|
+
if (j.state === 'programada' || ACTIVE.has(j.state)) this.finish(j, 'cancel·lada', 'L’has cancel·lada');
|
|
154
|
+
return j;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
remove(id) {
|
|
158
|
+
const j = this.get(id);
|
|
159
|
+
if (!j) throw httpErr(404, 'Aquesta feina no existeix');
|
|
160
|
+
if (ACTIVE.has(j.state) || j.state === 'programada') this.cancel(id);
|
|
161
|
+
this.jobs = this.jobs.filter((x) => x.id !== id);
|
|
162
|
+
this.save();
|
|
163
|
+
this.hub.emit('out', { type: 'job', removed: id });
|
|
164
|
+
return { ok: true };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
runNow(id) {
|
|
168
|
+
const j = this.get(id);
|
|
169
|
+
if (!j) throw httpErr(404, 'Aquesta feina no existeix');
|
|
170
|
+
if (j.state === 'programada') return this.spawnFrom(j);
|
|
171
|
+
if (j.state !== 'pendent') throw httpErr(409, 'Aquesta feina ja ha començat');
|
|
172
|
+
j.runAt = null;
|
|
173
|
+
j.after = null;
|
|
174
|
+
this.tick();
|
|
175
|
+
return j;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Una programació periòdica crea una feina nova cada vegada que toca.
|
|
179
|
+
spawnFrom(t) {
|
|
180
|
+
const j = { ...t, id: rid(), cron: null, from: t.id, state: 'pendent', runAt: null, created: Date.now() };
|
|
181
|
+
this.jobs.push(j);
|
|
182
|
+
this.emit(j);
|
|
183
|
+
this.save();
|
|
184
|
+
this.tick();
|
|
185
|
+
return j;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
tick(now = Date.now()) {
|
|
189
|
+
let changed = false;
|
|
190
|
+
for (const t of this.jobs) {
|
|
191
|
+
if (t.state === 'programada' && t.runAt && t.runAt <= now) {
|
|
192
|
+
t.runAt = nextCron(t.cron, now);
|
|
193
|
+
t.lastFired = now;
|
|
194
|
+
this.spawnFrom(t);
|
|
195
|
+
changed = true;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const running = this.jobs.filter((j) => j.state === 'en curs').length;
|
|
199
|
+
let free = Math.max(0, (this.cfg.jobs?.max || 2) - running);
|
|
200
|
+
for (const j of this.jobs) {
|
|
201
|
+
if (j.state !== 'pendent' || free <= 0) continue;
|
|
202
|
+
if (j.runAt && j.runAt > now) continue;
|
|
203
|
+
if (j.after) {
|
|
204
|
+
const p = this.get(j.after);
|
|
205
|
+
if (!p || p.state === 'cancel·lada' || (p.state === 'fallada' && !j.always)) {
|
|
206
|
+
this.finish(j, 'cancel·lada', p ? `La feina anterior (${p.title}) no ha acabat bé` : 'La feina anterior ja no hi és');
|
|
207
|
+
changed = true;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (p.state !== 'feta' && p.state !== 'fallada') continue;
|
|
211
|
+
}
|
|
212
|
+
this.start(j);
|
|
213
|
+
free--;
|
|
214
|
+
changed = true;
|
|
215
|
+
}
|
|
216
|
+
if (changed) this.save();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
start(j) {
|
|
220
|
+
const prev = j.after ? this.get(j.after) : null;
|
|
221
|
+
const text = j.prompt.replace(/\{\{\s*anterior\s*\}\}/g, prev?.result || '(la feina anterior no ha deixat cap resposta)');
|
|
222
|
+
try {
|
|
223
|
+
const s = this.hub.create({ agent: j.agent, cwd: j.cwd, title: `⚙ ${j.title}`, mode: j.mode, model: j.model });
|
|
224
|
+
j.session = s.id;
|
|
225
|
+
j.state = 'en curs';
|
|
226
|
+
j.started = Date.now();
|
|
227
|
+
this.emit(j);
|
|
228
|
+
this.hub.send(s.id, text, this.hookUrl ? this.hookUrl() : undefined);
|
|
229
|
+
} catch (e) {
|
|
230
|
+
this.finish(j, 'fallada', e.message);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
onHub(m) {
|
|
235
|
+
if (m.type !== 'event' || m.event?.kind !== 'status') return;
|
|
236
|
+
const j = this.jobs.find((x) => x.session === m.session && x.state === 'en curs');
|
|
237
|
+
if (!j || m.event.state === 'running') return;
|
|
238
|
+
const s = this.hub.get(m.session);
|
|
239
|
+
const last = s ? [...s.events].reverse().find((e) => e.kind === 'text') : null;
|
|
240
|
+
j.result = last ? String(last.text).slice(0, 8000) : '';
|
|
241
|
+
if (m.event.state === 'error') this.finish(j, 'fallada', s ? [...s.events].reverse().find((e) => e.kind === 'error')?.text : 'Error');
|
|
242
|
+
else if (m.event.code === 130 || m.event.code === 143) this.finish(j, 'cancel·lada', 'L’has aturat');
|
|
243
|
+
else this.finish(j, 'feta');
|
|
244
|
+
setImmediate(() => this.tick());
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
finish(j, state, error, quiet) {
|
|
248
|
+
j.state = state;
|
|
249
|
+
j.finished = Date.now();
|
|
250
|
+
if (error) j.error = String(error).slice(0, 500);
|
|
251
|
+
this.emit(j);
|
|
252
|
+
this.save();
|
|
253
|
+
if (quiet) return;
|
|
254
|
+
this.audit.add({ type: 'job', what: `${state === 'feta' ? 'Feina feta' : state === 'fallada' ? 'Feina fallada' : 'Feina cancel·lada'}: ${j.title}`, agent: j.agent, why: j.error, session: j.session, job: j.id });
|
|
255
|
+
if (state !== 'cancel·lada') this.hub.notice(this.hub.get(j.session), state === 'feta' ? 'Feina feta' : 'Una feina ha fallat', j.title, { job: j.id });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
routes() {
|
|
259
|
+
return [
|
|
260
|
+
{ method: 'GET', path: '/api/jobs', handler: () => ({ jobs: this.list(), routing: this.cfg.routing || [], max: this.cfg.jobs?.max || 2 }) },
|
|
261
|
+
{ method: 'POST', path: '/api/jobs', handler: ({ body }) => this.add(body) },
|
|
262
|
+
{ method: 'POST', path: /^\/api\/jobs\/([\w-]+)\/run$/, handler: ({ params }) => this.runNow(params[0]) },
|
|
263
|
+
{ method: 'POST', path: /^\/api\/jobs\/([\w-]+)\/cancel$/, handler: ({ params }) => this.cancel(params[0]) },
|
|
264
|
+
{ method: 'DELETE', path: /^\/api\/jobs\/([\w-]+)$/, handler: ({ params }) => this.remove(params[0]) },
|
|
265
|
+
{
|
|
266
|
+
method: 'PUT',
|
|
267
|
+
path: '/api/jobs/routing',
|
|
268
|
+
handler: ({ body }) => {
|
|
269
|
+
const rules = (Array.isArray(body.rules) ? body.rules : []).slice(0, 50).map((r) => ({ match: String(r.match || '').slice(0, 200), agent: String(r.agent || ''), cwd: r.cwd ? String(r.cwd) : undefined }));
|
|
270
|
+
for (const r of rules) {
|
|
271
|
+
try { new RegExp(r.match, 'i'); } catch { throw httpErr(400, `Regla no vàlida: «${r.match}»`); }
|
|
272
|
+
}
|
|
273
|
+
this.cfg.routing = rules;
|
|
274
|
+
require('./config').save(this.cfg);
|
|
275
|
+
return { routing: rules };
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
startTimer() {
|
|
282
|
+
this.timer = setInterval(() => this.tick(), 15000);
|
|
283
|
+
this.timer.unref();
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
module.exports = { Jobs, parseCron, nextCron, route };
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Servidor MCP (stdio) de nio: la memòria compartida per a Claude Code i Codex.
|
|
3
|
+
// Claude Code i Codex l'engeguen amb `nio mcp`; parla JSON-RPC 2.0, un missatge per línia.
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { spawnSync } = require('child_process');
|
|
8
|
+
const memory = require('./memory');
|
|
9
|
+
|
|
10
|
+
const TOOLS = [
|
|
11
|
+
{
|
|
12
|
+
name: 'memoria_llista',
|
|
13
|
+
description: 'Llista els fitxers de la memòria compartida de nio (markdown). Fes-la servir per saber què hi ha abans de llegir o escriure.',
|
|
14
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
15
|
+
run: () => {
|
|
16
|
+
const files = memory.list();
|
|
17
|
+
return files.length ? files.map((f) => `${f.path} (${f.size} B)`).join('\n') : 'La memòria és buida.';
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: 'memoria_llegeix',
|
|
22
|
+
description: 'Llegeix un fitxer de la memòria compartida, p. ex. «preferencies.md» o «projectes/web.md».',
|
|
23
|
+
inputSchema: { type: 'object', properties: { fitxer: { type: 'string', description: 'Ruta relativa, acabada en .md' } }, required: ['fitxer'], additionalProperties: false },
|
|
24
|
+
run: ({ fitxer }) => memory.read(fitxer).content,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: 'memoria_cerca',
|
|
28
|
+
description: 'Busca text a tota la memòria compartida (no distingeix majúscules ni accents). Retorna fitxer, línia i text.',
|
|
29
|
+
inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'], additionalProperties: false },
|
|
30
|
+
run: ({ text }) => {
|
|
31
|
+
const hits = memory.search(text);
|
|
32
|
+
return hits.length ? hits.map((h) => `${h.path}:${h.line}: ${h.text}`).join('\n') : 'Cap resultat.';
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'memoria_escriu',
|
|
37
|
+
description: 'Desa informació útil per a més endavant a la memòria compartida (la llegiran també els altres agents). Per defecte afegeix al final; amb mode «substitueix» reescriu el fitxer sencer. Desa-hi fets durables (preferències, decisions, context de projectes), no converses.',
|
|
38
|
+
inputSchema: {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
fitxer: { type: 'string', description: 'Ruta relativa acabada en .md, p. ex. «projectes/botiga.md»' },
|
|
42
|
+
text: { type: 'string' },
|
|
43
|
+
mode: { type: 'string', enum: ['afegeix', 'substitueix'], default: 'afegeix' },
|
|
44
|
+
},
|
|
45
|
+
required: ['fitxer', 'text'],
|
|
46
|
+
additionalProperties: false,
|
|
47
|
+
},
|
|
48
|
+
run: ({ fitxer, text, mode }) => {
|
|
49
|
+
const r = memory.write(fitxer, text, { append: mode !== 'substitueix' });
|
|
50
|
+
return `Desat a ${r.path} (${r.size} B).`;
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
function serve({ input = process.stdin, output = process.stdout } = {}) {
|
|
56
|
+
const send = (msg) => output.write(`${JSON.stringify({ jsonrpc: '2.0', ...msg })}\n`);
|
|
57
|
+
const handle = (msg) => {
|
|
58
|
+
const { id, method, params } = msg;
|
|
59
|
+
const reply = (result) => id !== undefined && send({ id, result });
|
|
60
|
+
const fail = (code, message) => id !== undefined && send({ id, error: { code, message } });
|
|
61
|
+
switch (method) {
|
|
62
|
+
case 'initialize':
|
|
63
|
+
return reply({
|
|
64
|
+
protocolVersion: params?.protocolVersion || '2025-06-18',
|
|
65
|
+
capabilities: { tools: {} },
|
|
66
|
+
serverInfo: { name: 'nio', version: require('../package.json').version },
|
|
67
|
+
instructions: 'Memòria compartida de nio (fitxers markdown en aquest ordinador). Consulta-la a l’inici d’una tasca (memoria_cerca o memoria_llegeix preferencies.md) i desa-hi el que valgui la pena recordar.',
|
|
68
|
+
});
|
|
69
|
+
case 'ping':
|
|
70
|
+
return reply({});
|
|
71
|
+
case 'tools/list':
|
|
72
|
+
return reply({ tools: TOOLS.map(({ run, ...t }) => t) });
|
|
73
|
+
case 'tools/call': {
|
|
74
|
+
const tool = TOOLS.find((t) => t.name === params?.name);
|
|
75
|
+
if (!tool) return fail(-32602, `Eina desconeguda: ${params?.name}`);
|
|
76
|
+
try {
|
|
77
|
+
return reply({ content: [{ type: 'text', text: String(tool.run(params.arguments || {})) }] });
|
|
78
|
+
} catch (e) {
|
|
79
|
+
return reply({ content: [{ type: 'text', text: e.message }], isError: true });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
default:
|
|
83
|
+
if (method && method.startsWith('notifications/')) return undefined;
|
|
84
|
+
return fail(-32601, `Mètode desconegut: ${method}`);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
let buf = '';
|
|
88
|
+
input.setEncoding('utf8');
|
|
89
|
+
input.on('data', (d) => {
|
|
90
|
+
buf += d;
|
|
91
|
+
let i;
|
|
92
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
93
|
+
const line = buf.slice(0, i).trim();
|
|
94
|
+
buf = buf.slice(i + 1);
|
|
95
|
+
if (!line) continue;
|
|
96
|
+
let msg;
|
|
97
|
+
try {
|
|
98
|
+
msg = JSON.parse(line);
|
|
99
|
+
} catch {
|
|
100
|
+
send({ id: null, error: { code: -32700, message: 'JSON no vàlid' } });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
for (const m of Array.isArray(msg) ? msg : [msg]) handle(m);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------- registre a Claude Code i Codex (un sol cop, sense duplicar)
|
|
109
|
+
function command() {
|
|
110
|
+
const entry = fs.realpathSync(path.join(__dirname, '..', 'bin', 'nio.js'));
|
|
111
|
+
return { cmd: process.execPath, args: [entry, 'mcp'], unstable: entry.includes(`${path.sep}_npx${path.sep}`) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function registerClaude(bin, { force } = {}) {
|
|
115
|
+
if (!bin) return 'Claude Code no hi és';
|
|
116
|
+
const { cmd, args } = command();
|
|
117
|
+
if (!force && spawnSync(bin, ['mcp', 'get', 'nio'], { encoding: 'utf8', timeout: 15000 }).status === 0) return 'ja hi era';
|
|
118
|
+
spawnSync(bin, ['mcp', 'remove', '--scope', 'user', 'nio'], { timeout: 15000 });
|
|
119
|
+
const env = process.env.NIO_HOME ? ['-e', `NIO_HOME=${process.env.NIO_HOME}`] : [];
|
|
120
|
+
const r = spawnSync(bin, ['mcp', 'add', 'nio', '--scope', 'user', ...env, '--', cmd, ...args], { encoding: 'utf8', timeout: 15000 });
|
|
121
|
+
return r.status === 0 ? 'afegit' : `no s’ha pogut afegir: ${(r.stderr || r.stdout || '').trim().split('\n').pop()}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function registerCodex() {
|
|
125
|
+
const dir = path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'));
|
|
126
|
+
const file = path.join(dir, 'config.toml');
|
|
127
|
+
let txt = '';
|
|
128
|
+
try {
|
|
129
|
+
txt = fs.readFileSync(file, 'utf8');
|
|
130
|
+
} catch {
|
|
131
|
+
/* encara no hi és */
|
|
132
|
+
}
|
|
133
|
+
if (/^\s*\[mcp_servers\.nio\]/m.test(txt)) return 'ja hi era';
|
|
134
|
+
const { cmd, args } = command();
|
|
135
|
+
const q = (s) => JSON.stringify(s);
|
|
136
|
+
const block = `\n# Memòria compartida de nio (afegit per nio; ho pots treure quan vulguis)\n[mcp_servers.nio]\ncommand = ${q(cmd)}\nargs = [${args.map(q).join(', ')}]\n${process.env.NIO_HOME ? `env = { NIO_HOME = ${q(process.env.NIO_HOME)} }\n` : ''}`;
|
|
137
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
138
|
+
fs.appendFileSync(file, `${txt && !txt.endsWith('\n') ? '\n' : ''}${block}`);
|
|
139
|
+
return 'afegit';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { serve, TOOLS, registerClaude, registerCodex, command };
|
package/src/memory.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Memòria compartida: fitxers markdown a ~/.nio/memory, només en aquest ordinador.
|
|
3
|
+
// Claude Code i Codex hi accedeixen pel servidor MCP de nio (`nio mcp`), i tu des de l'app.
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { HOME } = require('./config');
|
|
7
|
+
|
|
8
|
+
const DIR = process.env.NIO_MEMORY || path.join(HOME, 'memory');
|
|
9
|
+
const MAX = 512 * 1024; // mida màxima d'un fitxer
|
|
10
|
+
|
|
11
|
+
const SEED = {
|
|
12
|
+
'LLEGEIX-ME.md': `# Memòria de nio
|
|
13
|
+
|
|
14
|
+
Aquesta carpeta és la memòria que comparteixen els teus agents (Claude Code i Codex) en aquest ordinador.
|
|
15
|
+
Són fitxers markdown normals: els pots editar aquí, des de l'app (Memòria) o amb qualsevol editor.
|
|
16
|
+
|
|
17
|
+
- \`preferencies.md\`: com t'agrada treballar (idioma, estil, eines…).
|
|
18
|
+
- \`projectes/\`: un fitxer per projecte, amb context que val la pena recordar.
|
|
19
|
+
- \`persones/\`: gent amb qui treballes i el que cal saber-ne.
|
|
20
|
+
|
|
21
|
+
nio no envia mai aquests fitxers enlloc.
|
|
22
|
+
`,
|
|
23
|
+
'preferencies.md': '# Preferències\n\n- Respon en català.\n',
|
|
24
|
+
'projectes/.keep': '',
|
|
25
|
+
'persones/.keep': '',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function httpErr(status, message) {
|
|
29
|
+
return Object.assign(new Error(message), { status });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ensure() {
|
|
33
|
+
if (fs.existsSync(DIR)) return;
|
|
34
|
+
fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
35
|
+
for (const [rel, txt] of Object.entries(SEED)) {
|
|
36
|
+
const f = path.join(DIR, rel);
|
|
37
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
38
|
+
fs.writeFileSync(f, txt);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Ruta relativa segura: només .md dins de la carpeta de memòria, sense «..» ni fitxers ocults.
|
|
43
|
+
function safe(rel) {
|
|
44
|
+
const r = String(rel || '').replace(/\\/g, '/').trim();
|
|
45
|
+
if (!r || r.length > 200) throw httpErr(400, 'Falta el nom del fitxer');
|
|
46
|
+
if (r.startsWith('/')) throw httpErr(400, 'Fes servir una ruta relativa a la memòria');
|
|
47
|
+
const parts = r.split('/');
|
|
48
|
+
if (parts.some((p) => !p || p === '.' || p === '..' || p.startsWith('.'))) throw httpErr(400, 'Aquest nom de fitxer no és vàlid');
|
|
49
|
+
if (!/\.md$/i.test(r)) throw httpErr(400, 'Els fitxers de memòria han d’acabar en .md');
|
|
50
|
+
if (!/^[\p{L}\p{N} _.,()'’-]+$/u.test(parts.join(''))) throw httpErr(400, 'Fes servir lletres, xifres, espais, guions i punts');
|
|
51
|
+
const abs = path.resolve(DIR, r);
|
|
52
|
+
if (!abs.startsWith(path.resolve(DIR) + path.sep)) throw httpErr(400, 'Aquest nom de fitxer no és vàlid');
|
|
53
|
+
return { rel: parts.join('/'), abs };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function list() {
|
|
57
|
+
ensure();
|
|
58
|
+
const out = [];
|
|
59
|
+
const walk = (dir, pre) => {
|
|
60
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
61
|
+
if (e.name.startsWith('.')) continue;
|
|
62
|
+
const rel = pre ? `${pre}/${e.name}` : e.name;
|
|
63
|
+
const abs = path.join(dir, e.name);
|
|
64
|
+
if (e.isDirectory()) walk(abs, rel);
|
|
65
|
+
else if (e.isFile() && /\.md$/i.test(e.name)) {
|
|
66
|
+
const st = fs.statSync(abs);
|
|
67
|
+
out.push({ path: rel, size: st.size, updated: st.mtimeMs });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
walk(DIR, '');
|
|
72
|
+
return out.sort((a, b) => a.path.localeCompare(b.path, 'ca'));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function read(rel) {
|
|
76
|
+
ensure();
|
|
77
|
+
const { rel: r, abs } = safe(rel);
|
|
78
|
+
if (!fs.existsSync(abs)) throw httpErr(404, `No hi ha cap fitxer «${r}» a la memòria`);
|
|
79
|
+
return { path: r, content: fs.readFileSync(abs, 'utf8'), updated: fs.statSync(abs).mtimeMs };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function write(rel, content, { append = false } = {}) {
|
|
83
|
+
ensure();
|
|
84
|
+
const { rel: r, abs } = safe(rel);
|
|
85
|
+
const text = String(content ?? '');
|
|
86
|
+
const prev = append && fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : '';
|
|
87
|
+
const next = append ? `${prev}${prev && !prev.endsWith('\n') ? '\n' : ''}${text}` : text;
|
|
88
|
+
if (Buffer.byteLength(next) > MAX) throw httpErr(413, 'El fitxer seria massa gran (màxim 512 kB)');
|
|
89
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true, mode: 0o700 });
|
|
90
|
+
fs.writeFileSync(abs, next.endsWith('\n') ? next : `${next}\n`, { mode: 0o600 });
|
|
91
|
+
return { path: r, size: Buffer.byteLength(next) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function remove(rel) {
|
|
95
|
+
ensure();
|
|
96
|
+
const { rel: r, abs } = safe(rel);
|
|
97
|
+
if (!fs.existsSync(abs)) throw httpErr(404, `No hi ha cap fitxer «${r}» a la memòria`);
|
|
98
|
+
fs.rmSync(abs);
|
|
99
|
+
return { path: r };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Cerca de text (sense majúscules ni accents) amb la línia on surt.
|
|
103
|
+
function search(q, limit = 40) {
|
|
104
|
+
const norm = (s) => String(s).toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
|
|
105
|
+
const terms = norm(q).split(/\s+/).filter(Boolean);
|
|
106
|
+
if (!terms.length) throw httpErr(400, 'Escriu què vols buscar');
|
|
107
|
+
const hits = [];
|
|
108
|
+
for (const f of list()) {
|
|
109
|
+
const lines = fs.readFileSync(path.join(DIR, f.path), 'utf8').split('\n');
|
|
110
|
+
const nameHit = terms.every((t) => norm(f.path).includes(t));
|
|
111
|
+
lines.forEach((line, i) => {
|
|
112
|
+
if (hits.length >= limit) return;
|
|
113
|
+
const n = norm(line);
|
|
114
|
+
if (terms.every((t) => n.includes(t)) || (nameHit && i === 0)) hits.push({ path: f.path, line: i + 1, text: line.slice(0, 300) });
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return hits;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function routes() {
|
|
121
|
+
const wrap = (fn) => fn;
|
|
122
|
+
return [
|
|
123
|
+
{ method: 'GET', path: '/api/memory', handler: wrap(() => ({ dir: DIR, files: list() })) },
|
|
124
|
+
{ method: 'GET', path: '/api/memory/search', handler: wrap(({ url }) => search(url.searchParams.get('q') || '')) },
|
|
125
|
+
{ method: 'GET', path: /^\/api\/memory\/f\/(.+)$/, handler: wrap(({ params }) => read(params[0])) },
|
|
126
|
+
{ method: 'PUT', path: /^\/api\/memory\/f\/(.+)$/, limit: MAX + 4096, handler: wrap(({ params, body }) => write(params[0], body.content)) },
|
|
127
|
+
{ method: 'DELETE', path: /^\/api\/memory\/f\/(.+)$/, handler: wrap(({ params }) => remove(params[0])) },
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = { DIR, ensure, safe, list, read, write, remove, search, routes };
|
package/src/proc.js
CHANGED
package/src/push.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// Web Push sense dependències ni servidors de tercers:
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// Web Push sense dependències ni servidors de tercers: VAPID (RFC 8292) i, si el navegador
|
|
3
|
+
// ens ha donat les claus de la subscripció, contingut xifrat de punta a punta (RFC 8291,
|
|
4
|
+
// aes128gcm). El servei de push d'Apple o Google només veu bytes xifrats.
|
|
5
|
+
// Sense claus (subscripcions antigues), s'envia un avís buit i el service worker
|
|
6
|
+
// demana el detall directament a aquest ordinador.
|
|
5
7
|
const crypto = require('crypto');
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
const b64 = (buf) => Buffer.from(buf).toString('base64url');
|
|
10
|
+
const unb64 = (s) => Buffer.from(String(s || ''), 'base64url');
|
|
11
|
+
const hmac = (key, data) => crypto.createHmac('sha256', key).update(data).digest();
|
|
10
12
|
|
|
11
13
|
function vapidHeader(vapid, endpoint) {
|
|
12
14
|
const aud = new URL(endpoint).origin;
|
|
@@ -20,21 +22,53 @@ function vapidHeader(vapid, endpoint) {
|
|
|
20
22
|
return `vapid t=${data}.${b64(sig)}, k=${vapid.publicKey}`;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
+
// RFC 8291. `test` permet fixar la clau efímera i la sal per comprovar-ho amb els vectors de l'RFC.
|
|
26
|
+
function encrypt(payload, { p256dh, auth }, test = {}) {
|
|
27
|
+
const uaPublic = unb64(p256dh);
|
|
28
|
+
const authSecret = unb64(auth);
|
|
29
|
+
if (uaPublic.length !== 65 || authSecret.length !== 16) throw new Error('Claus de subscripció no vàlides');
|
|
30
|
+
const ecdh = crypto.createECDH('prime256v1');
|
|
31
|
+
if (test.privateKey) ecdh.setPrivateKey(unb64(test.privateKey));
|
|
32
|
+
else ecdh.generateKeys();
|
|
33
|
+
const asPublic = ecdh.getPublicKey();
|
|
34
|
+
const shared = ecdh.computeSecret(uaPublic);
|
|
35
|
+
const salt = test.salt ? unb64(test.salt) : crypto.randomBytes(16);
|
|
36
|
+
|
|
37
|
+
const prkKey = hmac(authSecret, shared);
|
|
38
|
+
const keyInfo = Buffer.concat([Buffer.from('WebPush: info\0'), uaPublic, asPublic, Buffer.from([1])]);
|
|
39
|
+
const ikm = hmac(prkKey, keyInfo);
|
|
40
|
+
const prk = hmac(salt, ikm);
|
|
41
|
+
const cek = hmac(prk, Buffer.from('Content-Encoding: aes128gcm\0\x01')).subarray(0, 16);
|
|
42
|
+
const nonce = hmac(prk, Buffer.from('Content-Encoding: nonce\0\x01')).subarray(0, 12);
|
|
43
|
+
|
|
44
|
+
const plain = Buffer.concat([Buffer.from(payload), Buffer.from([2])]); // un sol registre, sense farciment
|
|
45
|
+
const cipher = crypto.createCipheriv('aes-128-gcm', cek, nonce);
|
|
46
|
+
const body = Buffer.concat([cipher.update(plain), cipher.final(), cipher.getAuthTag()]);
|
|
47
|
+
const rs = Buffer.alloc(4);
|
|
48
|
+
rs.writeUInt32BE(4096);
|
|
49
|
+
return Buffer.concat([salt, rs, Buffer.from([asPublic.length]), asPublic, body]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Envia l'avís a totes les subscripcions. Retorna els endpoints que ja no són vàlids.
|
|
53
|
+
async function notifyAll(cfg, message) {
|
|
25
54
|
const dead = [];
|
|
55
|
+
const json = message ? JSON.stringify(message).slice(0, 3000) : null;
|
|
26
56
|
await Promise.all(
|
|
27
57
|
(cfg.pushSubs || []).map(async (sub) => {
|
|
28
58
|
try {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
59
|
+
const headers = {
|
|
60
|
+
Authorization: vapidHeader(cfg.vapid, sub.endpoint),
|
|
61
|
+
TTL: '3600',
|
|
62
|
+
Urgency: 'high',
|
|
63
|
+
};
|
|
64
|
+
let body = null;
|
|
65
|
+
if (json && sub.keys?.p256dh && sub.keys?.auth) {
|
|
66
|
+
body = encrypt(json, sub.keys);
|
|
67
|
+
headers['Content-Encoding'] = 'aes128gcm';
|
|
68
|
+
headers['Content-Type'] = 'application/octet-stream';
|
|
69
|
+
}
|
|
70
|
+
headers['Content-Length'] = String(body ? body.length : 0);
|
|
71
|
+
const res = await fetch(sub.endpoint, { method: 'POST', headers, body });
|
|
38
72
|
if (res.status === 404 || res.status === 410) dead.push(sub.endpoint);
|
|
39
73
|
} catch {
|
|
40
74
|
/* sense xarxa: ho tornarem a provar al següent avís */
|
|
@@ -44,4 +78,4 @@ async function notifyAll(cfg) {
|
|
|
44
78
|
return dead;
|
|
45
79
|
}
|
|
46
80
|
|
|
47
|
-
module.exports = { notifyAll, vapidHeader };
|
|
81
|
+
module.exports = { notifyAll, vapidHeader, encrypt };
|