nio-cat 0.9.14 → 0.9.16

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 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 { Learner } = require('../src/learn');
17
18
  const { Notes } = require('../src/notes');
18
19
  const { Call } = require('../src/call');
19
20
  const mcp = require('../src/mcp');
@@ -217,7 +218,8 @@ async function start() {
217
218
  const remote = new Remote({ cfg, audit });
218
219
  const notes = new Notes({ cfg, hub });
219
220
  const call = new Call({ cfg, hub, audit, projects: () => listProjects(cfg), hookUrl: jobs.hookUrl, terminal: () => require('../src/terminal').listSessions(require('../src/agents').binPath(cfg, 'claude'), require('../src/proc').childEnv()) });
220
- const routes = [...memory.routes(), ...notes.routes(), ...call.routes(), ...jobs.routes(), ...voice.routes(cfg), ...tg.routes(), ...control.routes(), ...remote.routes()];
221
+ const learner = new Learner(hub);
222
+ const routes = [...learner.routes(), ...memory.routes(), ...notes.routes(), ...call.routes(), ...jobs.routes(), ...voice.routes(cfg), ...tg.routes(), ...control.routes(), ...remote.routes()];
221
223
  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 });
222
224
 
223
225
  server.on('error', (e) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.9.14",
3
+ "version": "0.9.16",
4
4
  "description": "nio: fes servir Claude Code, Codex, Grok, Antigravity i Ollama del teu ordinador des del mòbil.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "f.cat",
@@ -5,6 +5,10 @@ const path = require('path');
5
5
  const { run, tryJson } = require('../proc');
6
6
 
7
7
  const HOOK = path.join(__dirname, '..', 'claude-hook.js');
8
+ // L'ordre del hook la llança la shell de Claude Code (a Windows, Git Bash): el Node d'aquest mateix procés i
9
+ // barres «/», que entenen totes les shells.
10
+ const slash = (p) => (process.platform === 'win32' ? p.replace(/\\/g, '/') : p);
11
+ const HOOK_CMD = `"${slash(process.execPath)}" "${slash(HOOK)}"`;
8
12
 
9
13
  module.exports = {
10
14
  id: 'claude',
@@ -21,7 +25,9 @@ module.exports = {
21
25
  if (session.nativeId && session.forkNative) { args.push('--fork-session'); delete session.forkNative; }
22
26
  if (session.model) args.push('--model', session.model);
23
27
  // Que l'agent posi un títol que resumeixi la feina (eina MCP de nio; el hub el desa).
24
- args.push('--append-system-prompt', 'Treballes dins una sessió de nio. Quan entenguis la tasca, crida l’eina mcp__nio__sessio_titol amb un títol de 3-7 paraules que la resumeixi, en l’idioma de l’usuari; torna-la a cridar només si la feina canvia de tema.');
28
+ // A la primera crida, també el que ja recorda la memòria compartida d'aquest projecte.
29
+ const mem = session.nativeId || cfg.memory?.context === false ? '' : require('../learn').context(session.cwd);
30
+ args.push('--append-system-prompt', `Treballes dins una sessió de nio. Quan entenguis la tasca, crida l’eina mcp__nio__sessio_titol amb un títol de 3-7 paraules que la resumeixi, en l’idioma de l’usuari; torna-la a cridar només si la feina canvia de tema.${mem ? `\n\n${mem}` : ''}`);
25
31
 
26
32
  // Com a root (un VPS), Claude Code no accepta bypassPermissions: «Via lliure» hi fa servir el mode auto,
27
33
  // que també treballa sol però fa revisar cada acció a un classificador (el mateix que el Claude Code remot).
@@ -30,7 +36,7 @@ module.exports = {
30
36
  const env = {};
31
37
  const gate = (extra = {}) => {
32
38
  // Hook PreToolUse: envia al mòbil el que cal aprovar.
33
- args.push('--settings', JSON.stringify({ hooks: { PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: `node "${HOOK}"`, timeout: 900 }] }] } }));
39
+ args.push('--settings', JSON.stringify({ hooks: { PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: HOOK_CMD, timeout: 900 }] }] } }));
34
40
  Object.assign(env, { NIO_HOOK_URL: ctx.hook.url, NIO_HOOK_KEY: ctx.hook.key, NIO_SESSION: session.id }, extra);
35
41
  };
36
42
  const control = cfg.control?.navegador || cfg.control?.escriptori;
@@ -47,7 +53,7 @@ module.exports = {
47
53
  PreToolUse: [
48
54
  {
49
55
  matcher: '*',
50
- hooks: [{ type: 'command', command: `node "${HOOK}"`, timeout: 900 }],
56
+ hooks: [{ type: 'command', command: HOOK_CMD, timeout: 900 }],
51
57
  },
52
58
  ],
53
59
  },
