File size: 38,742 Bytes
b30b7c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4bd8362
 
 
b30b7c5
 
 
 
 
4bd8362
b30b7c5
 
 
 
 
4bd8362
 
b30b7c5
4bd8362
 
b30b7c5
4bd8362
b30b7c5
 
 
4bd8362
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b30b7c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4bd8362
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b30b7c5
 
4bd8362
 
b30b7c5
4bd8362
 
b30b7c5
 
 
 
 
4bd8362
 
 
b30b7c5
 
 
4bd8362
b30b7c5
 
 
 
d6605e7
b30b7c5
 
 
 
 
d6605e7
b30b7c5
d6605e7
b30b7c5
 
 
 
 
d6605e7
b30b7c5
d6605e7
b30b7c5
 
 
d6605e7
 
 
 
 
 
b30b7c5
 
 
 
 
 
 
 
 
 
 
 
 
4bd8362
b30b7c5
 
 
 
4bd8362
 
 
 
 
b30b7c5
 
4bd8362
 
 
 
 
 
 
 
 
 
 
 
 
b30b7c5
 
 
4bd8362
 
 
b30b7c5
 
4bd8362
b30b7c5
 
 
 
 
 
 
 
 
73063cc
 
 
4bd8362
b30b7c5
 
 
 
4bd8362
b30b7c5
 
 
 
 
 
 
d305959
 
 
 
 
 
b30b7c5
d305959
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b30b7c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d305959
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b30b7c5
d305959
b30b7c5
 
d305959
 
 
5314ddc
 
 
 
 
 
 
 
 
d305959
5314ddc
 
 
b30b7c5
d305959
 
 
 
 
 
 
 
 
 
b30b7c5
 
 
d305959
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b30b7c5
d305959
 
 
b30b7c5
 
5314ddc
 
 
 
 
 
73063cc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d305959
 
 
 
 
 
5314ddc
73063cc
 
 
 
 
d305959
 
73063cc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d305959
 
73063cc
5314ddc
 
73063cc
 
d305959
5314ddc
73063cc
5314ddc
73063cc
5314ddc
73063cc
5314ddc
 
 
 
d305959
 
 
 
 
 
 
 
 
 
5314ddc
 
d305959
 
 
5314ddc
73063cc
 
 
 
 
 
 
d305959
 
5314ddc
d305959
 
 
b30b7c5
 
5314ddc
 
 
 
73063cc
 
 
 
 
b30b7c5
 
 
 
4bd8362
73063cc
5314ddc
b30b7c5
 
5314ddc
b30b7c5
 
 
5314ddc
b30b7c5
4bd8362
 
 
 
 
 
 
73063cc
b30b7c5
73063cc
 
 
 
 
b30b7c5
 
5314ddc
 
 
 
 
 
 
b30b7c5
 
 
73063cc
 
 
 
 
 
4bd8362
 
 
5314ddc
4bd8362
 
 
b30b7c5
d305959
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
// Herramientas de proyecto: el único mundo que toca Elffuss Code es la
// carpeta de código que el usuario abre con el picker nativo.
import * as db from '../db.js';

let projectHandle = null;
let projectName = '';
let currentFile = null;              // ruta abierta en el editor
let onFileWritten = () => {};        // el IDE refresca pestañas/árbol

export function setOnFileWritten(fn) { onFileWritten = fn; }
export function setCurrentFile(path) { currentFile = path; }
export function current() { return { projectName, currentFile }; }
export function handle() { return projectHandle; }

// Lee un archivo de texto dentro de un dir handle por ruta (soporta subdirs).
async function readIn(dir, path) {
  const parts = path.split('/'); const name = parts.pop();
  for (const p of parts) dir = await dir.getDirectoryHandle(p);
  return (await (await dir.getFileHandle(name)).getFile()).text();
}

// Integración git SIN dependencias: parsea el .git directamente (rama, último
// commit con su SHA y el remoto «origin»). Suficiente para orientar; commitear
// se le pide al agente/terminal.
export async function gitInfo() {
  if (!projectHandle) return { isRepo: false };
  let git;
  try { git = await projectHandle.getDirectoryHandle('.git'); }
  catch { return { isRepo: false }; }
  const out = { isRepo: true, branch: '(detached)', lastCommit: null, web: null };
  try {
    const head = (await readIn(git, 'HEAD')).trim();
    out.branch = head.match(/ref:\s*refs\/heads\/(.+)/)?.[1] || head.slice(0, 7);
  } catch { /* sin HEAD */ }
  try {
    // «<viejo> <nuevo> Autor <correo> <ts> <tz>\t<mensaje>»: el SHA del commit
    // es el SEGUNDO campo (antes se tiraba). El correo no se guarda: no se enseña.
    const last = (await readIn(git, 'logs/HEAD')).trim().split('\n').pop();
    const m = last.match(/^\S+ (\S+) (.+?) <[^>]*> (\d+)[^\t]*\t(.+)$/);
    if (m) out.lastCommit = { sha: /^[0-9a-f]{7,64}$/i.test(m[1]) ? m[1] : null, author: m[2], when: new Date(+m[3] * 1000), msg: m[4] };
  } catch { /* sin logs (repo recién creado) */ }
  try { out.web = remoteWeb(await readIn(git, 'config')); } catch { /* sin config */ }
  return out;
}

