nio-cat 0.9.15 → 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.15",
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",
@@ -25,7 +25,9 @@ module.exports = {
25
25
  if (session.nativeId && session.forkNative) { args.push('--fork-session'); delete session.forkNative; }
26
26
  if (session.model) args.push('--model', session.model);
27
27
  // Que l'agent posi un títol que resumeixi la feina (eina MCP de nio; el hub el desa).
28
- 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}` : ''}`);
29
31
 
30
32
  // Com a root (un VPS), Claude Code no accepta bypassPermissions: «Via lliure» hi fa servir el mode auto,
31
33
  // que també treballa sol però fa revisar cada acció a un classificador (el mateix que el Claude Code remot).
@@ -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/hub.js CHANGED
@@ -335,6 +335,7 @@ class Hub extends EventEmitter {
335
335
  s.state = code === 130 || code === 143 ? 'idle' : code && code !== 0 ? 'error' : 'idle';
336
336
  this.push(s, 'status', { state: s.state, code });
337
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)
338
339
  },
339
340
  };
340
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({});
package/web/app/app.js CHANGED
@@ -1142,8 +1142,29 @@ function viewMemory() {
1142
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.')}`;
1143
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>
1144
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>
1145
1146
  <div id="memBody">${memHtml()}</div>`;
1146
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
+ }
1147
1168
  function memHtml() {
1148
1169
  if (!mem) return `<div class="panel empty">${bot('work')}<h2>Carregant…</h2></div>`;
1149
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>`;
@@ -1153,6 +1174,7 @@ function memHtml() {
1153
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>';
1154
1175
  }
1155
1176
  async function loadMemory() {
1177
+ loadLearn();
1156
1178
  try { mem = await api('/api/memory'); } catch (e) { mem = { error: e.message }; }
1157
1179
  const box = $('#memBody'); if (box) box.innerHTML = memHtml();
1158
1180
  }
@@ -1179,6 +1201,20 @@ async function memEdit(p) {
1179
1201
  });
1180
1202
  }
1181
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
+ });
1182
1218
  document.addEventListener('submit', async (e) => {
1183
1219
  if (e.target.id !== 'memSearch') return;
1184
1220
  e.preventDefault(); const q = $('#memQ').value.trim(); if (!q) return loadMemory();
@@ -1535,7 +1571,7 @@ function eventNode(e) {
1535
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>`);
1536
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)); }
1537
1573
  case 'error': return el(`<div class="ev-error" role="alert"><b>Error</b><span>${esc(e.text)}</span></div>`);
1538
- 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>`);
1539
1575
  case 'result': {
1540
1576
  const bits = [];
1541
1577
  if (e.ms) bits.push(`${(e.ms / 1000).toFixed(1).replace('.', ',')} s`);
@@ -2864,6 +2900,12 @@ document.addEventListener('click', async (e) => {
2864
2900
  case 'screen-now': return screenNow();
2865
2901
  case 'mem-new': return memEdit(null);
2866
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');
2867
2909
  case 'activity-refresh': activity = null; return loadActivity();
2868
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;
2869
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/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-v62';
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) => {