@@ -19,7 +19,9 @@ module.exports = {
19
19
  if (session.model) args.push('--model', session.model);
20
20
  for (const a of (cfg.agents?.codex?.extraArgs || [])) args.push(a);
21
21
  if (session.nativeId) args.push('resume', session.nativeId);
22
- args.push(prompt);
22
+ // Codex no té indicacions de sistema en mode exec: el context de la memòria va davant del primer missatge.
23
+ const mem = session.nativeId || cfg.memory?.context === false ? '' : require('../learn').context(session.cwd);
24
+ args.push(mem ? `${mem}\n\n---\n\n${prompt}` : prompt);
23
25
 
24
26
  const started = new Set();
25
27
  return run({
package/src/control.js CHANGED
@@ -10,7 +10,7 @@ const os = require('os');
10
10
  const path = require('path');
11
11
  const crypto = require('crypto');
12
12
  const { spawn } = require('child_process');
13
- const { which, childEnv } = require('./proc');
13
+ const { which, childEnv, command, killTree } = require('./proc');
14
14
  const config = require('./config');
15
15
  const { HOME } = config;
16
16
 
@@ -61,11 +61,13 @@ function run(bin, args, timeout = 15000) {
61
61
  resolve({ ok, out, err: err || why || '', timedOut: why === 'temps' });
62
62
  };
63
63
  const timer = setTimeout(() => {
64
- try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { /* ja no hi és */ } }
64
+ if (process.platform === 'win32') killTree(child?.pid);
65
+ else try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { /* ja no hi és */ } }
65
66
  finish(false, 'temps');
66
67
  }, timeout);
67
68
  try {
68
- child = spawn(bin, args, { env: childEnv(), stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32' });
69
+ const c = command(bin, args);
70
+ child = spawn(c.cmd, c.args, { env: childEnv(), stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32', windowsHide: true, ...c.opts });
69
71
  } catch (e) { return finish(false, e.message); }
70
72
  child.stdout.on('data', (d) => { if (out.length < 8 << 20) out += d; });
71
73
  child.stderr.on('data', (d) => { if (err.length < 1 << 20) err += d; });
package/src/hub.js CHANGED
@@ -194,7 +194,8 @@ class Hub extends EventEmitter {
194
194
  codexStatus(s, bp) {
195
195
  const { execFile } = require('child_process');
196
196
  const codex = s.agent === 'codex', name = byId[s.agent]?.name || s.agent;
197
- execFile(bp, codex ? ['login', 'status'] : ['--version'], { timeout: 8000 }, (err, out, errOut) => {
197
+ const c = require('./proc').command(bp, codex ? ['login', 'status'] : ['--version']);
198
+ execFile(c.cmd, c.args, { timeout: 8000, windowsHide: true, ...c.opts }, (err, out, errOut) => {
198
199
  const rows = String(out || errOut || '').split('\n').map((l) => l.trim()).filter(Boolean), login = rows.find((l) => /logged|log in|login/i.test(l) && !/^warning/i.test(l)) || rows[0] || (err ? 'No he pogut llegir el compte' : '');
199
200
  const lines = [codex ? `**Compte:** ${login}` : `**Versió:** ${login}`, `**Model:** ${s.model || s.modelUsed || 'el de per defecte'}`, `**Carpeta:** \`${s.cwd}\``, `**Permisos:** ${s.mode}`, `**Conversa:** ${s.nativeId ? `continua la de ${name}` : 'encara no ha començat'}`];
200
201
  this.push(s, 'text', { text: lines.join('\n\n') });
@@ -334,6 +335,7 @@ class Hub extends EventEmitter {
334
335
  s.state = code === 130 || code === 143 ? 'idle' : code && code !== 0 ? 'error' : 'idle';
335
336
  this.push(s, 'status', { state: s.state, code });
336
337
  this.notice(s, s.state === 'error' ? 'Hi ha hagut un error' : 'Feina acabada', s.title);
338
+ if (s.state === 'idle') this.emit('finished', s); // la memòria n'aprèn (learn.js)
337
339
  },
338
340
  };
339
341
 
package/src/learn.js ADDED
@@ -0,0 +1,417 @@
1
+ 'use strict';
2
+ // Memòria que aprèn sola: quan una sessió acaba, un model petit (Claude Code en mode headless, sense eines)
3
+ // llegeix la conversa i en treu els fets durables. Els nous es desen a la memòria; els que en contradiuen
4
+ // un d'existent queden pendents perquè decideixis tu des de l'app (Memòria).
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const crypto = require('crypto');
8
+ const memory = require('./memory');
9
+ const { HOME, save } = require('./config');
10
+
11
+ const FILE = path.join(HOME, 'memory-learn.json');
12
+ const DELAY = Number(process.env.NIO_LEARN_DELAY) || 2 * 60 * 1000; // espera que la sessió reposi
13
+ const MAX_CHAT = 40000; // caràcters de conversa que es llegeixen
14
+ const MAX_MEM = 30000; // caràcters de memòria que es passen per comparar
15
+ const MAX_LOG = 200;
16
+
17
+ const norm = (s) => String(s).toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
18
+ const slug = (s) => norm(s).replace(/\s+/g, '-');
19
+ const uid = () => crypto.randomBytes(6).toString('base64url');
20
+
21
+ function load() {
22
+ try { return { pending: [], log: [], ...JSON.parse(fs.readFileSync(FILE, 'utf8')) }; } catch { return { pending: [], log: [] }; }
23
+ }
24
+ function store(st) {
25
+ st.log = st.log.slice(-MAX_LOG);
26
+ fs.writeFileSync(FILE, JSON.stringify(st, null, 2), { mode: 0o600 });
27
+ }
28
+
29
+ // ---------- context per a l'inici d'una sessió ----------
30
+ // Fitxer del projecte d'una carpeta: projectes/<nom de la carpeta>.md (o el que s'hi assembli).
31
+ function projectFile(cwd) {
32
+ const base = slug(path.basename(String(cwd || '')));
33
+ if (!base) return null;
34
+ const files = memory.list().map((f) => f.path).filter((p) => p.startsWith('projectes/'));
35
+ return files.find((p) => slug(path.basename(p, '.md')) === base) || null;
36
+ }
37
+ function suggestedFile(cwd) {
38
+ return projectFile(cwd) || `projectes/${path.basename(String(cwd || '')).replace(/[^\p{L}\p{N} _.,()'’-]+/gu, '-') || 'general'}.md`;
39
+ }
40
+
41
+ // Text que es dona a l'agent abans de començar: preferències, el fitxer del projecte i què més hi ha.
42
+ function context(cwd, max = 8000) {
43
+ try {
44
+ memory.ensure();
45
+ const parts = [];
46
+ const add = (p) => { try { const t = memory.read(p).content.trim(); if (t) parts.push(`--- ${p} ---\n${t}`); } catch { /* no hi és */ } };
47
+ add('preferencies.md');
48
+ const pf = projectFile(cwd);
49
+ if (pf) add(pf);
50
+ let txt = parts.join('\n\n');
51
+ if (txt.length > max) txt = `${txt.slice(0, max)}\n… (retallat: llegeix el fitxer sencer amb memoria_llegeix)`;
52
+ const others = memory.list().map((f) => f.path).filter((p) => p !== 'preferencies.md' && p !== pf && p !== 'LLEGEIX-ME.md');
53
+ if (!txt && !others.length) return '';
54
+ return `Memòria compartida de nio (la mantenen tots els teus agents; fes-la servir i, si hi veus res obsolet, corregeix-ho amb memoria_corregeix):\n\n${txt || '(encara no hi ha res d’aquest projecte)'}${others.length ? `\n\nAltres fitxers de memòria (llegeix-los amb memoria_llegeix si et calen): ${others.slice(0, 60).join(', ')}` : ''}`;
55
+ } catch {
56
+ return '';
57
+ }
58
+ }
59
+
60
+ // ---------- el model ----------
61
+ // Crida Claude Code sense eines ni MCP ni sessió desada i en retorna la sortida estructurada.
62
+ function askClaude(cfg, prompt, schema) {
63
+ const { binPath } = require('./agents');
64
+ const { run } = require('./proc');
65
+ const bin = binPath(cfg, 'claude');
66
+ if (!bin) return Promise.reject(new Error('Cal Claude Code en aquest ordinador perquè la memòria aprengui sola'));
67
+ const model = cfg.memory?.model || 'haiku';
68
+ const args = ['-p', '--model', model, '--tools', '', '--strict-mcp-config', '--no-session-persistence', '--setting-sources', '', '--output-format', 'json', '--json-schema', JSON.stringify(schema)];
69
+ return new Promise((resolve, reject) => {
70
+ const out = [];
71
+ const errs = [];
72
+ const h = run({
73
+ bin, args, cwd: memory.DIR, stdin: prompt,
74
+ onLine: (l) => out.push(l),
75
+ onErr: (l) => errs.push(l),
76
+ onExit: (code, err) => {
77
+ clearTimeout(timer);
78
+ if (err) return reject(err);
79
+ let r = null;
80
+ for (const l of out.reverse()) { try { r = JSON.parse(l); break; } catch { /* no és JSON */ } }
81
+ if (!r || r.is_error) return reject(new Error((r && r.result) || errs.slice(-1)[0] || `Claude Code ha acabat amb el codi ${code}`));
82
+ let data = r.structured_output;
83
+ if (!data && typeof r.result === 'string') { try { data = JSON.parse(r.result.replace(/^```(?:json)?\s*|\s*```$/g, '')); } catch { /* res */ } }
84
+ if (!data) return reject(new Error('El model no ha retornat res que s’entengui'));
85
+ resolve({ data, cost: r.total_cost_usd || 0 });
86
+ },
87
+ });
88
+ const timer = setTimeout(() => h.kill(), 3 * 60 * 1000);
89
+ });
90
+ }
91
+
92
+ const FACTS = {
93
+ type: 'object',
94
+ properties: {
95
+ fets: {
96
+ type: 'array',
97
+ maxItems: 10,
98
+ items: {
99
+ type: 'object',
100
+ properties: {
101
+ fitxer: { type: 'string', description: 'Ruta relativa acabada en .md' },
102
+ text: { type: 'string', description: 'El fet, una sola línia curta' },
103
+ antic: { type: 'string', description: 'Només si contradiu o substitueix una línia que ja és a la memòria: aquella línia, copiada exactament' },
104
+ },
105
+ required: ['fitxer', 'text'],
106
+ },
107
+ },
108
+ },
109
+ required: ['fets'],
110
+ };
111
+
112
+ const RULES = `Regles:
113
+ - Desa només fets durables que serviran en sessions futures a qualsevol agent: preferències de l'usuari, decisions preses, com es fa una cosa en un projecte (ordres, desplegament, rutes, comptes, convencions), errors que no s'han de repetir, persones i el seu rol.
114
+ - No desis l'estat de la feina d'avui, resums de la conversa, coses que ja diu el codi o la memòria, ni secrets (contrasenyes, tokens, claus).
115
+ - Cada fet és una línia curta i autònoma (s'ha d'entendre sense la conversa), en l'idioma de l'usuari. Si cal, afegeix la data.
116
+ - Si un fet nou contradiu o deixa obsoleta una línia de la memòria, posa aquella línia exacta a «antic».
117
+ - Fitxers: preferències generals → preferencies.md; persones → persones/<nom>.md; un projecte → projectes/<nom>.md.
118
+ - Si no hi ha res que valgui la pena, retorna una llista buida. Millor poc i bo que molt.`;
119
+
120
+ function memoryDump(max = MAX_MEM, first = []) {
121
+ const files = memory.list().map((f) => f.path).filter((p) => p !== 'LLEGEIX-ME.md');
122
+ const order = [...new Set([...first.filter((p) => files.includes(p)), ...files])];
123
+ let out = '';
124
+ for (const p of order) {
125
+ let t = '';
126
+ try { t = memory.read(p).content.trim(); } catch { continue; }
127
+ if (!t) continue;
128
+ const block = `--- ${p} ---\n${t}\n\n`;
129
+ if (out.length + block.length > max) { out += `--- ${p} --- (no hi cap)\n`; continue; }
130
+ out += block;
131
+ }
132
+ return out.trim() || '(buida)';
133
+ }
134
+
135
+ function transcript(s, fromSeq) {
136
+ const evs = s.events.filter((e) => e.seq > fromSeq && (e.kind === 'user' || e.kind === 'text' || e.kind === 'tool'));
137
+ const lines = evs.map((e) => (e.kind === 'user' ? `USUARI: ${e.text}` : e.kind === 'text' ? `AGENT: ${e.text}` : `(eina ${e.name}: ${String(e.input?.command || e.input?.file_path || e.input?.description || '').slice(0, 160)})`));
138
+ let txt = lines.join('\n\n');
139
+ if (txt.length > MAX_CHAT) txt = `… (comença a mitja conversa)\n${txt.slice(-MAX_CHAT)}`;
140
+ return { txt, users: evs.filter((e) => e.kind === 'user').length };
141
+ }
142
+
143
+ // ---------- desar ----------
144
+ function lines(file) {
145
+ try { return memory.read(file).content.split('\n'); } catch { return null; }
146
+ }
147
+ function hasLine(file, text) {
148
+ const n = norm(text);
149
+ return (lines(file) || []).some((l) => norm(l.replace(/^\s*[-*]\s+/, '')) === n);
150
+ }
151
+ function findLine(file, text) {
152
+ const n = norm(String(text).replace(/^\s*[-*]\s+/, ''));
153
+ if (!n) return -1;
154
+ const ls = lines(file) || [];
155
+ const i = ls.findIndex((l) => norm(l.replace(/^\s*[-*]\s+/, '')) === n);
156
+ return i >= 0 ? i : ls.findIndex((l) => n.length > 12 && norm(l).includes(n));
157
+ }
158
+ function appendFact(file, text) {
159
+ const exists = lines(file);
160
+ const head = exists ? '' : `# ${path.basename(file, '.md').replace(/^./, (c) => c.toUpperCase())}\n\n`;
161
+ memory.write(file, `${head}- ${text}`, { append: true });
162
+ }
163
+ // Substitueix una línia (per text exacte) i en manté la vinyeta.
164
+ function replaceLine(file, old, text) {
165
+ const ls = lines(file);
166
+ const i = ls ? findLine(file, old) : -1;
167
+ if (i < 0) return false;
168
+ const bullet = (ls[i].match(/^\s*[-*]\s+/) || ['- '])[0];
169
+ ls[i] = `${bullet}${text}`;
170
+ memory.write(file, ls.join('\n'));
171
+ return true;
172
+ }
173
+ function removeLine(file, text) {
174
+ const ls = lines(file);
175
+ const i = ls ? findLine(file, text) : -1;
176
+ if (i < 0) return false;
177
+ ls.splice(i, 1);
178
+ memory.write(file, ls.join('\n'));
179
+ return true;
180
+ }
181
+
182
+ function cleanFact(f) {
183
+ const text = String(f.text || '').replace(/\s+/g, ' ').replace(/^[-*]\s+/, '').trim().slice(0, 400);
184
+ let file;
185
+ try { file = memory.safe(String(f.fitxer || '').trim()).rel; } catch { file = null; }
186
+ const antic = String(f.antic || '').replace(/^\s*[-*]\s+/, '').trim();
187
+ return { text, file, antic };
188
+ }
189
+
190
+ // Aplica el que ha trobat el model: fets nous a la memòria, contradiccions a la safata.
191
+ function apply(st, facts, meta, fallbackFile) {
192
+ const added = [];
193
+ const conflicts = [];
194
+ for (const raw of facts || []) {
195
+ const f = cleanFact(raw);
196
+ if (!f.text) continue;
197
+ const file = f.file || fallbackFile;
198
+ if (/(?:token|password|contrasenya|secret|api[_ -]?key)\s*[:=]\s*\S{8,}/i.test(f.text)) continue; // mai secrets
199
+ if (f.antic) {
200
+ // On és la línia antiga? Al fitxer proposat o a qualsevol altre.
201
+ const where = [file, ...memory.list().map((x) => x.path)].find((p) => findLine(p, f.antic) >= 0);
202
+ if (where && norm(f.antic) !== norm(f.text)) {
203
+ if (st.pending.some((p) => p.file === where && norm(p.old) === norm(f.antic) && norm(p.new) === norm(f.text))) continue;
204
+ const ls = lines(where);
205
+ const p = { id: uid(), t: Date.now(), file: where, old: ls[findLine(where, f.antic)].replace(/^\s*[-*]\s+/, '').trim(), new: f.text, ...meta };
206
+ st.pending.push(p);
207
+ conflicts.push(p);
208
+ continue;
209
+ }
210
+ }
211
+ if (hasLine(file, f.text)) continue;
212
+ appendFact(file, f.text);
213
+ const e = { id: uid(), t: Date.now(), file, text: f.text, ...meta };
214
+ st.log.push(e);
215
+ added.push(e);
216
+ }
217
+ return { added, conflicts };
218
+ }
219
+
220
+ // ---------- el motor ----------
221
+ class Learner {
222
+ constructor(hub, { ask } = {}) {
223
+ this.hub = hub;
224
+ this.cfg = hub.cfg;
225
+ this.ask = ask || ((prompt, schema) => askClaude(this.cfg, prompt, schema));
226
+ this.timers = new Map();
227
+ this.busy = new Set();
228
+ this.reviewing = false;
229
+ hub.on('finished', (s) => this.schedule(s));
230
+ }
231
+
232
+ enabled() {
233
+ return this.cfg.memory?.auto !== false;
234
+ }
235
+
236
+ schedule(s) {
237
+ if (!this.enabled() || s.agent === 'demo' || s.noLearn) return;
238
+ clearTimeout(this.timers.get(s.id));
239
+ const t = setTimeout(() => {
240
+ this.timers.delete(s.id);
241
+ if (this.hub.runs.has(s.id)) return; // torna a treballar: ja s'avisarà quan acabi
242
+ this.learn(s.id).catch(() => {});
243
+ }, DELAY);
244
+ t.unref?.();
245
+ this.timers.set(s.id, t);
246
+ }
247
+
248
+ async learn(sid) {
249
+ const s = this.hub.get(sid);
250
+ if (!s || this.busy.has(sid)) return null;
251
+ const from = s.memSeq || 0;
252
+ const { txt, users } = transcript(s, from);
253
+ if (!users || txt.length < 200) return null;
254
+ this.busy.add(sid);
255
+ const upto = s.seq;
256
+ try {
257
+ const file = suggestedFile(s.cwd);
258
+ const prompt = `Ets la memòria d'un equip d'agents de programació (Claude Code, Codex…) que treballen per la mateixa persona. Llegeix aquesta sessió i treu-ne els fets que convé recordar perquè la propera sessió de qualsevol agent comenci sabent-los.
259
+
260
+ Avui és ${new Date().toISOString().slice(0, 10)}.
261
+ Carpeta de la sessió: ${s.cwd} (el fitxer d'aquest projecte és ${file})
262
+ Títol: ${s.title || '—'}
263
+
264
+ ${RULES}
265
+
266
+ MEMÒRIA ACTUAL:
267
+ ${memoryDump(MAX_MEM, ['preferencies.md', file])}
268
+
269
+ SESSIÓ:
270
+ ${txt}`;
271
+ const { data, cost } = await this.ask(prompt, FACTS);
272
+ const st = load();
273
+ const r = apply(st, data.fets, { session: s.id, title: s.title || '', agent: s.agent }, file);
274
+ store(st);
275
+ s.memSeq = upto;
276
+ this.hub.persist(s);
277
+ if (cost) this.hub.emit('out', { type: 'memory', cost });
278
+ if (r.added.length || r.conflicts.length) {
279
+ this.hub.emit('out', { type: 'memory', added: r.added.length, conflicts: r.conflicts.length });
280
+ this.hub.push(s, 'info', { text: `Memòria: ${[r.added.length && `${r.added.length} ${r.added.length === 1 ? 'fet nou' : 'fets nous'}`, r.conflicts.length && `${r.conflicts.length} ${r.conflicts.length === 1 ? 'contradicció per revisar' : 'contradiccions per revisar'}`].filter(Boolean).join(' · ')}`, memory: true });
281
+ }
282
+ if (r.conflicts.length) this.hub.notice(s, 'La memòria té una contradicció', `${r.conflicts[0].old} → ${r.conflicts[0].new}`, { route: 'memoria' });
283
+ return r;
284
+ } finally {
285
+ this.busy.delete(sid);
286
+ }
287
+ }
288
+
289
+ // Repassa tota la memòria: contradiccions i duplicats entre fitxers.
290
+ async review() {
291
+ if (this.reviewing) throw Object.assign(new Error('Ja s’està revisant'), { status: 409 });
292
+ this.reviewing = true;
293
+ try {
294
+ const schema = {
295
+ type: 'object',
296
+ properties: {
297
+ problemes: {
298
+ type: 'array',
299
+ maxItems: 15,
300
+ items: {
301
+ type: 'object',
302
+ properties: {
303
+ fitxer: { type: 'string' },
304
+ antic: { type: 'string', description: 'La línia obsoleta o duplicada, copiada exactament' },
305
+ text: { type: 'string', description: 'Com hauria de quedar (buit per esborrar-la si és un duplicat)' },
306
+ motiu: { type: 'string', description: 'Per què, en poques paraules' },
307
+ },
308
+ required: ['fitxer', 'antic', 'text', 'motiu'],
309
+ },
310
+ },
311
+ },
312
+ required: ['problemes'],
313
+ };
314
+ const prompt = `Avui és ${new Date().toISOString().slice(0, 10)}. Revisa la memòria compartida d'aquests agents. Busca línies que es contradiguin entre si (dins un fitxer o entre fitxers), que una altra línia hagi deixat obsoletes (mira les dates) o que estiguin duplicades. Per a cada problema, indica la línia que cal canviar («antic», copiada exactament), com hauria de quedar («text»; buit si és un duplicat que cal treure) i el motiu. No proposis canvis d'estil ni de redacció. Si no hi ha cap problema, retorna una llista buida.
315
+
316
+ MEMÒRIA:
317
+ ${memoryDump(MAX_MEM * 2)}`;
318
+ const { data } = await this.ask(prompt, schema);
319
+ const st = load();
320
+ const found = [];
321
+ for (const p of data.problemes || []) {
322
+ const f = cleanFact({ fitxer: p.fitxer, text: p.text, antic: p.antic });
323
+ const where = [f.file, ...memory.list().map((x) => x.path)].filter(Boolean).find((x) => findLine(x, f.antic) >= 0);
324
+ if (!where || norm(f.antic) === norm(f.text)) continue;
325
+ if (st.pending.some((x) => x.file === where && norm(x.old) === norm(f.antic))) continue;
326
+ const item = { id: uid(), t: Date.now(), file: where, old: lines(where)[findLine(where, f.antic)].replace(/^\s*[-*]\s+/, '').trim(), new: f.text, why: String(p.motiu || '').slice(0, 200), review: true };
327
+ st.pending.push(item);
328
+ found.push(item);
329
+ }
330
+ store(st);
331
+ return { found: found.length, pending: st.pending };
332
+ } finally {
333
+ this.reviewing = false;
334
+ }
335
+ }
336
+
337
+ status() {
338
+ const st = load();
339
+ return { auto: this.enabled(), pending: st.pending, log: st.log.slice(-40).reverse(), reviewing: this.reviewing, learning: this.busy.size };
340
+ }
341
+
342
+ // choice: «nou» (fes servir el nou), «antic» (mantén el que hi havia) o «tots» (desa el nou com a línia a part).
343
+ resolve(id, choice) {
344
+ const st = load();
345
+ const p = st.pending.find((x) => x.id === id);
346
+ if (!p) throw Object.assign(new Error('Aquesta proposta ja no hi és'), { status: 404 });
347
+ if (choice === 'nou') {
348
+ const ok = p.new ? replaceLine(p.file, p.old, p.new) : removeLine(p.file, p.old);
349
+ if (!ok && p.new && !hasLine(p.file, p.new)) appendFact(p.file, p.new);
350
+ if (p.new) st.log.push({ id: uid(), t: Date.now(), file: p.file, text: p.new, replaced: p.old, session: p.session, title: p.title, agent: p.agent });
351
+ } else if (choice === 'tots') {
352
+ if (p.new && !hasLine(p.file, p.new)) appendFact(p.file, p.new);
353
+ } else if (choice !== 'antic') {
354
+ throw Object.assign(new Error('Tria «nou», «antic» o «tots»'), { status: 400 });
355
+ }
356
+ st.pending = st.pending.filter((x) => x.id !== id);
357
+ store(st);
358
+ return { pending: st.pending };
359
+ }
360
+
361
+ // Desfà un fet après: el treu del fitxer (o hi torna a posar l'antic si n'havia substituït un).
362
+ undo(id) {
363
+ const st = load();
364
+ const e = st.log.find((x) => x.id === id);
365
+ if (!e) throw Object.assign(new Error('Aquest canvi ja no hi és'), { status: 404 });
366
+ const ok = e.replaced ? replaceLine(e.file, e.text, e.replaced) : removeLine(e.file, e.text);
367
+ st.log = st.log.filter((x) => x.id !== id);
368
+ store(st);
369
+ return { ok };
370
+ }
371
+
372
+ routes() {
373
+ const httpErr = (status, message) => Object.assign(new Error(message), { status });
374
+ return [
375
+ { method: 'GET', path: '/api/memory/learn', handler: () => this.status() },
376
+ {
377
+ method: 'PUT', path: '/api/memory/learn', handler: ({ body }) => {
378
+ if (typeof body?.auto !== 'boolean') throw httpErr(400, 'Falta «auto»');
379
+ this.cfg.memory = { ...(this.cfg.memory || {}), auto: body.auto };
380
+ save(this.cfg);
381
+ return this.status();
382
+ },
383
+ },
384
+ { method: 'POST', path: /^\/api\/memory\/pending\/([\w-]+)$/, handler: ({ params, body }) => this.resolve(params[0], body?.choice) },
385
+ { method: 'DELETE', path: /^\/api\/memory\/log\/([\w-]+)$/, handler: ({ params }) => this.undo(params[0]) },
386
+ { method: 'POST', path: '/api/memory/review', handler: () => this.review() },
387
+ {
388
+ method: 'POST', path: /^\/api\/memory\/learn\/([\w-]+)$/, handler: async ({ params }) => {
389
+ if (!this.hub.get(params[0])) throw httpErr(404, 'Aquesta sessió no existeix');
390
+ return (await this.learn(params[0])) || { added: [], conflicts: [] };
391
+ },
392
+ },
393
+ ];
394
+ }
395
+ }
396
+
397
+ // Línies de la memòria que s'assemblen a un text nou (per avisar l'agent que potser contradiu o repeteix).
398
+ function similar(text, { skip } = {}) {
399
+ const words = (s) => new Set(norm(s).split(' ').filter((w) => w.length > 3));
400
+ const a = words(text);
401
+ if (a.size < 2) return [];
402
+ const out = [];
403
+ for (const f of memory.list()) {
404
+ (lines(f.path) || []).forEach((l, i) => {
405
+ if (skip && skip.file === f.path && skip.lines.has(l)) return;
406
+ const b = words(l);
407
+ if (b.size < 2) return;
408
+ let n = 0;
409
+ for (const w of a) if (b.has(w)) n++;
410
+ const score = n / Math.min(a.size, b.size);
411
+ if (n >= 2 && score >= 0.5) out.push({ path: f.path, line: i + 1, text: l.trim().slice(0, 300), score });
412
+ });
413
+ }
414
+ return out.sort((x, y) => y.score - x.score).slice(0, 5);
415
+ }
416
+
417
+ module.exports = { Learner, context, projectFile, suggestedFile, similar, apply, replaceLine, removeLine, askClaude, load, FILE };
package/src/mcp.js CHANGED
@@ -47,7 +47,30 @@ const TOOLS = [
47
47
  },
48
48
  run: ({ fitxer, text, mode }) => {
49
49
  const r = memory.write(fitxer, text, { append: mode !== 'substitueix' });
50
- return `Desat a ${r.path} (${r.size} B).`;
50
+ // Avisa si ja hi havia res semblant: potser és un duplicat o el nou contradiu l'antic.
51
+ const near = require('./learn').similar(text, { skip: { file: r.path, lines: new Set(String(text).split('\n')) } });
52
+ return `Desat a ${r.path} (${r.size} B).${near.length ? `\n\nAlerta: a la memòria ja hi ha línies semblants. Si el que acabes de desar les contradiu o les repeteix, corregeix-les amb memoria_corregeix:\n${near.map((h) => `${h.path}:${h.line}: ${h.text}`).join('\n')}` : ''}`;
53
+ },
54
+ },
55
+ {
56
+ name: 'memoria_corregeix',
57
+ description: 'Corregeix una línia de la memòria que ha quedat obsoleta o és falsa: substitueix-la pel text nou (o esborra-la si «nou» és buit). Fes-la servir en lloc d’afegir un fet que en contradigui un altre, perquè la memòria no tingui dues versions.',
58
+ inputSchema: {
59
+ type: 'object',
60
+ properties: {
61
+ fitxer: { type: 'string', description: 'Ruta relativa acabada en .md' },
62
+ antic: { type: 'string', description: 'La línia que cal canviar, tal com és ara (o un tros prou llarg per trobar-la)' },
63
+ nou: { type: 'string', description: 'Com ha de quedar; buit per esborrar-la' },
64
+ },
65
+ required: ['fitxer', 'antic', 'nou'],
66
+ additionalProperties: false,
67
+ },
68
+ run: ({ fitxer, antic, nou }) => {
69
+ const learn = require('./learn');
70
+ memory.read(fitxer); // 404 si no hi és
71
+ const ok = String(nou || '').trim() ? learn.replaceLine(memory.safe(fitxer).rel, antic, String(nou).trim()) : learn.removeLine(memory.safe(fitxer).rel, antic);
72
+ if (!ok) throw new Error(`No he trobat aquesta línia a ${fitxer}. Llegeix el fitxer amb memoria_llegeix i copia-la tal com és.`);
73
+ return String(nou || '').trim() ? `Corregit a ${fitxer}.` : `Esborrat de ${fitxer}.`;
51
74
  },
52
75
  },
53
76
  {
@@ -72,7 +95,7 @@ function serve({ input = process.stdin, output = process.stdout } = {}) {
72
95
  protocolVersion: params?.protocolVersion || '2025-06-18',
73
96
  capabilities: { tools: {} },
74
97
  serverInfo: { name: 'nio', version: require('../package.json').version },
75
- 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. Si treballes dins una sessió de nio, posa-li un títol curt amb sessio_titol.',
98
+ 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. Si un fet ha canviat, corregeix la línia antiga amb memoria_corregeix en lloc d’afegir-ne una de nova. Si treballes dins una sessió de nio, posa-li un títol curt amb sessio_titol.',
76
99
  });
77
100
  case 'ping':
78
101
  return reply({});
@@ -122,10 +145,11 @@ function command() {
122
145
  function registerClaude(bin, { force } = {}) {
123
146
  if (!bin) return 'Claude Code no hi és';
124
147
  const { cmd, args } = command();
125
- if (!force && spawnSync(bin, ['mcp', 'get', 'nio'], { encoding: 'utf8', timeout: 15000 }).status === 0) return 'ja hi era';
126
- spawnSync(bin, ['mcp', 'remove', '--scope', 'user', 'nio'], { timeout: 15000 });
148
+ const sync = (args, o) => { const c = require('./proc').command(bin, args); return spawnSync(c.cmd, c.args, { windowsHide: true, ...o, ...c.opts }); };
149
+ if (!force && sync(['mcp', 'get', 'nio'], { encoding: 'utf8', timeout: 15000 }).status === 0) return 'ja hi era';
150
+ sync(['mcp', 'remove', '--scope', 'user', 'nio'], { timeout: 15000 });
127
151
  const env = process.env.NIO_HOME ? ['-e', `NIO_HOME=${process.env.NIO_HOME}`] : [];
128
- const r = spawnSync(bin, ['mcp', 'add', 'nio', '--scope', 'user', ...env, '--', cmd, ...args], { encoding: 'utf8', timeout: 15000 });
152
+ const r = sync(['mcp', 'add', 'nio', '--scope', 'user', ...env, '--', cmd, ...args], { encoding: 'utf8', timeout: 15000 });
129
153
  return r.status === 0 ? 'afegit' : `no s’ha pogut afegir: ${(r.stderr || r.stdout || '').trim().split('\n').pop()}`;
130
154
  }
131
155
 
package/src/notes.js CHANGED
@@ -8,7 +8,7 @@ const path = require('path');
8
8
  const crypto = require('crypto');
9
9
  const { spawn } = require('child_process');
10
10
  const { HOME } = require('./config');
11
- const { childEnv } = require('./proc');
11
+ const { childEnv, command, killTree } = require('./proc');
12
12
  const voice = require('./voice');
13
13
  const ollama = require('./agents/ollama');
14
14
 
@@ -470,9 +470,10 @@ function parseJson(raw) {
470
470
  // ---------- Execució d'un model, d'un sol cop
471
471
  function runCli(bin, args, input, { cwd, timeout = 180000 } = {}) {
472
472
  return new Promise((resolve, reject) => {
473
- const p = spawn(bin, args, { cwd, env: childEnv(), stdio: ['pipe', 'pipe', 'pipe'] });
473
+ const c = command(bin, args);
474
+ const p = spawn(c.cmd, c.args, { cwd, env: childEnv(), stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, ...c.opts });
474
475
  let out = '', err = '';
475
- 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);
476
+ const t = setTimeout(() => { if (process.platform === 'win32') killTree(p.pid); else p.kill('SIGKILL'); reject(httpErr(504, 'El model ha trigat massa. Prova-ho amb un de més petit o ràpid.')); }, timeout);
476
477
  p.stdout.on('data', (d) => { out += d; });
477
478
  p.stderr.on('data', (d) => { err += d; });
478
479
  p.on('error', (e) => { clearTimeout(t); reject(httpErr(500, `No he pogut engegar ${path.basename(bin)}: ${e.message}`)); });
package/src/proc.js CHANGED
@@ -5,27 +5,28 @@ const os = require('os');
5
5
  const path = require('path');
6
6
  const { spawn, execFile } = require('child_process');
7
7
 
8
+ const WIN = process.platform === 'win32';
8
9
  const EXTRA_DIRS = [
9
10
  path.join(os.homedir(), '.nio', 'bin'),
11
+ // Windows: npm -g, l'instal·lador natiu de Claude Code i winget.
12
+ ...(WIN ? [process.env.APPDATA && path.join(process.env.APPDATA, 'npm'), process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links'), process.env.ProgramFiles && path.join(process.env.ProgramFiles, 'nodejs')].filter(Boolean) : []),
10
13
  path.join(os.homedir(), '.local', 'bin'),
11
14
  path.join(os.homedir(), '.npm-global', 'bin'),
12
15
  path.join(os.homedir(), '.bun', 'bin'),
13
16
  path.join(os.homedir(), '.grok', 'bin'),
14
- '/opt/homebrew/bin',
15
- '/usr/local/bin',
16
- '/usr/bin',
17
+ ...(WIN ? [] : ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin']),
17
18
  ];
18
19
 
19
20
  function searchPath() {
20
- const parts = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
21
- for (const d of EXTRA_DIRS) if (!parts.includes(d)) parts.push(d);
21
+ const parts = (process.env.PATH || process.env.Path || '').split(path.delimiter).filter(Boolean);
22
+ for (const d of EXTRA_DIRS) if (!parts.some((x) => (WIN ? x.toLowerCase() === d.toLowerCase() : x === d))) parts.push(d);
22
23
  return parts;
23
24
  }
24
25
 
25
26
  function which(bin) {
26
27
  if (!bin) return null;
27
28
  if (bin.includes('/') || bin.includes('\\')) return fs.existsSync(bin) ? bin : null;
28
- const exts = process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
29
+ const exts = WIN ? ['.exe', '.cmd', '.bat'] : [''];
29
30
  for (const dir of searchPath()) {
30
31
  for (const ext of exts) {
31
32
  const p = path.join(dir, bin + ext);
@@ -40,9 +41,44 @@ function which(bin) {
40
41
  return null;
41
42
  }
42
43
 
44
+ // Windows: els CLI de npm (claude.cmd, codex.cmd…) són scripts .cmd, i Node no els executa sense una shell
45
+ // (i passar-hi el text de l'usuari per cmd.exe seria perillós). Es llegeix l'script i s'executa directament
46
+ // el que hi ha a dins: «node …\cli.js» o un .exe. Si no s'entén, cmd.exe amb els arguments escapats.
47
+ const shimCache = new Map();
48
+ function command(bin, args = []) {
49
+ if (!WIN || !/\.(cmd|bat)$/i.test(bin)) return { cmd: bin, args, opts: {} };
50
+ let target = shimCache.get(bin);
51
+ if (target === undefined) {
52
+ target = null;
53
+ try {
54
+ const txt = fs.readFileSync(bin, 'utf8');
55
+ // El primer «"%dp0%\…"» que existeixi, sense comptar el node.exe opcional de la mateixa carpeta.
56
+ for (const m of txt.matchAll(/"%~?dp0%?\\?([^"%]+?\.(?:js|cjs|mjs|exe))"/gi)) {
57
+ const p = path.join(path.dirname(bin), m[1]);
58
+ if (!/^node\.exe$/i.test(m[1]) && fs.existsSync(p)) { target = p; break; }
59
+ }
60
+ } catch {
61
+ /* no es pot llegir */
62
+ }
63
+ shimCache.set(bin, target);
64
+ }
65
+ if (target && /\.exe$/i.test(target)) return { cmd: target, args, opts: {} };
66
+ if (target) return { cmd: process.execPath, args: [target, ...args], opts: {} };
67
+ const line = [bin, ...args].map(cmdQuote).join(' ');
68
+ return { cmd: process.env.ComSpec || 'cmd.exe', args: ['/d', '/s', '/c', `"${line}"`], opts: { windowsVerbatimArguments: true } };
69
+ }
70
+ // Cometes per a cmd.exe: les de CreateProcess i, a sobre, ^ davant dels caràcters especials de cmd.
71
+ function cmdQuote(a) {
72
+ let s = String(a);
73
+ if (s && !/[\s"^&|<>%!()]/.test(s)) return s;
74
+ s = `"${s.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"`;
75
+ return s.replace(/[\^&|<>%!()"]/g, '^$&');
76
+ }
77
+
43
78
  function version(binPath, args = ['--version']) {
44
79
  return new Promise((resolve) => {
45
- execFile(binPath, args, { timeout: 4000, env: childEnv() }, (err, stdout, stderr) => {
80
+ const c = command(binPath, args);
81
+ execFile(c.cmd, c.args, { timeout: 4000, env: childEnv(), windowsHide: true, ...c.opts }, (err, stdout, stderr) => {
46
82
  const out = String(stdout || stderr || '').trim().split('\n')[0];
47
83
  resolve(err && !out ? null : out.slice(0, 60));
48
84
  });
@@ -75,11 +111,14 @@ function run({ bin, args, cwd, env, stdin, onLine, onErr, onExit, tty }) {
75
111
  argv = ['-qfec', [bin, ...args].map(shQuote).join(' '), '/dev/null'];
76
112
  }
77
113
  }
78
- const child = spawn(cmd, argv, {
114
+ const c = command(cmd, argv);
115
+ const child = spawn(c.cmd, c.args, {
79
116
  cwd,
80
117
  env: childEnv(env),
81
118
  stdio: ['pipe', 'pipe', 'pipe'],
82
- detached: process.platform !== 'win32',
119
+ detached: !WIN,
120
+ windowsHide: true,
121
+ ...c.opts,
83
122
  });
84
123
  if (stdin != null) child.stdin.end(stdin);
85
124
  else child.stdin.end();
@@ -109,8 +148,8 @@ function run({ bin, args, cwd, env, stdin, onLine, onErr, onExit, tty }) {
109
148
  child,
110
149
  kill() {
111
150
  try {
112
- if (process.platform !== 'win32') process.kill(-child.pid, 'SIGTERM');
113
- else child.kill();
151
+ if (!WIN) process.kill(-child.pid, 'SIGTERM');
152
+ else killTree(child.pid);
114
153
  } catch {
115
154
  try {
116
155
  child.kill();
@@ -122,6 +161,16 @@ function run({ bin, args, cwd, env, stdin, onLine, onErr, onExit, tty }) {
122
161
  };
123
162
  }
124
163
 
164
+ // Windows: atura el procés i tots els que ha obert (l'agent, les seves eines…).
165
+ function killTree(pid) {
166
+ if (!pid) return;
167
+ try {
168
+ spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }).on('error', () => {});
169
+ } catch {
170
+ /* ja ha acabat */
171
+ }
172
+ }
173
+
125
174
  function shQuote(s) {
126
175
  return `'${String(s).replace(/'/g, `'\\''`)}'`;
127
176
  }
@@ -141,4 +190,4 @@ function tryJson(line) {
141
190
  }
142
191
  }
143
192
 
144
- module.exports = { which, version, run, childEnv, tryJson, stripAnsi, searchPath, shQuote };
193
+ module.exports = { which, version, run, childEnv, tryJson, stripAnsi, searchPath, shQuote, command, cmdQuote, killTree };
package/src/service.js CHANGED
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
- // Servei del sistema perquè nio arrenqui sol amb l'ordinador (systemd a Linux, launchd a macOS).
2
+ // Servei del sistema perquè nio arrenqui sol amb l'ordinador (systemd a Linux, launchd a macOS i, a Windows,
3
+ // un script a la carpeta Inici que l'engega amagat en iniciar sessió i el torna a obrir si es tanca).
3
4
  const fs = require('fs');
4
5
  const os = require('os');
5
6
  const path = require('path');
@@ -66,8 +67,37 @@ ${args}
66
67
  `;
67
68
  }
68
69
 
70
+ // Windows: no cal ser administrador. L'script fa de supervisor: engega nio sense finestra, n'escriu la sortida
71
+ // a ~/.nio/nio.log i, si es tanca, el torna a obrir al cap de 3 segons.
72
+ function windowsStartup() {
73
+ return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'nio.vbs');
74
+ }
75
+ function windowsVbs({ roots }) {
76
+ const qv = (s) => String(s).replace(/"/g, '""'); // dins d'una cadena de VBScript
77
+ const arg = (s) => `"${s}"`;
78
+ // cmd /s /c "…": treu les cometes de fora i executa la resta tal qual (amb la redirecció al registre).
79
+ const line = qv(`cmd /d /s /c "${[process.execPath, entry(), '--no-open', ...roots].map(arg).join(' ')} >> ${arg(path.join(HOME, 'nio.log'))} 2>&1"`);
80
+ return `' nio · s'engega en iniciar sessió (nio service uninstall per treure-ho)
81
+ Set sh = CreateObject("WScript.Shell")
82
+ Set env = sh.Environment("PROCESS")
83
+ env("NO_COLOR") = "1"
84
+ env("PATH") = "${qv(searchPath().join(';'))}"
85
+ ${process.env.NIO_HOME ? `env("NIO_HOME") = "${qv(process.env.NIO_HOME)}"
86
+ ` : ''}sh.CurrentDirectory = "${qv(roots[0] || os.homedir())}"
87
+ Do
88
+ sh.Run "${line}", 0, True
89
+ WScript.Sleep 3000
90
+ Loop
91
+ `;
92
+ }
93
+ // Atura el supervisor i nio (els processos que en porten la ruta a la línia d'ordres).
94
+ function windowsStop() {
95
+ const ps = `Get-CimInstance Win32_Process | Where-Object { ($_.Name -eq 'wscript.exe' -and $_.CommandLine -like '*nio.vbs*') -or ($_.Name -eq 'node.exe' -and $_.CommandLine -like '*${entry().replace(/'/g, "''")}*--no-open*') } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }`;
96
+ sh('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps]);
97
+ }
98
+
69
99
  function sh(cmd, args) {
70
- const r = spawnSync(cmd, args, { encoding: 'utf8' });
100
+ const r = spawnSync(cmd, args, { encoding: 'utf8', windowsHide: true });
71
101
  return { ok: r.status === 0, out: `${r.stdout || ''}${r.stderr || ''}`.trim() };
72
102
  }
73
103
 
@@ -85,7 +115,18 @@ function install({ roots, log = console.log }) {
85
115
  if (!r.ok) throw new Error(`launchctl no l’ha pogut carregar: ${r.out}`);
86
116
  return { file, logs: `tail -f ${path.join(HOME, 'nio.log')}` };
87
117
  }
88
- if (process.platform !== 'linux') throw new Error('El servei automàtic només funciona a Linux (systemd) i macOS (launchd).');
118
+ if (process.platform === 'win32') {
119
+ const file = windowsStartup();
120
+ fs.mkdirSync(path.dirname(file), { recursive: true });
121
+ fs.mkdirSync(HOME, { recursive: true });
122
+ windowsStop();
123
+ fs.writeFileSync(file, windowsVbs({ roots }));
124
+ const child = require('child_process').spawn('wscript.exe', ['//B', '//Nologo', file], { detached: true, stdio: 'ignore', windowsHide: true });
125
+ child.on('error', () => {});
126
+ child.unref();
127
+ return { file, logs: `Get-Content -Wait "${path.join(HOME, 'nio.log')}"` };
128
+ }
129
+ if (process.platform !== 'linux') throw new Error('El servei automàtic només funciona a Linux (systemd), macOS (launchd) i Windows.');
89
130
  if (!sh('systemctl', ['--version']).ok) throw new Error('Aquest sistema no té systemd.');
90
131
  const system = process.getuid && process.getuid() === 0;
91
132
  const file = system ? '/etc/systemd/system/nio.service' : path.join(os.homedir(), '.config', 'systemd', 'user', 'nio.service');
@@ -105,6 +146,12 @@ function install({ roots, log = console.log }) {
105
146
  }
106
147
 
107
148
  function uninstall() {
149
+ if (process.platform === 'win32') {
150
+ const file = windowsStartup();
151
+ windowsStop();
152
+ fs.rmSync(file, { force: true });
153
+ return file;
154
+ }
108
155
  if (process.platform === 'darwin') {
109
156
  const file = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
110
157
  sh('launchctl', ['unload', '-w', file]);
@@ -120,4 +167,4 @@ function uninstall() {
120
167
  return file;
121
168
  }
122
169
 
123
- module.exports = { install, uninstall, systemdUnit, launchdPlist };
170
+ module.exports = { install, uninstall, systemdUnit, launchdPlist, windowsVbs };
package/web/app/app.js CHANGED
@@ -670,7 +670,12 @@ function render() {
670
670
  /* ---------- Ordinadors (llista, afegir i detall de l'actiu) */
671
671
  const deviceId = () => { let d = LS.get('nio.device', ''); if (!d) { d = Math.random().toString(36).slice(2, 12); LS.set('nio.device', d); } return d; };
672
672
  const isPhone = () => navigator.userAgentData?.mobile === true || /Android|iPhone|iPod|Mobile/i.test(navigator.userAgent) || (isTouch && matchMedia('(max-width: 1024px)').matches);
673
- const INSTALL_CMD = 'curl -fsSL https://www.nio.cat/install.sh | sh';
673
+ const SH_CMD = 'curl -fsSL https://www.nio.cat/install.sh | sh', WIN_CMD = 'irm https://www.nio.cat/install.ps1 | iex';
674
+ // En un PC amb Windows, l'ordre de PowerShell; a la resta (i al mòbil, que no sap quin ordinador tens), la de Mac i Linux.
675
+ const isWindows = !isPhone() && (navigator.userAgentData?.platform === 'Windows' || /Windows/.test(navigator.userAgent));
676
+ const INSTALL_CMD = isWindows ? WIN_CMD : SH_CMD;
677
+ const OPEN_TERM = isWindows ? 'Obre PowerShell: prem la tecla Windows i escriu «PowerShell».' : 'Obre el Terminal. Al Mac, prem ⌘ Espai i escriu «Terminal».';
678
+ const OTHER_OS = isWindows ? `Windows. A Mac o Linux: <span class="mono">${SH_CMD}</span>` : `Mac o Linux. A Windows, al PowerShell: <span class="mono">${WIN_CMD}</span>`;
674
679
  const cmdBox = (c) => `<div class="cmd big"><code>${esc(c)}</code><button class="btn sm" data-copy="${esc(c)}" type="button">${svg(ICON.copy)}Copia</button></div>`;
675
680
  // Estat de la resta d'ordinadors (el connectat ja té el seu flux en directe): una consulta lleugera.
676
681
  const reach = new Map();
@@ -805,9 +810,9 @@ function viewComputers() {
805
810
  if (!ms.length) {
806
811
  const phone = isPhone();
807
812
  const steps = phone
808
- ? [['Enganxa-la a l’ordinador', 'Al Terminal. Instal·la nio i el deixa engegat, també quan reinicies.'], ['Escaneja el QR', 'Surt al navegador de l’ordinador quan acaba. També hi ha un codi.'], ['Dona feina a un agent', 'Tria l’agent i la carpeta, i mira com treballa des d’aquí.']]
809
- : [['Obre el Terminal', 'Al Mac, ⌘ Espai i escriu «Terminal». A Linux, Ctrl Alt T.'], ['Enganxa l’ordre', 'Instal·la nio i el deixa engegat, també quan reinicies.'], ['Escaneja el QR', 'Aquesta web es connecta sola i et mostra el QR del mòbil.']];
810
- return `${head}<div class="panel empty first-run">${bot('idle')}<h2>Connecta el teu primer ordinador</h2><p>${phone ? 'nio s’instal·la a l’ordinador amb una sola ordre. Copia-la i enganxa-la allà.' : 'Una sola ordre instal·la nio en aquest ordinador i ho deixa tot a punt.'}</p>${cmdBox(INSTALL_CMD)}<ol class="stepper">${steps.map(([t, d], i) => `<li><span class="n">${i + 1}</span><div><b>${t}</b><p>${d}</p></div></li>`).join('')}</ol><div class="row" style="justify-content:center"><button class="btn ink" data-act="add-computer" type="button">${svg(ICON.plus)}${phone ? 'Tinc un codi o un QR' : 'Afegeix ordinador'}</button><button class="btn" data-act="demo" type="button">Prova una demo</button></div><p class="muted small">Mac o Linux. Per provar-lo sense instal·lar res, <span class="mono">npx nio-cat</span>.</p></div>`;
813
+ ? [['Enganxa-la a l’ordinador', 'Al Terminal (a Windows, a PowerShell). Instal·la nio i el deixa engegat, també quan reinicies.'], ['Escaneja el QR', 'Surt al navegador de l’ordinador quan acaba. També hi ha un codi.'], ['Dona feina a un agent', 'Tria l’agent i la carpeta, i mira com treballa des d’aquí.']]
814
+ : [isWindows ? ['Obre PowerShell', 'Prem la tecla Windows i escriu «PowerShell».'] : ['Obre el Terminal', 'Al Mac, ⌘ Espai i escriu «Terminal». A Linux, Ctrl Alt T.'], ['Enganxa l’ordre', 'Instal·la nio i el deixa engegat, també quan reinicies.'], ['Escaneja el QR', 'Aquesta web es connecta sola i et mostra el QR del mòbil.']];
815
+ return `${head}<div class="panel empty first-run">${bot('idle')}<h2>Connecta el teu primer ordinador</h2><p>${phone ? 'nio s’instal·la a l’ordinador amb una sola ordre. Copia-la i enganxa-la allà.' : 'Una sola ordre instal·la nio en aquest ordinador i ho deixa tot a punt.'}</p>${cmdBox(INSTALL_CMD)}<ol class="stepper">${steps.map(([t, d], i) => `<li><span class="n">${i + 1}</span><div><b>${t}</b><p>${d}</p></div></li>`).join('')}</ol><div class="row" style="justify-content:center"><button class="btn ink" data-act="add-computer" type="button">${svg(ICON.plus)}${phone ? 'Tinc un codi o un QR' : 'Afegeix ordinador'}</button><button class="btn" data-act="demo" type="button">Prova una demo</button></div><p class="muted small">${OTHER_OS}. Per provar-lo sense instal·lar res, <span class="mono">npx nio-cat</span>.</p></div>`;
811
816
  }
812
817
  const detail = m && state.connected ? `<h2 class="sec">${esc(m.name)}</h2>
813
818
  <div class="grid2 one">
@@ -894,7 +899,7 @@ function addComputerSheet(tab, opts = {}) {
894
899
  const saved = d && store.machines.findIndex((x) => x.id === d.id);
895
900
  if (d && saved >= 0) set('saved', `${intro('Aquest ordinador ja és a la llista', 'El tens connectat com a «' + esc(d.name) + '».')}<div class="row"><button class="btn ink" type="button" data-open-saved="${saved}">Obre sessions</button></div>`);
896
901
  else if (d) set('found', `${intro('nio ja funciona en aquest ordinador', 'Aquest navegador encara no hi té accés. Escriu això al Terminal i s’obrirà el panell ja connectat:')}${cmdBox('nio pair')}`);
897
- else set('none', `${intro('Instal·la nio en aquest ordinador', 'Una sola ordre ho deixa tot a punt i engegat, també quan reinicies.')}${todo(['Obre el Terminal. Al Mac, prem ⌘ Espai i escriu «Terminal».', `Enganxa aquesta ordre i prem Retorn:${cmdBox(INSTALL_CMD)}`, 'Quan acabi, aquesta pàgina es connectarà sola.'])}${live('Esperant nio…')}<p class="hint">Mac o Linux. Si ja el tens i està aturat, escriu <span class="mono">nio</span> al Terminal.</p>${store.machines.length ? '' : '<div class="or"><span>o</span></div><button class="btn block" type="button" data-close data-act="demo">Prova una demo, sense instal·lar res</button>'}`);
902
+ else set('none', `${intro('Instal·la nio en aquest ordinador', 'Una sola ordre ho deixa tot a punt i engegat, també quan reinicies.')}${todo([OPEN_TERM, `Enganxa aquesta ordre i prem Retorn:${cmdBox(INSTALL_CMD)}`, 'Quan acabi, aquesta pàgina es connectarà sola.'])}${live('Esperant nio…')}<p class="hint">${OTHER_OS}. Si ja el tens i està aturat, escriu <span class="mono">nio</span> al ${isWindows ? 'PowerShell' : 'Terminal'}.</p>${store.machines.length ? '' : '<div class="or"><span>o</span></div><button class="btn block" type="button" data-close data-act="demo">Prova una demo, sense instal·lar res</button>'}`);
898
903
  if (saved < 0 || !d) poll = setTimeout(local, 3000);
899
904
  };
900
905
  const runSsh = async (target) => {
@@ -1137,8 +1142,29 @@ function viewMemory() {
1137
1142
  if (!machine()) return `<div class="top"><div><h1>Memòria</h1><p>El que recorden els teus agents: preferències, decisions i context dels projectes.</p></div></div>${noMachine('La memòria són fitxers a l’ordinador. Connecta’n un per veure-la i editar-la.')}`;
1138
1143
  return `<div class="top"><div><h1>Memòria</h1><p>Preferències, decisions i context que els agents recorden en aquest ordinador. Obre un fitxer per consultar-lo o editar-lo.</p></div><button class="btn ink" data-act="mem-new" type="button">${svg(ICON.plus)}Fitxer nou</button></div>
1139
1144
  <form class="mem-search" id="memSearch" role="search"><input type="search" id="memQ" placeholder="Busca a la memòria…" aria-label="Busca a la memòria" autocomplete="off"><button class="btn ink" type="submit">Busca</button></form>
1145
+ <div id="memLearn">${learnHtml()}</div>
1140
1146
  <div id="memBody">${memHtml()}</div>`;
1141
1147
  }
1148
+ // La memòria que aprèn sola (learn.js): contradiccions per decidir, el que s'ha après i l'interruptor.
1149
+ let memLearn = null;
1150
+ function learnHtml() {
1151
+ const L = memLearn;
1152
+ if (!L || L.error) return '';
1153
+ const pend = L.pending.map((p) => `<div class="card mem-conflict"><div class="muted small"><span class="mono">${esc(p.file)}</span>${p.title ? ` · ${esc(p.title)}` : ''}</div>
1154
+ <div class="mem-old"><span class="muted small">Ara diu</span><s>${esc(p.old)}</s></div>
1155
+ <div class="mem-new"><span class="muted small">${p.new ? 'Hauria de dir' : 'Proposta'}</span>${p.new ? esc(p.new) : '<i>Esborrar-la (repetida)</i>'}</div>
1156
+ ${p.why ? `<div class="muted small">${esc(p.why)}</div>` : ''}
1157
+ <div class="row wrap"><button class="btn ink sm" type="button" data-mem-pick="${esc(p.id)}:nou">${p.new ? 'Fes servir el nou' : 'Esborra-la'}</button><button class="btn sm" type="button" data-mem-pick="${esc(p.id)}:antic">Mantén l’antic</button>${p.new ? `<button class="btn ghost sm" type="button" data-mem-pick="${esc(p.id)}:tots">Desa’ls tots dos</button>` : ''}</div></div>`).join('');
1158
+ const log = L.log.slice(0, 8).map((e) => `<div class="card row mem-learned"><span class="grow"><span class="small">${esc(e.text)}</span><div class="muted small"><span class="mono">${esc(e.file)}</span> · ${ago(e.t)}${e.replaced ? ' · substitueix una línia' : ''}</div></span><button class="btn ghost sm" type="button" data-mem-undo="${esc(e.id)}">Desfés</button></div>`).join('');
1159
+ return `${pend ? `<div class="section-title">Contradiccions per revisar</div><div class="list mem-learn">${pend}</div>` : ''}
1160
+ <div class="panel mem-auto"><label class="row check"><input type="checkbox" id="memAuto" ${L.auto ? 'checked' : ''}> <span><b>Aprèn sola de les sessions</b><span class="muted small block">Quan una sessió acaba, Claude (Haiku) en treu el que convé recordar. Les contradiccions no es canvien soles: te les deixa aquí.</span></span></label>
1161
+ <button class="btn sm" type="button" data-act="mem-review" ${L.reviewing ? 'disabled' : ''}>${L.reviewing ? 'Revisant…' : 'Revisa la memòria'}</button></div>
1162
+ ${log ? `<div class="section-title">Après fa poc</div><div class="list mem-learn">${log}</div>` : ''}`;
1163
+ }
1164
+ async function loadLearn() {
1165
+ try { memLearn = await api('/api/memory/learn'); } catch (e) { memLearn = { error: e.message }; }
1166
+ const box = $('#memLearn'); if (box) box.innerHTML = learnHtml();
1167
+ }
1142
1168
  function memHtml() {
1143
1169
  if (!mem) return `<div class="panel empty">${bot('work')}<h2>Carregant…</h2></div>`;
1144
1170
  if (mem.error) return `<div class="panel empty">${bot('sleep')}<h2>${esc(mem.error)}</h2><p>Cal nio 0.6 o més nou en aquest ordinador.</p></div>`;
@@ -1148,6 +1174,7 @@ function memHtml() {
1148
1174
  return Object.entries(groups).map(([g, fs]) => `${g ? `<div class="section-title">${esc(g)}</div>` : ''}<div class="list mem-list">${fs.map((f) => `<button class="card row" type="button" data-mem="${esc(f.path)}"><span class="grow"><b>${esc(f.path.split('/').pop().replace(/\.md$/, ''))}</b><div class="muted small">${ago(f.updated)} · ${bytes(f.size)}</div></span></button>`).join('')}</div>`).join('') || '<p class="muted">La memòria és buida.</p>';
1149
1175
  }
1150
1176
  async function loadMemory() {
1177
+ loadLearn();
1151
1178
  try { mem = await api('/api/memory'); } catch (e) { mem = { error: e.message }; }
1152
1179
  const box = $('#memBody'); if (box) box.innerHTML = memHtml();
1153
1180
  }
@@ -1174,6 +1201,20 @@ async function memEdit(p) {
1174
1201
  });
1175
1202
  }
1176
1203
  document.addEventListener('click', (e) => { const b = e.target.closest('[data-mem]'); if (b) memEdit(b.dataset.mem); });
1204
+ document.addEventListener('click', async (e) => {
1205
+ const pick = e.target.closest('[data-mem-pick]'), undo = e.target.closest('[data-mem-undo]');
1206
+ if (!pick && !undo) return;
1207
+ (pick || undo).disabled = true;
1208
+ try {
1209
+ if (pick) { const [id, choice] = pick.dataset.memPick.split(':'); await api(`/api/memory/pending/${id}`, { method: 'POST', body: { choice } }); toast(choice === 'antic' ? 'Es queda com estava' : 'Memòria actualitzada'); }
1210
+ else { await api(`/api/memory/log/${undo.dataset.memUndo}`, { method: 'DELETE' }); toast('Desfet'); }
1211
+ } catch (err) { toast(err.message); }
1212
+ loadMemory();
1213
+ });
1214
+ document.addEventListener('change', async (e) => {
1215
+ if (e.target.id !== 'memAuto') return;
1216
+ try { memLearn = await api('/api/memory/learn', { method: 'PUT', body: { auto: e.target.checked } }); toast(e.target.checked ? 'La memòria aprendrà de les sessions' : 'Aprenentatge automàtic aturat'); } catch (err) { e.target.checked = !e.target.checked; toast(err.message); }
1217
+ });
1177
1218
  document.addEventListener('submit', async (e) => {
1178
1219
  if (e.target.id !== 'memSearch') return;
1179
1220
  e.preventDefault(); const q = $('#memQ').value.trim(); if (!q) return loadMemory();
@@ -1530,7 +1571,7 @@ function eventNode(e) {
1530
1571
  case 'tool_result': return el(`<details class="tool"><summary><span class="name">${esc(e.name || 'Eina')}</span><span class="arg"></span><span class="st ${e.error ? 'err' : 'ok'}">${e.error ? '✕' : '✓'}</span></summary><pre class="out">${esc(e.output)}</pre></details>`);
1531
1572
  case 'approval': { const a = state.approvals.get(e.approval) || {}; return el(approvalCard({ ...a, approval: e.approval, tool: e.tool, input: e.input, state: e.state && e.state !== 'pending' ? e.state : a.state || e.state }, false)); }
1532
1573
  case 'error': return el(`<div class="ev-error" role="alert"><b>Error</b><span>${esc(e.text)}</span></div>`);
1533
- case 'info': return el(`<div class="ev-info">${esc(e.text)}</div>`);
1574
+ case 'info': return el(e.memory ? `<div class="ev-info">${esc(e.text)} · <button class="linkish" type="button" data-act="open-memory">Obre la memòria</button></div>` : `<div class="ev-info">${esc(e.text)}</div>`);
1534
1575
  case 'result': {
1535
1576
  const bits = [];
1536
1577
  if (e.ms) bits.push(`${(e.ms / 1000).toFixed(1).replace('.', ',')} s`);
@@ -2859,6 +2900,12 @@ document.addEventListener('click', async (e) => {
2859
2900
  case 'screen-now': return screenNow();
2860
2901
  case 'mem-new': return memEdit(null);
2861
2902
  case 'mem-all': if (mem) delete mem.hits; return loadMemory();
2903
+ case 'mem-review': {
2904
+ a.disabled = true; a.textContent = 'Revisant…';
2905
+ try { const r = await api('/api/memory/review', { method: 'POST' }); toast(r.found ? `${r.found} ${r.found === 1 ? 'cosa' : 'coses'} per revisar` : 'Cap contradicció ni duplicat'); } catch (err) { toast(err.message); }
2906
+ return loadMemory();
2907
+ }
2908
+ case 'open-memory': return go('#/memoria');
2862
2909
  case 'activity-refresh': activity = null; return loadActivity();
2863
2910
  case 'push-test': try { const r = await api('/api/push/test', { method: 'POST' }); toast(r.subs ? 'Enviat. Arribarà en uns segons.' : 'Primer activa els avisos'); } catch (err) { toast(err.message); } return;
2864
2911
  case 'add-computer': return addComputerSheet();
package/web/app/demo.js CHANGED
@@ -305,8 +305,15 @@
305
305
  const noteId = () => Math.random().toString(36).slice(2, 10);
306
306
  const MEMORY = {
307
307
  'preferencies.md': '# Preferències\n\n- Respon en català.\n- Fes commits petits i explica’ls bé.\n',
308
- 'projectes/web-botiga.md': '# web-botiga\n\nBotiga en línia feta amb Astro. Els colors són a src/styles.css.\n',
308
+ 'projectes/web-botiga.md': '# web-botiga\n\nBotiga en línia feta amb Astro. Els colors són a src/styles.css.\n- Es desplega fent push a main\n- Fa servir pnpm, no npm\n',
309
309
  };
310
+ // La memòria que aprèn sola: una contradicció per decidir i un fet après.
311
+ const LEARN = {
312
+ auto: true, reviewing: false, learning: 0,
313
+ pending: [{ id: 'c1', t: Date.now() - 3600000, file: 'projectes/web-botiga.md', title: 'Desplegament manual', old: 'Es desplega fent push a main', new: 'Es desplega a mà amb «vercel deploy --prod» (la integració de GitHub s’ha tret)' }],
314
+ log: [{ id: 'l1', t: Date.now() - 3600000, file: 'projectes/web-botiga.md', text: 'Fa servir pnpm, no npm' }],
315
+ };
316
+ const memLines = (f) => (MEMORY[f] || '').split('\n');
310
317
  const DIRS = { [ROOT]: ['web-botiga', 'api', 'notes'], [`${ROOT}/api`]: ['src', 'test'], [`${ROOT}/web-botiga`]: ['public', 'src'] };
311
318
 
312
319
  async function route(method, p, q, body) {
@@ -324,6 +331,7 @@
324
331
  if (/^\/api\/sessions\/[\w-]+\/git$/.test(p)) return { repo: false };
325
332
  if (p === '/api/projects') return [{ path: ROOT, name: 'projectes', root: true, git: false }, ...DIRS[ROOT].map((n) => ({ path: `${ROOT}/${n}`, name: n, root: false, git: n !== 'notes' }))];
326
333
  if (p === '/api/fs') { const d = (q.get('path') || ROOT).replace(/\/+$/, '') || ROOT; return { path: d, parent: d === '/Users/tu' ? null : d.replace(/\/[^/]+$/, '') || null, home: '/Users/tu', git: false, dirs: (DIRS[d] || []).map((n) => ({ name: n, path: `${d}/${n}`, hidden: false })) }; }
334
+ if (p === '/api/memory/learn') return LEARN;
327
335
  if (p === '/api/memory') return { dir: '~/.nio/memory', files: Object.entries(MEMORY).map(([path, c]) => ({ path, size: c.length, updated: Date.now() - 86400000 })) };
328
336
  if ((m = p.match(/^\/api\/memory\/f\/(.+)$/))) { const path = decodeURIComponent(m[1]); if (!(path in MEMORY)) throw err(404, 'Aquest fitxer no existeix'); return { path, content: MEMORY[path], updated: Date.now() - 86400000 }; }
329
337
  if (p === '/api/memory/search') { const t = (q.get('q') || '').toLowerCase(), r = []; for (const [path, c] of Object.entries(MEMORY)) c.split('\n').forEach((l, i) => l.toLowerCase().includes(t) && t && r.push({ path, line: i + 1, text: l })); return r; }
@@ -371,6 +379,17 @@
371
379
  }
372
380
  if (method === 'DELETE' && (m = p.match(/^\/api\/jobs\/([\w-]+)$/))) { const i = jobs.findIndex((x) => x.id === m[1]); if (i >= 0) jobs.splice(i, 1); out({ type: 'job', removed: m[1] }); return { ok: true }; }
373
381
  if (method === 'PUT' && p === '/api/control') { for (const k of ['navegador', 'escriptori']) if (typeof body[k] === 'boolean') caps[k].enabled = body[k]; return { ...caps, notes: [] }; }
382
+ if (p === '/api/memory/learn' && method === 'PUT') { LEARN.auto = !!body.auto; return LEARN; }
383
+ if (p === '/api/memory/review' && method === 'POST') { await sleep(1200); return { found: 0, pending: LEARN.pending }; }
384
+ if ((m = p.match(/^\/api\/memory\/pending\/([\w-]+)$/)) && method === 'POST') {
385
+ const c = LEARN.pending.find((x) => x.id === m[1]);
386
+ if (c && body.choice !== 'antic') { const ls = memLines(c.file); const i = ls.findIndex((l) => l.includes(c.old)); if (body.choice === 'nou' && i >= 0) ls[i] = `- ${c.new}`; else ls.push(`- ${c.new}`); MEMORY[c.file] = ls.join('\n'); }
387
+ LEARN.pending = LEARN.pending.filter((x) => x.id !== m[1]); return LEARN;
388
+ }
389
+ if ((m = p.match(/^\/api\/memory\/log\/([\w-]+)$/)) && method === 'DELETE') {
390
+ const e = LEARN.log.find((x) => x.id === m[1]); if (e) MEMORY[e.file] = memLines(e.file).filter((l) => !l.includes(e.text)).join('\n');
391
+ LEARN.log = LEARN.log.filter((x) => x.id !== m[1]); return { ok: true };
392
+ }
374
393
  if ((m = p.match(/^\/api\/memory\/f\/(.+)$/))) {
375
394
  if (method === 'PUT') { MEMORY[decodeURIComponent(m[1])] = String(body.content ?? ''); return { ok: true }; }
376
395
  if (method === 'DELETE') { delete MEMORY[decodeURIComponent(m[1])]; return { ok: true }; }
package/web/app/style.css CHANGED
@@ -709,6 +709,15 @@ dialog.sheet.wiz.compact .sheet-in, .compact .wiz-body { flex: 0 1 auto; }
709
709
  .mem-list .card { color: var(--fg); }
710
710
  .mem-list .card b { color: var(--fg); }
711
711
  .section-title + .mem-list { margin-bottom: 8px; }
712
+ .mem-learn { gap: 10px; margin-bottom: 20px; }
713
+ .mem-conflict { display: grid; gap: 8px; color: var(--fg); cursor: default; }
714
+ .mem-conflict .mem-old, .mem-conflict .mem-new { display: grid; gap: 2px; padding: 8px 10px; border-radius: 10px; background: var(--bg-2, rgba(127, 127, 127, .08)); }
715
+ .mem-conflict .mem-old s { text-decoration-color: var(--fg-muted); }
716
+ .mem-learned { color: var(--fg); cursor: default; align-items: flex-start; }
717
+ .mem-auto { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; padding: 14px 16px; margin: 0 0 24px; }
718
+ .mem-auto .check { align-items: flex-start; flex: 1 1 260px; flex-wrap: nowrap; }
719
+ .mem-auto .check input { margin-top: 2px; flex: none; }
720
+ .mem-auto .block { display: block; margin-top: 2px; }
712
721
 
713
722
  /* Capçalera d'una secció dins d'una pàgina */
714
723
  .sec-head { display: flex; flex-direction: column; align-items: flex-start; gap: 12px; margin: 36px 0 14px; }
package/web/index.html CHANGED
@@ -68,6 +68,8 @@ h1 { font-size: clamp(38px, 6.4vw, 68px); font-weight: 500; line-height: 1.04; l
68
68
  .install code { background: none; border-radius: 0; flex: 1; min-width: 0; overflow-x: auto; white-space: nowrap; padding: 9px 0; font: 500 15.5px ui-monospace, "SF Mono", Menlo, monospace; scrollbar-width: none; }
69
69
  .install code::-webkit-scrollbar { display: none; }
70
70
  .hero .alt { margin-top: 14px; font-size: 15px; color: var(--fg-muted); }
71
+ .hero .os-sw { border: 0; padding: 0; background: none; font: inherit; color: inherit; text-decoration: underline; text-underline-offset: 3px; cursor: pointer; }
72
+ .hero .os-sw:hover { color: var(--fg); }
71
73
  .copy { flex: none; display: inline-grid; place-items: center; width: 42px; height: 42px; border: 0; border-radius: 11px; background: rgba(255,255,255,.1); color: inherit; cursor: pointer; transition: background-color .2s, transform .12s; }
72
74
  .copy:hover { background: rgba(255,255,255,.2); }
73
75
  .copy:active { transform: scale(.95); }
@@ -203,7 +205,7 @@ main { padding-bottom: 8px; }
203
205
  <h1>Els teus agents, a les teves màquines, des del mòbil.</h1>
204
206
  <p class="lead">nio és un comandament per a Claude Code, Codex i altres agents. Treballen als teus ordinadors, amb els teus comptes i les teves claus. Tu els dones feina des del mòbil, els segueixes en directe i aproves el que és arriscat. Personal i gratuït.</p>
205
207
  <div class="install"><code id="cmd">curl -fsSL https://www.nio.cat/install.sh | sh</code><button class="copy" type="button" data-copy="curl -fsSL https://www.nio.cat/install.sh | sh" aria-label="Copia l’ordre"><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/></svg><svg class="ok" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></button></div>
206
- <p class="alt"><span>Linux o Mac.</span> <span>Per provar-ho sense instal·lar: <code>npx nio-cat</code></span></p>
208
+ <p class="alt"><span><span id="osTxt">Linux o Mac.</span> <button class="os-sw" id="osSw" type="button">Tens Windows?</button></span> <span>Per provar-ho sense instal·lar: <code>npx nio-cat</code></span></p>
207
209
  </div>
208
210
 
209
211
  <section aria-labelledby="com">
@@ -255,6 +257,20 @@ main { padding-bottom: 8px; }
255
257
  </footer>
256
258
  <script src="/bot.js" defer></script>
257
259
  <script>
260
+ // A Windows, l'ordre de PowerShell. Es pot canviar a mà (per instal·lar-lo en un altre ordinador).
261
+ (() => {
262
+ const CMD = { unix: 'curl -fsSL https://www.nio.cat/install.sh | sh', win: 'irm https://www.nio.cat/install.ps1 | iex' };
263
+ const code = document.getElementById('cmd'), copy = code.nextElementSibling, txt = document.getElementById('osTxt'), sw = document.getElementById('osSw');
264
+ const set = (os) => {
265
+ code.textContent = CMD[os]; copy.dataset.copy = CMD[os]; sw.dataset.os = os;
266
+ txt.textContent = os === 'win' ? 'Windows, al PowerShell.' : 'Linux o Mac.';
267
+ sw.textContent = os === 'win' ? 'Tens Mac o Linux?' : 'Tens Windows?';
268
+ };
269
+ sw.onclick = () => set(sw.dataset.os === 'win' ? 'unix' : 'win');
270
+ set(navigator.userAgentData?.platform === 'Windows' || /Windows/.test(navigator.userAgent) ? 'win' : 'unix');
271
+ })();
272
+ </script>
273
+ <script>
258
274
  document.addEventListener('click', async (e) => {
259
275
  const b = e.target.closest('[data-copy]'); if (!b) return;
260
276
  try { await navigator.clipboard.writeText(b.dataset.copy); }
package/web/sw.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // nio · service worker: obre l'app encara que no hi hagi connexió i mostra els avisos.
2
- const SHELL = 'nio-shell-v61';
2
+ const SHELL = 'nio-shell-v63';
3
3
  const FILES = ['/app', '/app/style.css', '/app/notes.css', '/app/call.css', '/app/app.js', '/app/notes.js', '/app/notes-live.js', '/app/call.js', '/app/demo.js', '/app/android.js', '/bot.css', '/bot.js', '/board.css', '/board.js', '/favicon.svg', '/manifest.webmanifest'];
4
4
 
5
5
  self.addEventListener('install', (e) => {