// La URL web del remoto «origin», SIN credenciales (https://usuario:token@…
// no puede acabar en un enlace) y con los enlaces de rama y commit según el
// tipo de forja. null si no hay remoto o no es http(s)/ssh reconocible.
export function remoteWeb(config) {
  const sec = String(config || '').split(/^\s*\[/m).find(b => /^remote\s+"origin"\s*\]/.test(b));
  const raw = sec?.match(/^\s*url\s*=\s*(\S+)/m)?.[1];
  if (!raw) return null;
  let host, path;
  const ssh = raw.match(/^(?:ssh:\/\/)?[\w.-]+@([\w.-]+)(?::\d+)?[:/](.+)$/);
  if (/^https?:\/\//i.test(raw)) {
    try { const u = new URL(raw); host = u.hostname; path = u.pathname; } catch { return null; }
  } else if (ssh) { host = ssh[1]; path = ssh[2]; }
  else return null;
  path = path.replace(/^\/+/, '').replace(/\.git\/?$/, '').replace(/\/+$/, '');
  if (!host || !/^[\w.-]+\/[\w.\-/]+$/.test(path)) return null;
  const base = `https://${host}/${path}`;
  const kind = /(^|\.)github\.com$/i.test(host) ? 'github' : /(^|\.)gitlab\.com$/i.test(host) ? 'gitlab'
    : /(^|\.)bitbucket\.org$/i.test(host) ? 'bitbucket' : 'gitea';
  const enc = s => String(s).split('/').map(encodeURIComponent).join('/');
  return {
    base, kind,
    branch: b => kind === 'gitea' ? `${base}/src/branch/${enc(b)}` : kind === 'bitbucket' ? `${base}/src/${enc(b)}` : kind === 'gitlab' ? `${base}/-/tree/${enc(b)}` : `${base}/tree/${enc(b)}`,
    commit: sha => kind === 'bitbucket' ? `${base}/commits/${sha}` : kind === 'gitlab' ? `${base}/-/commit/${sha}` : `${base}/commit/${sha}`,
  };
}

// Lista plana de archivos (para el command palette). Cacheada por proyecto.
let fileListCache = null;
export function invalidateFileList() { fileListCache = null; }
export async function fileList() {
  if (fileListCache) return fileListCache;
  if (!projectHandle) return [];
  const files = [];
  async function walk(dir, prefix, depth) {
    if (depth > 8 || files.length > 4000) return;
    for await (const e of dir.values()) {
      if (IGNORE.has(e.name)) continue;
      const p = prefix ? prefix + '/' + e.name : e.name;
      if (e.kind === 'directory') await walk(e, p, depth + 1);
      else files.push(p);
    }
  }
  await walk(projectHandle, '', 0);
  fileListCache = files;
  return files;
}

export async function openProject(handle) {
  projectHandle = handle;
  projectName = handle.name || 'proyecto';
  await db.set('kv', 'project', handle).catch(() => {});
  return projectName;
}

// Reabrir el último proyecto (el navegador puede exigir un gesto para re-conceder).
export async function restoreProject() {
  const h = await db.get('kv', 'project').catch(() => null);
  if (!h) return null;
  const q = h.queryPermission ? await h.queryPermission({ mode: 'readwrite' }) : 'granted';
  if (q === 'granted') {
    projectHandle = h;
    projectName = h.name || 'proyecto';
    return { name: projectName, ready: true };
  }
  return { name: h.name, ready: false, handle: h };
}

export async function regrant(h) {
  if (await h.requestPermission({ mode: 'readwrite' }) !== 'granted')
    throw new Error('permiso denegado');
  return openProject(h);
}

const IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'target', '__pycache__', '.next', 'venv', '.venv', '.DS_Store']);
const MAX_READ = 60_000;

// Normaliza rutas del modelo: quita ./ y / iniciales y segmentos '.'
const normalize = path => (path || '').split('/').filter(p => p && p !== '.');

async function dirOf(path, { create = false } = {}) {
  if (!projectHandle) throw new Error('No hay proyecto abierto');
  const parts = normalize(path);
  const name = parts.pop();
  let dir = projectHandle;
  for (const p of parts) dir = await dir.getDirectoryHandle(p, { create });
  return { dir, name };
}

// Buscar archivos por nombre (para sugerir cuando el modelo inventa rutas, o
// para resolver un enlace del chat que solo mencionaba el nombre del fichero).
// Busca por NOMBRE de fichero en todo el proyecto.
//
// El tope era `visited > 500`, y ESE era el bug: se pidió «obj_1_Zusammenbau.stl»,
// el fichero EXISTÍA en una subcarpeta, y el recorrido se rendía antes de llegar.
// El error decía «no existe en este proyecto» —una afirmación falsa— y encima
// sugería otro fichero cualquiera, el primero que hubiera cabido en el presupuesto.
//
// Sube a 20.000 entradas, que es un proyecto grande de verdad, y cuando se agota
// se DICE: «no está» y «me he rendido» no son lo mismo. Con lo primero el modelo
// deja de intentarlo.
export const FIND_MAX_VISITED = 20000;
// `tope` es inyectable SOLO para que el banco pueda forzar el caso «me he
// rendido»: el orden de recorrido de OPFS es arbitrario, así que sembrar muchos
// ficheros no garantiza agotar nada y el control negativo saldría por suerte.
export async function findByName(basename, limit = 5, tope = FIND_MAX_VISITED) {
  const hits = [];
  let visited = 0;
  let agotado = false;
  const diana = basename.toLowerCase();
  async function walk(dir, prefix) {
    if (hits.length >= limit) return;
    if (visited > tope) { agotado = true; return; }
    for await (const e of dir.values()) {
      if (IGNORE.has(e.name)) continue;
      visited++;
      const p = prefix ? prefix + '/' + e.name : e.name;
      if (e.kind === 'directory') await walk(e, p);
      else if (e.name.toLowerCase() === diana) hits.push(p);
      if (hits.length >= limit) return;
      if (visited > tope) { agotado = true; return; }
    }
  }
  await walk(projectHandle, '');
  hits.agotado = agotado;
  return hits;
}

// Árbol de texto (para el modelo y el CONTEXTO). Ignora dependencias/binarios.
const TREE_MAX = 800;
export async function tree({ path = '', depth = 3 } = {}) {
  if (!projectHandle) throw new Error('No hay proyecto abierto');
  let root = projectHandle;
  for (const p of normalize(path)) root = await root.getDirectoryHandle(p);
  const out = [];
  let count = 0, truncated = false;
  async function walk(dir, prefix, d) {
    if (d > depth || count >= TREE_MAX) return;
    const entries = [];
    for await (const e of dir.values()) entries.push(e);
    entries.sort((a, b) => (a.kind !== b.kind) ? (a.kind === 'directory' ? -1 : 1) : a.name.localeCompare(b.name));
    for (const e of entries) {
      if (IGNORE.has(e.name)) continue;
      if (++count > TREE_MAX) { truncated = true; return; }
      out.push(prefix + (e.kind === 'directory' ? '📁 ' : '') + e.name);
      if (e.kind === 'directory') { await walk(e, prefix + '  ', d + 1); if (truncated) return; }
    }
  }
  await walk(root, '', 1);
  let res = out.join('\n') || '(vacío)';
  // Corte con AVISO y guía (como read/search): un «…» pelado se lee como «esto
  // es todo» y el modelo trabaja sobre un árbol incompleto sin saberlo.
  if (truncated)
    res += `\n… árbol recortado (>${TREE_MAX} entradas). Acota con code.tree({path:"subcarpeta"}) o localiza con code.search.`;
  return res;
}

const PAGE_SIZE = 100, PAGE_MAX = 500;

// Lectura completa (comportamiento de siempre) O por páginas de líneas — para
// ficheros grandes, o para plantarse justo en el número de línea que dio
// code.search y traer contexto antes/después sin volcar el fichero entero.
//   offset: línea 1-based donde empezar (con limit, PAGE_SIZE por defecto)
//   around: centra la página en esa línea (offset = around - limit/2)
// Sin offset/limit/around → fichero completo tal cual (no rompe nada que ya
// dependa de leerlo entero, p.ej. code.edit).
export async function read({ path, offset, limit, around } = {}) {
  if (!path) throw new Error('Falta path');
  let file, leido = path;
  try {
    const { dir, name } = await dirOf(path);
    file = await (await dir.getFileHandle(name)).getFile();
  } catch {
    // No está en la ruta pedida. Antes aquí se lanzaba SIEMPRE, aunque la
    // búsqueda hubiera encontrado el fichero: te decía «¿quizá X?» y te hacía
    // gastar otro turno en pedir X. Si hay UNA sola coincidencia exacta de
    // nombre, se lee y se dice por qué ruta; si hay varias, se eligen a mano
    // porque adivinar la equivocada es peor que preguntar.
    const base = normalize(path).pop() || path;
    const hits = await findByName(base).catch(() => []);
    if (hits.length === 1) {
      try {
        const { dir, name } = await dirOf(hits[0]);
        file = await (await dir.getFileHandle(name)).getFile();
        leido = hits[0];
      } catch { /* se cayó también: abajo se informa */ }
    }
    if (!file) throw new Error(
      (hits.agotado && !hits.length
        ? `no he podido recorrer el proyecto entero buscando «${base}» (demasiadas entradas), así que NO sé si existe.`
        : `no existe «${path}» en este proyecto.`) +
      (hits.length > 1 ? ` Hay ${hits.length} con ese nombre: ${hits.join(' · ')} — repite con la ruta completa.` : '') +
      (hits.agotado && hits.length ? ' (búsqueda incompleta: puede haber más)' : '') +
      ' Consulta el árbol (code.tree) o busca (code.search) antes de leer.');
  }
  const full = await file.text();
  // Si se resolvió por nombre, el modelo tiene que ENTERARSE de la ruta real:
  // su siguiente code.edit usa la que él escribió y fallaría.
  const aviso = leido === path ? '' : `(«${path}» no existe tal cual; leo ${leido})\n`;

  if (offset == null && limit == null && around == null) {
    return aviso + (full.length > MAX_READ ? full.slice(0, MAX_READ) + `\n… (recortado, ${file.size} bytes)` : full);
  }

  const lines = full.split('\n');
  const total = lines.length;
  const size = Math.max(1, Math.min(Math.round(limit) || PAGE_SIZE, PAGE_MAX));
  const start = around != null
    ? Math.max(1, Math.round(around) - Math.floor(size / 2))
    : Math.max(1, Math.round(offset) || 1);
  const startIdx = start - 1;
  // Pedir más allá del final devolvía un rango imposible («líneas 999-998 de 6»)
  // y CERO contenido: el modelo se quedaba sin nada que hacer. Mejor decírselo.
  if (startIdx >= total)
    return `${leido}: solo tiene ${total} líneas y pediste desde la ${start} — usa offset entre 1 y ${total}.`;
  const slice = lines.slice(startIdx, startIdx + size);
  const endLine = startIdx + slice.length;
  const numbered = slice.map((l, i) => `${start + i}→${l}`).join('\n');
  const more = endLine < total ? `\n… quedan líneas ${endLine + 1}-${total} — pide code.read con offset:${endLine + 1} para seguir` : '';
  return `${leido}: líneas ${start}-${endLine} de ${total}\n${numbered}${more}`;
}

// Aprobación de escritura: si «Auto» está apagado, el IDE pide confirmación
// antes de que la elfa toque archivos (como «Edit automatically» del plugin).
let approveWrite = async () => true;
export function setWriteApprover(fn) { approveWrite = fn; }

// `write` reescribe el fichero ENTERO, así que es la vía por la que se pierde
// código. Medido: tras dos rechazos de la guarda de sintaxis en `edit`, el
// modelo se escapó por aquí y reescribió el fichero. En uno de cuatro líneas da
// igual; en uno de seiscientas, así es como desaparece medio proyecto.
// `_interno` lo pone `edit`, que ya ha comprobado lo suyo sobre el texto exacto.
export async function write({ path, content = '', _interno = false } = {}) {
  if (!path) throw new Error('Falta path');
  if (!_interno) {
    let previo = null;
    try { const { dir, name } = await dirOf(path); previo = await (await (await dir.getFileHandle(name)).getFile()).text(); }
    catch { /* fichero nuevo: nada que preservar */ }
    if (previo !== null) {
      const antes = previo.split('\n').length, ahora = content.split('\n').length;
      // Un fichero con cuerpo que se queda en menos de la mitad casi nunca es
      // intencionado: es el modelo reescribiendo de memoria lo que no recuerda.
      if (antes >= 40 && ahora < antes * 0.5) {
        anota('truncaria', path, { antes, ahora });
        throw new Error(`Eso dejaría ${path} en ${ahora} líneas cuando tiene ${antes}: perderías el resto del fichero. ` +
          `Para cambiar una parte usa code.edit con «search» y «replace»; code.write solo para ficheros nuevos o reescrituras completas de verdad.`);
      }
      revisaSintaxis(path, previo, content);
    }
  }
  if (!await approveWrite(path, content)) return `Cambio en ${path} rechazado por el usuario`;
  const { dir, name } = await dirOf(path, { create: true });
  const fh = await dir.getFileHandle(name, { create: true });
  const w = await fh.createWritable();
  await w.write(content);
  await w.close();
  invalidateFileList();
  onFileWritten(path, content);
  return `Escrito ${path} (${content.split('\n').length} líneas)`;
}

// Edición PARCIAL (no hace falta reescribir el fichero entero): sustituye
// `search` por `replace`. Primero intenta coincidencia EXACTA (rápida, sin
// ambigüedad); si no la encuentra tal cual —el modelo puede recordar mal un
// espacio o un salto de línea—, cae a un parcheado difuso (diff-match-patch,
// la misma librería que usan herramientas como aider) que localiza el punto
// más parecido dentro del fichero real. Si ni así hay confianza suficiente,
// falla con un mensaje claro para que el agente reintente con más contexto
// — nunca escribe una coincidencia dudosa.
// Telemetría de edición: por qué vía se resolvió cada intento. Sin esto,
// «la edición falla» no es accionable — no sabes si el modelo cita mal, si
// cita ambiguo o si no encuentra el sitio.
export const editStats = { total: 0, exacta: 0, bloque: 0, nucleo: 0, difusa: 0, ambigua: 0, noEncontrado: 0, sinCambio: 0, noExiste: 0, rompeSintaxis: 0, yaAplicado: 0, truncaria: 0, detalle: [] };
export function resetEditStats() {
  editStats.total = editStats.exacta = editStats.bloque = editStats.nucleo = editStats.difusa = 0;
  editStats.ambigua = editStats.noEncontrado = editStats.sinCambio = editStats.noExiste = editStats.rompeSintaxis = editStats.yaAplicado = editStats.truncaria = 0;
  editStats.detalle.length = 0;
}
const anota = (via, path, extra) => { editStats[via]++; editStats.detalle.push({ via, path, ...extra }); };

// read() paginado devuelve «12→código» para que el modelo pueda pedir un
// offset; el efecto secundario es que el modelo copia ESE texto como «search»,
// con el número pegado delante. Entonces la cita no existe en el fichero y la
// edición no puede encajar NUNCA — un fichero que pasa de una página se vuelve
// ineditable. Se los quitamos, pero solo si TODAS las líneas los llevan: así
// una flecha suelta dentro del código de verdad no se toca.
const SIN_NUMEROS = /^[ \t]*\d+→/;
// Exigir que TODAS las líneas lleven número no valía: un modelo pequeño numera
// unas sí y otras no, la limpieza no se disparaba y la cita no podía casar. Con
// mayoría basta, y «\d+→» al principio de línea es lo bastante raro en código
// real como para no llevarse nada por delante. Se mira search y replace por
// separado: si los números se colaran en «replace» acabarían ESCRITOS dentro
// del fichero, que es peor que no editar.
function quitaNumerosDeLinea(txt) {
  if (txt == null) return txt;
  const lineas = txt.split('\n');
  const conCuerpo = lineas.filter(l => l.trim());
  if (!conCuerpo.length) return txt;
  const numeradas = conCuerpo.filter(l => SIN_NUMEROS.test(l)).length;
  return numeradas / conCuerpo.length >= 0.6 ? lineas.map(l => l.replace(SIN_NUMEROS, '')).join('\n') : txt;
}

// Una edición que deja el fichero sintácticamente roto es peor que una que no
// se aplica: el modelo dice «arreglado» y lo que hay es un fichero que ya ni
// carga. Caso real medido: el modelo sustituyó la línea de la firma por una
// función entera CON su llave de cierre, dejando huérfano el cuerpo de antes
// («Illegal return statement»). new Function() solo PARSEA, no ejecuta; para
// que trague un módulo hay que quitarle import/export, que son sintaxis de
// módulo. Solo se rechaza si el fichero estaba BIEN antes: si ya venía roto,
// la edición es justamente lo que viene a arreglarlo.
const ES_JS = /\.(js|mjs|cjs|jsx)$/i;
function seParsea(codigo) {
  const sinModulo = codigo
    .replace(/^\s*import\s+[^;]*;?\s*$/gm, '')
    .replace(/^\s*export\s+default\s+/gm, 'void ')
    .replace(/^\s*export\s*\{[^}]*\}\s*;?\s*$/gm, '')
    .replace(/^(\s*)export\s+/gm, '$1');
  try { new Function(sinModulo); return true; } catch (e) { return e instanceof SyntaxError ? String(e.message) : true; }
}
function revisaSintaxis(path, antes, despues) {
  if (!ES_JS.test(path)) return;
  if (seParsea(antes) !== true) return;          // ya estaba roto: no estorbar
  const mal = seParsea(despues);
  if (mal !== true) {
    anota('rompeSintaxis', path, { error: String(mal).slice(0, 80) });
    throw new Error(`Esa edición dejaría ${path} sin poder cargarse (${mal}). ` +
      `Suele pasar por cerrar una llave de más: revisa que «replace» encaje con lo que rodea a «search», ` +
      `o incluye en «search» el bloque completo que vas a sustituir.`);
  }
}

export async function edit({ path, search, replace } = {}) {
  editStats.total++;
  if (!path) throw new Error('Falta path');
  if (search == null || replace == null) throw new Error('Faltan search y replace');
  // Cada uno por su cuenta: el modelo puede numerar solo uno de los dos.
  search = quitaNumerosDeLinea(search);
  replace = quitaNumerosDeLinea(replace);
  // Contenido COMPLETO, sin el tope de MAX_READ. edit reescribe el fichero
  // entero, así que leer la vista recortada de read() truncaría todo lo que
  // hubiese más allá de MAX_READ (bug histórico: editar un fichero >60KB lo
  // dejaba en 60KB y metía el marcador «… (recortado)» dentro del código).
  let current;
  try {
    const { dir, name } = await dirOf(path);
    current = await (await (await dir.getFileHandle(name)).getFile()).text();
  } catch {
    anota('noExiste', path, {});
    await read({ path });   // relanza el error «que enseña» (sugiere code.tree/search); nunca retorna
    throw new Error(`no existe «${path}»`);
  }

  // Reintentar sobre algo YA hecho era un error igual que no encontrarlo, así
  // que el modelo volvía a intentarlo en bucle. Si el resultado ya está y la
  // cita ya no, la edición sobra: decirlo como estado y seguir. Se exige el
  // «replace» COMPLETO presente y el «search» ausente, para no dar por buena
  // una edición que no ocurrió.
  if (replace.trim().length >= 8 && current.includes(replace) && !current.includes(search)) {
    anota('yaAplicado', path, {});
    return `Ese cambio ya está en ${path} — no hace falta editar. Sigue con lo siguiente.`;
  }

  const first = current.indexOf(search);
  if (first !== -1) {
    if (current.indexOf(search, first + 1) !== -1) {
      anota('ambigua', path, { fase: 'exacta' });
      // decir DÓNDE está, igual que en las vías difusas: pedir «más contexto»
      // a secas hace que un modelo pequeño cite más largo y peor.
      const lineas = [];
      for (let i = current.indexOf(search); i !== -1 && lineas.length < 8; i = current.indexOf(search, i + 1))
        lineas.push(current.slice(0, i).split('\n').length);
      throw new Error(`«search» aparece ${lineas.length} veces en ${path} (líneas ${lineas.join(', ')}). ` +
        `Cita un bloque que incluya alguna línea que solo exista en el sitio que quieres cambiar.`);
    }
    const siguiente = current.slice(0, first) + replace + current.slice(first + search.length);
    // La vía difusa ya comprobaba esto abajo; la exacta no. Un search idéntico
    // al replace escribía el mismo fichero y devolvía ÉXITO, así que el agente
    // daba la tarea por arreglada sin haber cambiado una coma.
    if (siguiente === current) {
      anota('sinCambio', path, { fase: 'exacta' });
      throw new Error(`La edición en ${path} no cambió nada: «search» y «replace» son iguales. Escribe en «replace» el código YA corregido.`);
    }
    revisaSintaxis(path, current, siguiente);
    anota('exacta', path, {});
    return write({ path, content: siguiente, _interno: true });
  }

  // fallback difuso, LOCAL y por líneas (sin depender de esm.sh en tiempo de
  // edición — eso rompería la edición sin conexión, justo lo contrario del
  // producto). El error típico del modelo al recordar «search» es la sangría o
  // los espacios; localizamos el bloque normalizando espacios y, si hace falta,
  // por parecido de líneas — y sustituimos ESE bloque, esté donde esté (también
  // en lo hondo de un fichero grande).
  const norm = s => s.replace(/[ \t]+/g, ' ').trim();   // .trim() se lleva también el \r final
  // Partimos guardando el offset de carácter de cada línea: así el bloque se
  // sustituye por CORTE EXACTO y todo lo de fuera queda byte a byte igual
  // (incluidos sus finales de línea). Con split/join se reescribía el fichero
  // entero y un fichero CRLF acababa con finales de línea MEZCLADOS.
  const curLines = [], lineStart = [];
  for (let i = 0; i <= current.length;) {
    const nl = current.indexOf('\n', i);
    lineStart.push(i);
    if (nl === -1) { curLines.push(current.slice(i)); break; }
    curLines.push(current.slice(i, nl));                // puede acabar en \r
    i = nl + 1;
  }
  const crlf = (current.match(/\r\n/g) || []).length;
  const eol = crlf && crlf * 2 >= (current.match(/\n/g) || []).length ? '\r\n' : '\n';
  const nCur = curLines.map(norm);                        // normalizamos UNA vez
  let seaLines = search.replace(/[\r\n]+$/, '').split(/\r?\n/);
  let repLines = replace.split(/\r?\n/);

  // Posiciones donde un bloque de líneas encaja (ignorando espacios).
  const locate = (nBlock) => {
    const at = [];
    for (let i = 0; i + nBlock.length <= nCur.length; i++) {
      let same = true;
      for (let j = 0; j < nBlock.length; j++) if (nCur[i + j] !== nBlock[j]) { same = false; break; }
      if (same) { at.push(i); if (at.length > 8) break; }
    }
    return at;
  };
  // Pedir «más contexto» a un modelo pequeño produce citas más largas y peores.
  // Decirle en qué líneas está le deja elegir con un bloque MÁS corto y único.
  const ambiguous = (donde = []) => new Error(
    `«search» encaja en ${donde.length || 'varios'} sitios de ${path}` +
    (donde.length ? ` (líneas ${donde.map(i => i + 1).join(', ')})` : '') +
    `. Cita un bloque que incluya alguna línea que solo exista en el sitio que quieres cambiar.`);

  let start = -1, k = seaLines.length;

  // 1) el bloque ENTERO, idéntico salvo espacios → tiene que ser ÚNICO
  {
    const at = locate(seaLines.map(norm));
    if (at.length > 1) { anota('ambigua', path, { fase: 'bloque' }); throw ambiguous(at); }
    if (at.length === 1) { start = at[0]; anota('bloque', path, {}); }
  }

  // 2) si no aparece entero, puede ser que el fichero tenga líneas que el modelo
  // NO vio (un comentario añadido después, p. ej.) metidas dentro del bloque.
  // Como search y replace comparten el contexto sin tocar al principio y al
  // final, recortamos esa parte común y buscamos solo el NÚCLEO que cambia: así
  // lo que el modelo no vio se queda donde estaba en vez de desaparecer.
  if (start === -1) {
    let pre = 0, suf = 0;
    while (pre < seaLines.length && pre < repLines.length && norm(seaLines[pre]) === norm(repLines[pre])) pre++;
    while (suf < seaLines.length - pre && suf < repLines.length - pre &&
           norm(seaLines[seaLines.length - 1 - suf]) === norm(repLines[repLines.length - 1 - suf])) suf++;
    const core = seaLines.slice(pre, seaLines.length - suf);
    if (core.length) {
      let at = locate(core.map(norm));
      if (at.length > 1) {
        // desempate por CONTEXTO: gana el candidato con más líneas del contexto
        // recortado alrededor (y solo si gana en solitario).
        const ctx = [...seaLines.slice(0, pre), ...seaLines.slice(seaLines.length - suf)].map(norm).filter(Boolean);
        const near = i => {
          const from = Math.max(0, i - pre - 3), to = Math.min(nCur.length, i + core.length + suf + 3);
          const around = nCur.slice(from, to);
          return ctx.filter(c => around.includes(c)).length;
        };
        const scored = at.map(i => ({ i, s: near(i) })).sort((a, b) => b.s - a.s);
        if (ctx.length && scored[0].s > scored[1].s) at = [scored[0].i];
      }
      if (at.length > 1) { anota('ambigua', path, { fase: 'nucleo' }); throw ambiguous(at); }
      if (at.length === 1) { start = at[0]; k = core.length; repLines = repLines.slice(pre, repLines.length - suf); anota('nucleo', path, { pre, suf }); }
    }
  }

  // 3) por parecido: la ventana con más líneas coincidentes, exigiendo ≥70% y
  // que gane con claridad a la segunda mejor (sin ambigüedad)
  let mejorVentana = null;
  if (start === -1) {
    const nSea = seaLines.map(norm);
    let best = -1, bestScore = 0, second = 0;
    for (let i = 0; i + k <= nCur.length; i++) {
      let hit = 0;
      for (let j = 0; j < k; j++) if (nCur[i + j] === nSea[j]) hit++;
      const score = hit / k;
      if (score > bestScore) { second = bestScore; bestScore = score; best = i; }
      else if (score > second) second = score;
    }
    if (bestScore >= 0.7 && bestScore - second >= 0.2) { start = best; anota('difusa', path, { parecido: +bestScore.toFixed(2) }); }
    else if (bestScore > 0) anota('noEncontrado', path, { mejorParecido: +bestScore.toFixed(2), segundo: +second.toFixed(2) });
    // Pedirle que RELEA le cuesta un turno entero y a un modelo pequeño le sale
    // una cita más larga y peor. Como ya sabemos dónde está lo más parecido,
    // se lo damos literal y numerado: puede copiar de ahí en el mismo turno.
    if (best >= 0 && bestScore > 0) {
      const desde = Math.max(0, best - 2), hasta = Math.min(curLines.length, best + k + 2);
      mejorVentana = curLines.slice(desde, hasta)
        .map((l, i) => `${desde + i + 1}→${l}`).slice(0, 12).join('\n');
    }
  }
  if (start === -1)
    throw new Error(`No encontré en ${path} el bloque que citas. ` +
      (mejorVentana ? `Lo más parecido que hay es esto (copia «search» LITERAL de aquí, sin los números):\n${mejorVentana}`
                    : `Léelo con code.read y copia «search» literal de esas líneas.`));

  // Corte exacto del bloque [start, start+k): desde el inicio de su primera
  // línea hasta el inicio de la siguiente (es decir, salto de línea incluido).
  const cutFrom = lineStart[start];
  const hasTrailingNL = start + k < lineStart.length;
  const cutTo = hasTrailingNL ? lineStart[start + k] : current.length;
  const block = repLines.join(eol) + (hasTrailingNL ? eol : '');
  const nextContent = current.slice(0, cutFrom) + block + current.slice(cutTo);
  if (nextContent === current) {
    anota('sinCambio', path, {});
    throw new Error(`La edición en ${path} no cambió nada — revisa «replace».`);
  }
  revisaSintaxis(path, current, nextContent);
  return write({ path, content: nextContent, _interno: true });
}

// grep-lite por el proyecto (texto, con límites para no arrasar). Los topes
// existen por rendimiento, pero si se alcanzan hay que DECÍRSELO al modelo
// (igual que read() avisa «quedan líneas…»); si no, un corte silencioso se lee
// como «no hay más» y el modelo da por cerrada una búsqueda incompleta.
const SEARCH_MAX_FILES = 1500, SEARCH_MAX_HITS = 80, SEARCH_MAX_BYTES = 2_000_000;
// Binarios: NO pueden contener el texto que se busca y leerlos como texto es
// caro. Fuera de esta lista quedan .svg/.json/.md/.csv, que son texto y sí se
// buscan. (Antes el tope de tamaño hacía de filtro accidental de binarios.)
const BINARY_EXT = /\.(png|jpe?g|gif|webp|avif|ico|bmp|tiff?|mp[34]|m4a|wav|ogg|mov|avi|webm|pdf|zip|gz|bz2|xz|tar|7z|rar|woff2?|ttf|eot|otf|wasm|bin|exe|dll|so|dylib|class|jar|pyc|sqlite3?)$/i;
export async function search({ query, ext = '' } = {}) {
  if (!query) throw new Error('Falta query');
  if (!projectHandle) throw new Error('No hay proyecto abierto');
  const results = [];
  const porNombre = [];                     // coincidencias en el NOMBRE del fichero
  const skipped = [];                       // ficheros de texto NO buscados por tamaño
  let checked = 0, capped = false;
  const q = query.toLowerCase();
  async function walk(dir, prefix) {
    if (results.length >= SEARCH_MAX_HITS || checked >= SEARCH_MAX_FILES) { capped = true; return; }
    for await (const e of dir.values()) {
      if (IGNORE.has(e.name)) continue;
      const p = prefix ? prefix + '/' + e.name : e.name;
      if (e.kind === 'directory') { await walk(e, p); if (capped) return; continue; }
      if (ext && !e.name.endsWith(ext)) continue;
      // El NOMBRE cuenta como resultado, y se mira ANTES de descartar binarios.
      // Antes no: buscar un «.stl» por su nombre devolvía «Sin resultados»
      // aunque el fichero estuviera ahí, porque solo se grepeaba el contenido y
      // de un binario ni se abría. «Sin resultados» leído por el modelo es «eso
      // no existe», y a partir de ahí deja de buscar.
      if (e.name.toLowerCase().includes(q) && porNombre.length < SEARCH_MAX_HITS)
        porNombre.push(p);
      if (BINARY_EXT.test(e.name)) continue;
      const f = await e.getFile();
      // Un fichero grande es justo donde el modelo NO puede leerlo entero, así
      // que es el que más falta hace buscar. Se busca hasta SEARCH_MAX_BYTES; y
      // lo que quede fuera se DICE (antes: >200KB se saltaba en silencio, y
      // «Sin resultados» sonaba a «ese código no existe» siendo mentira).
      if (f.size > SEARCH_MAX_BYTES) { skipped.push(p); continue; }
      checked++;
      const lines = (await f.text()).split('\n');
      for (let i = 0; i < lines.length; i++) {
        if (lines[i].toLowerCase().includes(q)) {
          results.push(`${p}:${i + 1}: ${lines[i].trim().slice(0, 140)}`);
          if (results.length >= SEARCH_MAX_HITS) { capped = true; break; }
        }
      }
      if (results.length >= SEARCH_MAX_HITS || checked >= SEARCH_MAX_FILES) { capped = true; return; }
    }
  }
  await walk(projectHandle, '');
  // Todo lo que la búsqueda NO ha mirado se cuenta; un resultado incompleto que
  // se presenta como completo es peor que no buscar.
  const notes = [];
  if (capped) notes.push(`búsqueda cortada (${results.length} resultados, ${checked} ficheros revisados) — puede haber más: afina con ext o un término más concreto`);
  if (skipped.length) notes.push(`${skipped.length} fichero(s) NO buscados por tamaño (>${SEARCH_MAX_BYTES / 1e6} MB): ${skipped.slice(0, 3).join(', ')}${skipped.length > 3 ? '…' : ''} — mira dentro con code.read`);
  const tail = notes.length ? '\n… ' + notes.join('. ') + '.' : '';
  const cabecera = porNombre.length
    ? `${porNombre.length} fichero(s) cuyo NOMBRE contiene «${query}»:\n` + porNombre.map(x => '  ' + x).join('\n') + '\n'
    : '';
  if (!results.length)
    return (cabecera || `Sin resultados para «${query}»`) +
      (cabecera && !results.length ? '(ninguna coincidencia dentro del contenido)' : '') + (tail || '');
  return cabecera + (cabecera ? `Coincidencias en el contenido:\n` : '') + results.join('\n') + tail;
}

// ── code.run: ejecutar el código y ver qué hace de verdad ───────────────────
//
// Medido con Gemma E4B: arregló un bug DOS veces (cambió la constante Y el
// operador) y dejó un fichero perfectamente válido pero incorrecto, rematando
// con «aplicado correctamente». Ninguna guarda sintáctica puede ver eso: la
// única forma de saberlo es ejecutarlo.
//
// Corre en un Worker de módulo: sin DOM, sin acceso a la página, y se le corta
// el paso por tiempo si se cuelga en un bucle. Los imports relativos se
// resuelven a mano contra el proyecto — sin build, un blob no sabe resolver
// «./otro.js» por su cuenta, y sin esto solo servirían los ficheros sueltos.
const RUN_MS = 4000, RUN_MAX_MODULOS = 40;

async function moduloComoBlob(path, hechos = new Map(), profundidad = 0) {
  const clave = normalize(path).join('/');
  if (hechos.has(clave)) return hechos.get(clave);
  if (hechos.size >= RUN_MAX_MODULOS || profundidad > 12)
    throw new Error(`demasiados ficheros encadenados desde ${path}`);
  hechos.set(clave, null);                       // marca de ciclo
  const { dir, name } = await dirOf(clave);
  let txt = await (await (await dir.getFileHandle(name)).getFile()).text();

  // reescribir cada import/export relativo al blob del fichero al que apunta
  const relativos = [...txt.matchAll(/(from\s*|import\s*\(\s*)(['"])(\.[^'"]+)\2/g)];
  for (const m of relativos) {
    const destino = normalize(clave.split('/').slice(0, -1).join('/') + '/' + m[3]).join('/');
    let url;
    try { url = await moduloComoBlob(destino, hechos, profundidad + 1); }
    catch { continue; }                          // que falle al importar, no aquí
    if (url) txt = txt.split(m[0]).join(`${m[1]}${m[2]}${url}${m[2]}`);
  }
  const url = URL.createObjectURL(new Blob([txt], { type: 'text/javascript' }));
  hechos.set(clave, url);
  return url;
}

export async function run({ path, expr } = {}) {
  if (!path) throw new Error('Falta path');
  if (!expr) throw new Error('Falta expr: qué quieres evaluar, p.ej. "m.max([])"');
  if (!ES_JS.test(path)) throw new Error(`code.run solo ejecuta JavaScript, y ${path} no lo es`);
  if (/^\s*import[\s{]/.test(expr) || /\brequire\s*\(/.test(expr))
    throw new Error(`«expr» es solo una EXPRESIÓN, no un programa: el módulo ya está importado. ` +
      `Escribe directamente la llamada, p.ej. «miFuncion(1, 2)» o «m.miFuncion(1, 2)».`);
  const hechos = new Map();
  let url;
  try { url = await moduloComoBlob(path, hechos); }
  catch (e) { throw new Error(`no pude preparar ${path} para ejecutarlo: ${e.message}`); }

  // Los nombres exportados se ATAN como variables sueltas, además de en «m».
  // Medido: el modelo escribe «slugify(...)» de forma natural, le saltaba un
  // «slugify is not defined», y de ahí concluía que su arreglo estaba mal y se
  // ponía a destrozar código que YA funcionaba — le quitó el export y rompió el
  // módulo. La herramienta de comprobar le enseñó que lo correcto era incorrecto.
  // Y si aun así nombra algo que no existe, el error DICE qué hay exportado, en
  // vez de dejarle deducir que el fichero está roto.
  const guion = `
    self.onmessage = async ({ data }) => {
      try {
        const m = await import(data.url);
        const nombres = Object.keys(m).filter(n => /^[A-Za-z_$][\\w$]*$/.test(n));
        const fn = new Function('m', ...nombres, 'return (' + data.expr + ')');
        const valor = await fn(m, ...nombres.map(n => m[n]));
        let texto;
        try { texto = JSON.stringify(valor); } catch { texto = String(valor); }
        if (texto === undefined) texto = String(valor);
        self.postMessage({ ok: true, tipo: typeof valor, texto });
      } catch (e) {
        let exporta = [];
        try { exporta = Object.keys(await import(data.url)); } catch { /* ni carga */ }
        self.postMessage({ ok: false, error: e.message, exporta });
      }
    };`;
  const wUrl = URL.createObjectURL(new Blob([guion], { type: 'text/javascript' }));
  const worker = new Worker(wUrl, { type: 'module' });
  try {
    const r = await new Promise((resolve) => {
      // Un bucle infinito en el código del usuario no puede colgar el IDE.
      const reloj = setTimeout(() => resolve({ ok: false, error: `no terminó en ${RUN_MS} ms (¿bucle infinito?)` }), RUN_MS);
      worker.onmessage = e => { clearTimeout(reloj); resolve(e.data); };
      worker.onerror = e => { clearTimeout(reloj); resolve({ ok: false, error: e.message || 'error al cargar el módulo' }); };
      worker.postMessage({ url, expr });
    });
    if (r.ok) return `${expr} → ${r.texto}  (${r.tipo})`;
    // Un «X is not defined» sin más lleva al modelo a «el fichero está mal».
    // Decirle qué exporta de verdad corta esa deducción en seco.
    const noDefinido = /(\w+) is not defined/.exec(r.error || '');
    const pista = noDefinido && r.exporta?.length
      ? ` — «${noDefinido[1]}» no está en ${path}, que exporta: ${r.exporta.join(', ')}. Llámalo por su nombre o con m.<nombre>; el fichero NO tiene por qué estar mal.`
      : (r.exporta?.length ? ` (${path} exporta: ${r.exporta.join(', ')})` : '');
    return `${expr} lanzó: ${r.error}${pista}`;
  } finally {
    worker.terminate();
    URL.revokeObjectURL(wUrl);
    for (const u of hechos.values()) if (u) URL.revokeObjectURL(u);
  }
}