File size: 41,737 Bytes
73063cc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d305959
 
 
 
 
 
 
 
 
 
 
 
 
73063cc
 
d305959
73063cc
 
d305959
 
 
 
 
 
 
 
 
 
 
 
 
73063cc
d305959
 
 
 
 
 
 
73063cc
d305959
 
 
 
73063cc
d305959
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73063cc
 
 
 
d305959
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73063cc
 
 
 
 
d305959
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73063cc
 
 
d305959
 
 
 
 
 
 
 
73063cc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d305959
 
 
 
 
 
 
 
 
 
 
 
 
73063cc
 
d305959
73063cc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
// RLM — Recursive Language Models (el modo «Hard Work» de Elffuss).
//
// Idea (Zhang et al., MIT, 2025): un modelo PEQUEÑO y local puede manejar
// entradas ENORMES si no las mete todas de golpe en su ventana, sino que
// TROCEA el material, pregunta a cada trozo (map = una sub-llamada al mismo
// modelo) y FUNDE las respuestas (reduce). Si lo fundido aún no cabe, se
// recurre. El modelo raíz nunca ve todo el contexto a la vez: trabaja por
// partes, como un humano leyendo un tocho capítulo a capítulo.
//
// Trade-off honesto: cambia LATENCIA por CAPACIDAD. Son muchas inferencias del
// modelo local (que es lento), así que es para tareas que PUEDEN esperar —
// auditar, resumir o revisar una carpeta/repo entero— no para tiempo real.
//
// El motor es agnóstico del proveedor: solo necesita algo con
// `chat(history, system, onToken)` (lo cumplen rules/onnx/litert/api).

const CHARS_PER_TOK = 4; // misma estimación que context.js
const DEFAULTS = {
  chunkTokens: 1800,     // tamaño de cada trozo (deja hueco al prompt del modelo)
  overlapTokens: 120,    // solape para no cortar una idea a la mitad entre trozos
  maxDepth: 3,           // recursión máxima del reduce jerárquico
  maxChunks: 80,         // techo DURO de sub-llamadas del map (coste/tiempo)
  callTimeoutMs: 120000, // guarda por sub-llamada (un motor colgado no bloquea todo)
};

const clip = (s, n) => (s.length > n ? s.slice(0, n) + '…' : s);

// Trocea respetando límites de línea cuando es posible (no parte a mitad de
// palabra si hay un salto cerca del corte).
function chunk(text, chunkChars, overlapChars) {
  const step = Math.max(1, chunkChars - overlapChars);
  const out = [];
  for (let i = 0; i < text.length; i += step) {
    let end = Math.min(i + chunkChars, text.length);
    if (end < text.length) {
      const nl = text.lastIndexOf('\n', end);
      if (nl > i + chunkChars * 0.6) end = nl; // corta en salto si cae en el último 40%
    }
    out.push(text.slice(i, end));
    if (end >= text.length) break;
  }
  return out;
}

// sub(): UNA inferencia del modelo local sobre un fragmento. Es la recursión.
async function sub(provider, system, prompt, timeoutMs) {
  const run = provider.chat([{ role: 'user', content: prompt }], system, () => {});
  if (!timeoutMs) return run;
  let to;
  const guard = new Promise((_, rej) => { to = setTimeout(() => rej(new Error('sub-llamada agotó el tiempo')), timeoutMs); });
  try { return await Promise.race([run, guard]); }
  finally { clearTimeout(to); }
}

const MAP_SYS = 'Eres un analista minucioso. Te doy UN fragmento de un material más grande y una pregunta. Responde SOLO con lo que ESTE fragmento aporte a la pregunta: datos concretos, en pocas frases, con cita textual breve si procede. No inventes lo que no esté en el fragmento. Si el fragmento no aporta nada a la pregunta, responde exactamente: NADA';
const REDUCE_SYS = 'Eres un sintetizador. Te doy una pregunta y varios hallazgos extraídos de fragmentos distintos del MISMO material. Fúndelos en una única respuesta, coherente y completa, a la pregunta. Resuelve solapes, no repitas, no inventes lo que no esté en los hallazgos. Responde en el idioma de la pregunta.';

// Motor RLM. Devuelve { answer, chunks, kept }.
export async function hardWork({ question, context, provider, onProgress = () => {}, opts = {} }) {
  const o = { ...DEFAULTS, ...opts };
  if (!provider || typeof provider.chat !== 'function') throw new Error('Hard Work necesita un cerebro cargado (elige un modelo local o remoto arriba).');
  const q = String(question || '').trim();
  const ctx = String(context || '').trim();
  if (!q) throw new Error('Hard Work: dime qué quieres saber del material.');
  if (!ctx) throw new Error('Hard Work: no encontré material que procesar.');

  const chunkChars = o.chunkTokens * CHARS_PER_TOK;
  let parts = chunk(ctx, chunkChars, o.overlapTokens * CHARS_PER_TOK);
  const truncated = parts.length > o.maxChunks;
  if (truncated) parts = parts.slice(0, o.maxChunks);
  onProgress({ phase: 'plan', chunks: parts.length, chars: ctx.length, truncated });

  // MAP — un modelo local es UN motor, así que las sub-llamadas van en serie.
  const findings = [];
  for (let i = 0; i < parts.length; i++) {
    onProgress({ phase: 'map', i: i + 1, n: parts.length });
    let ans;
    try { ans = await sub(provider, MAP_SYS, `PREGUNTA:\n${q}\n\nFRAGMENTO ${i + 1}/${parts.length}:\n${parts[i]}`, o.callTimeoutMs); }
    catch { ans = 'NADA'; }
    ans = String(ans || '').trim();
    if (ans && !/^NADA\b/i.test(ans)) findings.push(`[${i + 1}] ${ans}`);
  }
  onProgress({ phase: 'mapped', kept: findings.length, of: parts.length });
  if (!findings.length) return { answer: 'Revisé el material por partes y no encontré nada relevante para eso.', chunks: parts.length, kept: 0, truncated };

  const answer = await reduce(provider, q, findings, o, onProgress, 0);
  return { answer: String(answer || '').trim(), chunks: parts.length, kept: findings.length, truncated };
}

// REDUCE jerárquico: si los hallazgos caben, funde en uno; si no, agrupa,
// funde cada grupo y recurre con las respuestas parciales.
async function reduce(provider, q, findings, o, onProgress, depth) {
  const budget = o.chunkTokens * CHARS_PER_TOK * 2; // el reduce puede ver algo más que un trozo
  const joined = findings.join('\n');
  if (joined.length <= budget || depth >= o.maxDepth) {
    onProgress({ phase: 'reduce', depth, final: true });
    return sub(provider, REDUCE_SYS, `PREGUNTA:\n${q}\n\nHALLAZGOS:\n${clip(joined, budget)}`, o.callTimeoutMs);
  }
  const groups = [];
  let cur = [];
  let len = 0;
  for (const f of findings) {
    if (len + f.length + 1 > budget && cur.length) { groups.push(cur); cur = []; len = 0; }
    cur.push(f); len += f.length + 1;
  }
  if (cur.length) groups.push(cur);
  onProgress({ phase: 'reduce', depth, groups: groups.length, final: false });
  const partials = [];
  for (let g = 0; g < groups.length; g++) {
    onProgress({ phase: 'reduce-group', depth, g: g + 1, n: groups.length });
    const p = await sub(provider, REDUCE_SYS, `PREGUNTA:\n${q}\n\nHALLAZGOS (grupo ${g + 1}/${groups.length}):\n${groups[g].join('\n')}`, o.callTimeoutMs);
    partials.push(`[g${g + 1}] ${String(p || '').trim()}`);
  }
  return reduce(provider, q, partials, o, onProgress, depth + 1);
}

// ── Hard Work · CREACIÓN: borrador → autocrítica → reescritura ──────────────
// El hermano creativo de RLM. RLM gasta cómputo LEYENDO material enorme; esto
// lo gasta MEJORANDO lo que el modelo crea: en vez de una sola pasada, el
// modelo pequeño hace un borrador, se autocritica sin piedad y se reescribe
// corrigiendo sus propios fallos. Sube la calidad de un modelo modesto sin
// cambiar de modelo — solo pensando más veces.
const CREATE_SYS = 'Eres Elffuss creando una app web. Respondes SOLO con un documento HTML completo y autocontenido (CSS y JS inline, fondo oscuro). Si es un juego o algo visual, usa <canvas> y que se vea vivo (color, movimiento, efectos). Nada de texto fuera del HTML.';
const CRITIQUE_SYS = 'Eres un crítico de videojuegos y front-end, implacable pero útil. Te doy el código de una app/juego web. Enumera 3 a 6 defectos CONCRETOS y accionables: bugs de lógica, controles que faltan, colisiones mal hechas, falta de condición de fin o de puntuación, jugabilidad sosa y pobreza visual (sin color, sin efectos, estático). Sé específico y breve. NO reescribas el código: solo la lista de defectos.';
const REWRITE_SYS = 'Eres Elffuss mejorando tu propia app. Te doy el HTML actual y una crítica. Devuelve SOLO el HTML completo y autocontenido MEJORADO que corrige TODOS los puntos de la crítica, conservando lo que ya funcionaba y subiendo el nivel visual (color, brillo, partículas si encaja). Sin explicaciones fuera del HTML.';

// Saca el documento HTML de la respuesta del modelo (fence ```html o directo).
export function extractHtml(text) {
  const s = String(text || '');
  const fence = s.match(/```html\s*([\s\S]*?)```/i) || s.match(/```\s*([\s\S]*?)```/);
  const body = fence ? fence[1] : s;
  const m = body.match(/<!doctype[\s\S]*<\/html>/i) || body.match(/<html[\s\S]*<\/html>/i);
  return (m ? m[0] : body).trim();
}

const CONT_SYS = 'Continúas un documento HTML/JS que se cortó a media escritura. Devuelve SOLO la continuación EXACTA desde donde termina —sin repetir nada de lo ya escrito, sin reabrir <html> ni <script>, sin explicaciones— hasta cerrar todas las etiquetas y </html>.';

// Genera y, si el modelo se quedó sin tokens a media escritura (el HTML no
// cierra </html>), pide la continuación desde la cola y la cose. Así el techo
// de 1024 tokens deja de truncar apps grandes: se completan por tramos.
async function genComplete(provider, system, prompt, { maxCont = 4 } = {}) {
  let full = String(await provider.chat([{ role: 'user', content: prompt }], system, () => {}) || '');
  for (let i = 0; i < maxCont && !/<\/html>/i.test(full); i++) {
    const tail = full.slice(-1400);
    let cont = String(await provider.chat(
      [{ role: 'user', content: `FINAL de lo escrito hasta ahora (continúa desde aquí, sin repetirlo):\n${tail}` }],
      CONT_SYS, () => {}) || '');
    // Defensa: si el modelo reabre el documento en vez de continuar, recórtalo.
    cont = cont.replace(/^[\s\S]*?<!doctype html>/i, '').replace(/^[\s\S]*?<html[^>]*>/i, '');
    if (!cont.trim()) break;
    full += cont;
  }
  return full;
}


// ── Jurado deliberante (Reasoning Jury, arXiv 2608.12585) ───────────────────
// Un juez único detecta mal los defectos: se inventa unos y se le escapan
// otros (lo vimos con el crítico único de Hard Work). El paper sustituye al
// juez por un JURADO que delibera: cada jurado propone defectos, luego ve los
// de los demás y VOTA cuáles son reales, y un moderador se queda con los que
// tienen apoyo. Aquí el jurado es un mismo modelo con lentes distintas —no
// modelos distintos, que no caben en el navegador—, así que el efecto es
// menor que en el paper; por eso se mide, no se asume.
const JURY_LENSES = [
  { key: 'logica', name: 'lógica', ask: 'errores de lógica y de estado: variables sin definir, condiciones al revés, bucles que no avanzan, una condición de fin que nunca se cumple, cuentas mal hechas' },
  { key: 'interaccion', name: 'interacción', ask: 'controles y respuesta: entradas que no hacen nada, colisiones que no detectan, falta de arranque o de reinicio, nada que indique al usuario qué pasa' },
  { key: 'visual', name: 'presentación', ask: 'lo que se ve: elementos con tamaño cero o fuera de pantalla, todo del mismo color, nada dibujado, texto ilegible' },
];

const asLines = t => String(t || '').split('\n')
  .map(l => l.replace(/^\s*(?:[-*\u2022]|\d+[.)])\s*/, '').trim())
  .filter(l => l.length > 12 && l.length < 220);

const normDefect = d => d.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
  .replace(/[^a-z0-9 ]/g, ' ').split(/\s+/).filter(w => w.length > 3).slice(0, 6).join(' ');

// Devuelve { defects:[{text,votes,jurors}], rounds } sobre un artefacto.
// Normaliza espacios para comparar una cita con el código real.
const flat = t => String(t).replace(/\s+/g, ' ').trim();

// Elige las lentes que TIENEN algo que mirar en este artefacto. Pasarle
// «presentación» a una función pura de cálculo no aporta nada: ese jurado o
// calla o repite lo que dijo el de lógica, y los repetidos se comían las
// plazas de candidatos.
function lensesFor(material) {
  const tieneUI = /<canvas|<button|<input|<form|document\.|addEventListener|innerHTML|querySelector/i.test(material);
  const tienePintado = /<canvas|getContext|fillRect|drawImage|style\.|css/i.test(material);
  const out = [JURY_LENSES[0]];                                  // lógica: siempre
  if (tieneUI) out.push(JURY_LENSES[1]);                         // interacción
  if (tienePintado) out.push(JURY_LENSES[2]);                    // presentación
  if (out.length === 1) out.push(JURY_LENSES_EXTRA[0], JURY_LENSES_EXTRA[1]);  // código puro
  else if (out.length === 2) out.push(JURY_LENSES_EXTRA[0]);
  return out;
}

// Lentes para código sin interfaz, donde «interacción» y «presentación» no pintan nada.
const JURY_LENSES_EXTRA = [
  { key: 'datos', name: 'datos y tipos', ask: 'tipos y datos: conversiones implícitas, valores que llegan como texto cuando se esperan números, undefined/NaN, claves que cambian de tipo' },
  { key: 'bordes', name: 'casos límite', ask: 'casos límite y validación: entradas vacías, negativas o fuera de rango, ausencia de comprobaciones, resultados absurdos que nadie detiene' },
];

export async function juryReview({ artifact, provider, onProgress = () => {}, lenses = null, requireEvidence = true }) {
  const dropped = [];
  if (!provider?.chat) throw new Error('el jurado necesita un cerebro cargado');
  const material = String(artifact || '').slice(0, 6000);
  lenses = lenses || lensesFor(material);
  onProgress({ phase: 'jury-lenses', lenses: lenses.map(l => l.name) });

  // Ronda 1 — cada jurado propone defectos desde SU lente, sin ver a los demás.
  const proposals = [];
  for (const L of lenses) {
    onProgress({ phase: 'jury-propose', lens: L.name });
    const sys = `Eres un revisor especializado en ${L.ask}. Te doy el código de una app web. Enumera SOLO los defectos REALES que veas desde tu especialidad. Por cada uno escribe DOS líneas seguidas:
DEFECTO: <qué está mal, en una línea>
PRUEBA: <copia LITERAL del fragmento de código que lo demuestra, tal cual aparece>
Máximo 4 defectos. Si no ves ninguno, escribe NINGUNO. La PRUEBA debe estar copiada del código palabra por palabra; si no puedes copiarla, no incluyas ese defecto.`;
    const out = await provider.chat([{ role: 'user', content: 'CÓDIGO:\n' + material }], sys, () => {});
    // Emparejar DEFECTO con su PRUEBA y COMPROBAR que la cita existe de verdad
    // en el código. Es un filtro determinista: si el modelo no sabe citar, se
    // lo ha inventado. (El modelo verifica mucho mejor de lo que propone, pero
    // esto ni siquiera necesita al modelo.)
    const raw = String(out).split('\n').map(x => x.trim());
    const found = [];
    for (let i = 0; i < raw.length; i++) {
      const dm = raw[i].match(/^DEFECTO:\s*(.+)/i);
      if (!dm) continue;
      const pm = (raw[i + 1] || '').match(/^PRUEBA:\s*(.+)/i);
      const texto = dm[1].trim();
      if (texto.length < 12) continue;
      if (!requireEvidence) { found.push({ texto, cita: pm ? pm[1].trim() : '' }); continue; }
      const cita = pm ? pm[1].trim().replace(/^[`'"]|[`'"]$/g, '') : '';
      if (cita.length >= 8 && flat(material).includes(flat(cita))) found.push({ texto, cita });
      else dropped.push({ lens: L.name, texto, cita: cita.slice(0, 60) });
    }
    proposals.push({ lens: L, found });
  }

  // Candidatos únicos (los jurados repiten el mismo defecto con otras palabras).
  // Deduplicar por la CITA de código, no por las palabras: dos jurados describen
  // el mismo fallo con frases distintas («el bucle empieza en 1» y «ignora el
  // primer elemento») y el deduplicador léxico los dejaba pasar como dos, con lo
  // que la lista de candidatos se llenaba de repetidos. Si señalan el mismo
  // fragmento de código, es el mismo defecto.
  const seen = new Map();
  for (const p of proposals) for (const d of p.found) {
    const k = d.cita && d.cita.length >= 8 ? 'c:' + flat(d.cita).slice(0, 80) : 't:' + normDefect(d.texto);
    if (k && !seen.has(k)) seen.set(k, d.texto);
  }
  const candidates = [...seen.values()].slice(0, 12);
  onProgress({ phase: 'jury-candidates', n: candidates.length });
  if (!candidates.length) return { defects: [], candidates: 0, dropped };

  // Ronda 2 — DELIBERACIÓN: cada jurado ve TODOS los candidatos (incluidos los
  // ajenos) y vota cuáles son de verdad. Aquí es donde puede retirar el suyo.
  const votes = candidates.map(() => 0);
  for (const L of lenses) {
    onProgress({ phase: 'jury-vote', lens: L.name });
    const list = candidates.map((c, i) => `${i + 1}. ${c}`).join('\n');
    // OJO con el formato: poner "N: SI" como plantilla hacía que el modelo
    // escribiera literalmente la letra N y no se parseara NINGÚN voto (el
    // jurado parecía rechazarlo todo). Los modelos pequeños copian el ejemplo
    // tal cual: hay que darles números de verdad.
    const sys = `Eres un revisor riguroso (especialidad: ${L.name}). Te doy el código y una lista numerada de defectos propuestos por otros revisores. Para CADA número, mira el código y di si el defecto es REAL. Responde SOLO con una línea por defecto, con su número y SI o NO. Así:
1: SI
2: NO
3: SI
Nada más: ni explicaciones ni texto extra. Ante la duda, NO.`;
    const out = await provider.chat(
      [{ role: 'user', content: 'CÓDIGO:\n' + material + '\n\nDEFECTOS PROPUESTOS:\n' + list }], sys, () => {});
    const txt = String(out);
    let parsed = 0;
    for (const m of txt.matchAll(/(\d+)\s*[:.)\-]\s*(S[IÍ]|YES|NO)\b/gi)) {
      const i = +m[1] - 1;
      parsed++;
      if (i >= 0 && i < votes.length && /^(s[ií]|yes)$/i.test(m[2])) votes[i]++;
    }
    // Respaldo: si el modelo ignoró el formato numerado, leer los SI/NO en orden.
    if (!parsed) {
      const seq = [...txt.matchAll(/\b(S[IÍ]|YES|NO)\b/gi)].map(m => m[1]);
      seq.slice(0, votes.length).forEach((v, i) => { if (/^(s[ií]|yes)$/i.test(v)) votes[i]++; });
    }
    onProgress({ phase: 'jury-parsed', lens: L.name, parsed });
  }

  // Moderador — se queda con los que tienen apoyo de la mayoría del jurado.
  const need = Math.ceil(lenses.length / 2);
  const defects = candidates.map((text, i) => ({ text, votes: votes[i] }))
    .filter(d => d.votes >= need)
    .sort((a, b) => b.votes - a.votes);
  onProgress({ phase: 'jury-verdict', kept: defects.length, of: candidates.length });
  onProgress({ phase: 'jury-evidence', dropped: dropped.length });
  return { defects, candidates: candidates.length, jurors: lenses.length, dropped };
}


// ── Edición QUIRÚRGICA en vez de reescritura completa ───────────────────────
// Reescribir el documento entero para corregir tres cosas hace que el modelo
// regenere de cero lo que ya funcionaba, y por el camino rompe la mecánica.
// Aquí solo se le piden los CAMBIOS, y los aplica este código: si el fragmento
// a buscar no está tal cual en el documento, ese cambio se descarta. El modelo
// propone, el código decide — igual que con las citas del jurado.
const PATCH_SYS = `Eres Elffuss arreglando tu propia app. Te doy el HTML actual y una lista de defectos COMPROBADOS ejecutandola.

Arregla ESOS defectos y nada mas. No reescribas el documento. Devuelve solo los cambios, asi:

BUSCAR
<las lineas actuales, copiadas LITERALES del HTML de arriba>
CAMBIAR
<las lineas nuevas que las sustituyen>
FIN

Puedes usar VARIAS lineas en cada parte: si hace falta anadir codigo nuevo (por ejemplo unos addEventListener de teclado), copia en BUSCAR una linea de referencia que exista y ponla en CAMBIAR seguida del codigo nuevo.

Reglas: lo de BUSCAR debe existir palabra por palabra en el HTML y aparecer una sola vez. BUSCAR y CAMBIAR nunca pueden ser iguales, eso no arregla nada. Maximo 4 parches, todos dirigidos a los defectos de la lista. No toques colores, textos ni nada que ya funcione.`;

export function applyPatches(html, respuesta) {
  const texto = String(respuesta || '');
  let out = html;
  const aplicados = [], fallidos = [];

  // Los parches se recogen en los DOS formatos. El de una línea era el único
  // que había, y con él los defectos importantes son INARREGLABLES por
  // construcción: añadir unos listeners de teclado necesita varias líneas, así
  // que el modelo solo podía hacer retoques cosméticos (medido: cambiaba el
  // verde de la serpiente mientras el defecto era que no responde a las teclas).
  const parches = [];
  // bloque multilínea:  BUSCAR \n …líneas… \n CAMBIAR \n …líneas… \n FIN
  const bloque = /^[ \t]*BUSCAR[ \t]*$\r?\n([\s\S]*?)^[ \t]*CAMBIAR[ \t]*$\r?\n([\s\S]*?)^[ \t]*FIN[ \t]*$/gim;
  for (const m of texto.matchAll(bloque))
    parches.push({ buscar: m[1].replace(/\s+$/, ''), cambiar: m[2].replace(/\s+$/, '') });
  // de una línea: BUSCAR: … / CAMBIAR: …
  const lineas = texto.split('\n').map(x => x.trim());
  for (let i = 0; i < lineas.length; i++) {
    const b = lineas[i].match(/^BUSCAR:\s*(.+)/i); if (!b) continue;
    const c = (lineas[i + 1] || '').match(/^CAMBIAR:\s*(.+)/i); if (!c) continue;
    parches.push({ buscar: b[1].trim(), cambiar: c[1].trim() });
  }

  const norm = t => t.replace(/[ \t]+/g, ' ').trim();
  for (const { buscar, cambiar } of parches) {
    if (buscar.length < 6) continue;
    // Un parche que no cambia nada se contaba como aplicado y gastaba uno de
    // los seis huecos. Medido: el modelo los emite (BUSCAR y CAMBIAR idénticos).
    if (buscar === cambiar) { fallidos.push({ buscar: buscar.slice(0, 60), motivo: 'no cambia nada' }); continue; }

    const veces = out.split(buscar).length - 1;
    if (veces === 1) { out = out.replace(buscar, cambiar); aplicados.push(buscar.slice(0, 60)); continue; }
    if (veces > 1) { fallidos.push({ buscar: buscar.slice(0, 60), veces, motivo: 'aparece varias veces' }); continue; }

    // No está literal: puede ser solo la sangría o los espacios, que es el
    // error de cita más común. Se busca por líneas normalizadas, y solo se
    // sustituye si el bloque es ÚNICO — nunca una coincidencia dudosa.
    const bl = buscar.split('\n').map(norm);
    const hl = out.split('\n'), hn = hl.map(norm);
    const donde = [];
    for (let i = 0; i + bl.length <= hn.length; i++) {
      let igual = true;
      for (let j = 0; j < bl.length; j++) if (hn[i + j] !== bl[j]) { igual = false; break; }
      if (igual) { donde.push(i); if (donde.length > 1) break; }
    }
    if (donde.length === 1) {
      hl.splice(donde[0], bl.length, ...cambiar.split('\n'));
      out = hl.join('\n');
      aplicados.push(buscar.slice(0, 60));
    } else fallidos.push({ buscar: buscar.slice(0, 60), veces: donde.length, motivo: donde.length ? 'ambiguo ignorando espacios' : 'no existe en el HTML' });
  }
  return { html: out, aplicados, fallidos };
}


// ── Evaluador OBJETIVO: ejecuta el artefacto y mide, no opina ───────────────
// fast-agent (evalstate/fast-agent) usa un LLM como evaluador y refina hasta
// una nota mínima. La idea es buena, pero preguntarle al modelo qué nota se
// pone es preguntarle a la parte que ya demostró alucinar. Aquí el artefacto
// se EJECUTA en un iframe aislado y se comprueba qué hace de verdad:
//
//   pinta      dibuja algo
//   vivo       lo que dibuja cambia solo (hay bucle)
//   responde   cambia al recibir teclas y ratón
//   arranca    no aparece ya terminado
//   completo   el documento cierra
//
// Medir esto costó descubrir que contar píxeles premiaba una pantalla de
// «GAME OVER» a toda página por encima de un juego jugable.
export async function rateArtifact(html, { ms = 2600 } = {}) {
  if (typeof document === 'undefined') return { nota: 0, detalle: {}, medible: false };
  // El iframe va SIN allow-same-origin a propósito: dentro corre código que ha
  // escrito el modelo y no debe poder tocar la página que lo aloja. Eso hace su
  // documento ilegible desde fuera, así que se le inyecta una sonda que se mide
  // a sí misma y devuelve NÚMEROS por postMessage. Nada entra; solo salen datos.
  const sonda = `<script>(function(){
    var W=window, P=function(m){try{parent.postMessage(m,'*')}catch(e){}};
    // Un juego que revienta al mover el ratón se puntuaba 5/5: la sonda medía
    // que pintaba y que cambiaba, pero era ciega a las excepciones. Se recogen
    // aquí dentro porque desde fuera el sandbox no deja verlas.
    var errores=[];
    // «responde» se medía como «el dibujo cambió tras pulsar», pero un juego que
    // anima continuamente cambia igual sin tocar nada: un juego SORDO pasaba.
    // Ahora se mira si llega a registrar algún escuchador de entrada, que es la
    // propiedad de verdad. Se puede porque la sonda entra ANTES que su código.
    var escucha = {};
    var ENTRADA = /^(keydown|keyup|keypress|mousemove|mousedown|mouseup|click|pointerdown|pointermove|touchstart|touchmove)$/;
    function espia(obj, nombre){
      if(!obj || !obj.addEventListener) return;
      var orig = obj.addEventListener;
      obj.addEventListener = function(tipo){ if(ENTRADA.test(String(tipo))) escucha[tipo]=1; return orig.apply(this, arguments); };
    }
    espia(W,'window'); espia(document,'document');
    if(W.HTMLElement && W.HTMLElement.prototype) espia(W.HTMLElement.prototype,'elementos');
    // los manejadores puestos como propiedad (onkeydown = ...) no pasan por ahí
    ['onkeydown','onkeyup','onclick','onmousemove','onmousedown'].forEach(function(k){
      [W, document].forEach(function(o){
        try{ var v; Object.defineProperty(o,k,{configurable:true,
          get:function(){return v;}, set:function(f){ if(f) escucha[k]=1; v=f; }}); }catch(e){}
      });
    });
    W.addEventListener('error',function(e){ errores.push(String((e&&e.message)||e).slice(0,120)); });
    W.addEventListener('unhandledrejection',function(e){ errores.push('promesa: '+String((e&&e.reason&&e.reason.message)||e.reason).slice(0,110)); });
    function leer(){ var c=document.querySelector('canvas'); if(!c) return null;
      var x=c.getContext('2d'); if(!x) return null;
      try{ var d=x.getImageData(0,0,c.width,c.height).data, nb=0, h=0;
        for(var i=0;i<d.length;i+=16){ if(d[i]||d[i+1]||d[i+2]) nb++;
          h=(h*31+d[i]+d[i+1]*3+d[i+2]*7)>>>0; }
        return {nb:nb,h:h,txt:(document.body.innerText||'').slice(0,400).toLowerCase(),err:errores[0]||null,nerr:errores.length,escucha:Object.keys(escucha)};
      }catch(e){ return {nb:0,h:0,txt:'',err:errores[0]||('canvas: '+e.message),nerr:errores.length+1,escucha:Object.keys(escucha)}; } }
    W.addEventListener('message',function(e){
      if(!e.data) return;
      if(e.data.__sonda){ P({__sonda:1,r:leer()}); return; }
      // Las pulsaciones hay que despacharlas DENTRO: desde fuera no cruzan el
      // sandbox, y sin esto «responde» medía simplemente que seguía animando.
      if(e.data.__tecla){ var k=e.data.__tecla;
        try{ var ev={key:k,code:k===' '?'Space':k,keyCode:k===' '?32:0,bubbles:true};
          document.dispatchEvent(new KeyboardEvent('keydown',ev));
          W.dispatchEvent(new KeyboardEvent('keydown',ev));
          var c=document.querySelector('canvas');
          if(c){ var r=c.getBoundingClientRect();
            var mv=new MouseEvent('mousemove',{clientX:r.left+r.width*0.7,clientY:r.top+r.height/2,bubbles:true});
            document.dispatchEvent(mv); c.dispatchEvent(mv);
            c.dispatchEvent(new MouseEvent('click',{bubbles:true})); }
        }catch(e2){}
      }
    });
    P({__sonda:'lista'});
  })();<\/script>`;
  const marco = document.createElement('iframe');
  marco.setAttribute('sandbox', 'allow-scripts');
  // DENTRO del viewport a propósito: fuera de pantalla Chrome estrangula
  // requestAnimationFrame y el juego no anima, así que se medía como muerto un
  // juego que funciona. Invisible por opacidad, no por posición.
  marco.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:600px;border:0;' +
    'opacity:.01;pointer-events:none;z-index:-1';
  const doc = String(html || '');
  // La sonda va lo ANTES posible: puesta antes de </body> se registraba después
  // de que el artefacto ya hubiera corrido, así que era ciega al fallo más
  // común —el de arranque o parseo— y esos juegos salían «sin errores».
  // Detrás de <head> y no antes del doctype: delante, el documento cae en modo
  // quirks y cambia el tamaño del canvas, o sea que cambiaría lo que medimos.
  marco.srcdoc = /<head[^>]*>/i.test(doc) ? doc.replace(/<head[^>]*>/i, m => m + sonda)
    : /<html[^>]*>/i.test(doc) ? doc.replace(/<html[^>]*>/i, m => m + sonda)
    : sonda + doc;

  const respuestas = [];
  const oir = e => { if (e.data && e.data.__sonda === 1) respuestas.push(e.data.r); };
  window.addEventListener('message', oir);
  document.body.appendChild(marco);

  const esperar = t => new Promise(r => setTimeout(r, t));
  const pedir = async () => {
    const n = respuestas.length;
    try { marco.contentWindow.postMessage({ __sonda: 1 }, '*'); } catch { /* */ }
    for (let i = 0; i < 40 && respuestas.length === n; i++) await esperar(50);
    // Al expirar el plazo, respuestas.length sigue valiendo n y esto devolvía
    // LA LECTURA ANTERIOR. Entonces b===a daba «no está vivo» y c===b daba «no
    // responde» sobre un juego que funciona. Y como el evaluador se usa MIENTRAS
    // el modelo genera, el iframe llega tarde justo cuando más se le mide: un
    // juego jugable se puntuaba 0 por no haber contestado a tiempo.
    return respuestas.length > n ? respuestas[respuestas.length - 1] : null;
  };

  await esperar(1300);
  const a = await pedir();
  await esperar(ms);
  const b = await pedir();
  try {
    for (const k of ['ArrowLeft', 'ArrowRight', 'ArrowUp', ' ', 'w', 's'])
      marco.contentWindow.postMessage({ __tecla: k }, '*');
  } catch { /* */ }
  await esperar(1200);
  const c = await pedir();
  // Cuarta lectura, SIN mandar nada más. Hace falta porque «sobrevive» empuja al
  // modelo a que el juego no arranque hasta la primera pulsación —que es lo
  // correcto— y entonces «vivo», medido solo ANTES de tocar nada, lo castigaba
  // justo por hacerlo bien. Las dos señales se contradecían. Ahora vale con que
  // se mueva solo en algún momento: antes de la entrada, o después de ella.
  await esperar(1400);
  const d = await pedir();

  window.removeEventListener('message', oir);
  marco.remove();

  if (!a || !b || !c || !d) return { nota: -1, detalle: { medible: false }, medible: false,
    error: 'el artefacto no contestó a tiempo (no es un fallo suyo: no se pudo medir)' };
  // Las mismas cinco señales que mide el arnés externo, para que la nota de
  // dentro y la de fuera signifiquen lo mismo y se puedan contrastar.
  const fallo = [a, b, c, d].map(x => x && x.err).find(Boolean) || null;
  // El snake generado arrancaba ya moviéndose y chocaba contra la pared en un
  // segundo y medio: cuando lo abres ya ha terminado. Gráficamente impecable y
  // pasaba las cinco señales anteriores. «arranca» mira el primer instante;
  // esto mira si SIGUE vivo unos segundos después sin que nadie juegue.
  const seAcabo = t => /game\s*over|has perdido|fin del juego|puntuaci[óo]n final/.test(t || '');
  const detalle = {
    pinta: a.nb > 0,
    vivo: !!(a.h !== b.h || c.h !== d.h),
    // escuchar Y reaccionar. Solo lo segundo daba por bueno un juego sordo que
    // simplemente seguía animando; solo lo primero daría por bueno uno que
    // registra el escuchador y no hace nada con él.
    responde: !!((c && c.escucha && c.escucha.length) && (c && b && b.h !== c.h)),
    arranca: !seAcabo(a.txt),
    // Se comprueba en la ÚLTIMA lectura, no en la segunda. Al pedirle al modelo
    // que no arranque hasta la primera pulsación, mirar antes de tocar nada
    // daba «sobrevive» por bueno trivialmente: el juego aún no había empezado.
    // Snake pasó de morir en segundo y medio a esperar y MORIR IGUAL de rápido
    // en cuanto arrancaba, y así puntuaba 6/6.
    sobrevive: !seAcabo(d && d.txt) && !seAcabo(b && b.txt),
    sinErrores: !fallo,
  };
  // «pinta» es puerta: sin dibujar nada, un HTML en blanco se llevaba puntos
  // gratis por «no muestra game over» y «sin errores», ciertos por vacuidad.
  const nota = detalle.pinta ? Object.values(detalle).filter(Boolean).length : 0;
  return { nota, maximo: Object.keys(detalle).length, detalle, medible: true, error: fallo, cerrado: /<\/html>/i.test(doc) };
}

// Pedirle a un modelo de 4B «encuentra los defectos de este HTML» es pedirle lo
// que peor hace: leer código largo y juzgarlo. Pero tras ejecutarlo YA sabemos
// objetivamente qué falla. Esto convierte la medición en la lista de defectos,
// que es información dura y no una opinión del propio modelo sobre sí mismo.
function defectosMedidos(detalle, error) {
  if (!detalle) return [];
  const d = [];
  if (!detalle.pinta) d.push('No se dibuja NADA en el canvas: revisa que exista el <canvas>, que se coja su contexto 2d y que algo se pinte al arrancar.');
  if (!detalle.vivo) d.push('La pantalla no cambia sola: falta el bucle de animación. Tiene que haber un requestAnimationFrame que se llame a sí mismo y repinte cada fotograma.');
  if (!detalle.responde) d.push('No reacciona a la entrada: al pulsar las flechas, W/S o la barra, y al mover el ratón, no cambia nada. Los listeners de teclado deben estar en document o window (no en el canvas, que no tiene foco).');
  if (!detalle.arranca) d.push('Arranca ya en pantalla de fin de partida: el estado inicial debe ser «jugando», no «game over».');
  if (!detalle.sobrevive) d.push('La partida se acaba SOLA a los pocos segundos sin que el jugador haga nada, así que es injugable. Arréglalo de las dos formas: (1) que no empiece a moverse hasta la primera pulsación del jugador, y (2) que empiece lejos de los bordes y con velocidad más lenta, para que dé tiempo a reaccionar.');
  if (!detalle.sinErrores) d.push('Lanza este error en ejecución y hay que corregirlo: ' + (error || 'error desconocido'));
  return d;
}

export async function deepCreate({ brief, provider, rounds = 1, onProgress = () => {}, useJury = false,
                                   editMode = 'rewrite', notaMinima = null, evaluar = null, bestOf = 1 }) {
  if (!provider || typeof provider.chat !== 'function') throw new Error('Hard Work necesita un cerebro cargado (elige un modelo arriba).');
  if (!String(brief || '').trim()) throw new Error('Hard Work: dime qué quieres que cree.');
  onProgress({ phase: 'draft' });
  let html = extractHtml(await genComplete(provider, CREATE_SYS, String(brief)));
  const trace = [{ round: 0, html }];

  // Varias tiradas y nos quedamos con la que MEJOR puntúa al ejecutarla.
  // A este tamaño de modelo, pedirle que se critique y se parchee empeora
  // (medido: una ronda dejó un juego jugable sin pintar). Volver a tirar y
  // juzgar por fuera no depende de que el modelo se juzgue bien a sí mismo.
  // Requiere muestreo con temperatura: con decodificado voraz las N tiradas
  // salen idénticas y esto sería pagar N veces por lo mismo.
  const puedeMuestrear = typeof provider.setSampling === 'function';
  if (bestOf > 1 && (evaluar || rateArtifact) && puedeMuestrear) {
    const juzga = evaluar || rateArtifact;
    let mejorHtml = html, mejorV = await juzga(html);
    onProgress({ phase: 'candidato', i: 0, nota: mejorV.nota });
    for (let i = 1; i < bestOf; i++) {
      provider.setSampling({ temperature: 0.85, seed: 1000 + i * 137 });
      const otro = extractHtml(await genComplete(provider, CREATE_SYS, String(brief)));
      const v = otro ? await juzga(otro) : { nota: -1 };
      onProgress({ phase: 'candidato', i, nota: v.nota });
      trace.push({ candidato: i, html: otro, nota: v.nota });
      if (v.nota > mejorV.nota) { mejorHtml = otro; mejorV = v; }
    }
    provider.setSampling(null);            // dejarlo como estaba: voraz
    html = mejorHtml; trace[0].html = html; trace[0].nota = mejorV.nota;
    onProgress({ phase: 'mejorCandidato', nota: mejorV.nota });
  }
  // Con criterio de nota: se guarda la MEJOR versión, no la última. Medimos que
  // una ronda de más puede empeorar (un juego jugable acabó en pantalla de fin),
  // así que devolver «lo último» es devolver a veces lo peor.
  const juez = notaMinima != null ? (evaluar || rateArtifact) : null;
  let mejor = html, mejorNota = -1, ultimoDetalle = null, ultimoError = null;
  if (juez) {
    const v = await juez(html);
    mejorNota = v.nota; ultimoDetalle = v.detalle; ultimoError = v.error;
    trace[0].nota = v.nota; trace[0].detalle = v.detalle;
    onProgress({ phase: 'rated', round: 0, nota: v.nota, detalle: v.detalle });
    if (v.nota >= notaMinima) { onProgress({ phase: 'done', rounds: 0, motivo: 'ya cumplía' }); return { html, trace, nota: v.nota }; }
  }
  for (let r = 1; r <= rounds; r++) {
    onProgress({ phase: 'critique', round: r });
    let critique;
    // Si hemos ejecutado el artefacto, lo MEDIDO manda: son defectos
    // comprobados, no sospechas. La crítica del modelo va detrás, como apoyo.
    const medidos = juez ? defectosMedidos(ultimoDetalle, ultimoError) : [];
    if (medidos.length) {
      critique = 'DEFECTOS COMPROBADOS EJECUTANDO EL JUEGO (corrige estos, son seguros):\n'
        + medidos.map(x => '- ' + x).join('\n');
      onProgress({ phase: 'critique', round: r, medidos: medidos.length });
    } else if (useJury) {
      const v = await juryReview({ artifact: html, provider, onProgress });
      critique = v.defects.length
        ? v.defects.map(d => `- ${d.text} (${d.votes} de ${v.jurors} revisores)`).join('\n')
        : 'Sin defectos con apoyo suficiente del jurado.';
    } else {
      critique = String(await provider.chat([{ role: 'user', content: `APP/JUEGO:\n${html}` }], CRITIQUE_SYS, () => {})).trim();
    }
    onProgress({ phase: 'rewrite', round: r, modo: editMode });
    if (editMode === 'patch') {
      const resp = await provider.chat(
        [{ role: 'user', content: `HTML ACTUAL:\n${html}\n\nDEFECTOS A CORREGIR:\n${critique}` }], PATCH_SYS, () => {});
      const { html: nuevo, aplicados, fallidos } = applyPatches(html, resp);
      onProgress({ phase: 'patched', round: r, aplicados: aplicados.length, fallidos: fallidos.length });
      if (aplicados.length) { html = nuevo; trace.push({ round: r, critique, html, aplicados, fallidos }); }
      else trace.push({ round: r, critique, html, aplicados: [], fallidos });
    } else {
      const improved = extractHtml(await genComplete(provider, REWRITE_SYS, `HTML ACTUAL:\n${html}\n\nCRÍTICA A CORREGIR:\n${critique}`));
      if (improved && /<\w+[\s>]/.test(improved)) { html = improved; trace.push({ round: r, critique, html }); }
    }
    if (juez) {
      const v = await juez(html);
      ultimoDetalle = v.detalle; ultimoError = v.error;
      trace[trace.length - 1].nota = v.nota; trace[trace.length - 1].detalle = v.detalle;
      onProgress({ phase: 'rated', round: r, nota: v.nota, detalle: v.detalle });
      if (v.nota > mejorNota) { mejorNota = v.nota; mejor = html; }
      if (v.nota >= notaMinima) { onProgress({ phase: 'done', rounds: r, motivo: 'alcanzó la nota' }); return { html: mejor, trace, nota: mejorNota }; }
      // Antes se paraba en cuanto una ronda puntuaba por debajo de la mejor. Con
      // eso una lectura floja tiraba las rondas que quedaban, y como siempre se
      // devuelve la MEJOR, seguir no puede empeorar el resultado: solo cuesta
      // tiempo. Se gasta el presupuesto y se elige al final.
      if (v.nota < mejorNota) onProgress({ phase: 'peor', round: r, nota: v.nota, mejor: mejorNota });
    }
  }
  onProgress({ phase: 'done', rounds: trace.length - 1 });
  return juez ? { html: mejor, trace, nota: mejorNota } : { html, trace };
}

// ── Recolección de material desde una carpeta autorizada (File System Access) ──
// Camina el árbol, concatena ficheros de texto con cabecera de ruta, y corta a
// maxBytes para no reventar la memoria. Mismo código sirve a Claw (carpeta
// autorizada) y a Code (proyecto abierto): ambos entregan un directory handle.
const TEXT_EXT = new Set(('txt md markdown js mjs cjs ts tsx jsx json html htm css scss sass ' +
  'py rb php go rs java kt swift c h cpp hpp cc cs sh bash zsh yml yaml toml ini cfg conf ' +
  'xml csv tsv sql vue svelte astro lua r pl pm ex exs erl clj tex org rst gradle properties env').split(' '));
const SKIP_DIR = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'out', 'target', 'vendor', '.cache', 'coverage', '__pycache__', '.venv', 'venv']);

export async function gatherFolder(rootHandle, { maxBytes = 1.5e6, maxFiles = 400 } = {}) {
  if (!rootHandle || typeof rootHandle.entries !== 'function') throw new Error('no hay carpeta que leer');
  const out = [];
  let bytes = 0;
  let files = 0;
  async function walk(dir, prefix) {
    if (bytes >= maxBytes || files >= maxFiles) return;
    const entries = [];
    for await (const e of dir.entries()) entries.push(e);
    entries.sort((a, b) => a[0].localeCompare(b[0]));
    for (const [name, handle] of entries) {
      if (bytes >= maxBytes || files >= maxFiles) return;
      if (name.startsWith('.') && name !== '.env') { if (handle.kind === 'directory') continue; }
      const path = prefix ? prefix + '/' + name : name;
      if (handle.kind === 'directory') {
        if (SKIP_DIR.has(name)) continue;
        await walk(handle, path);
      } else {
        const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : '';
        if (!TEXT_EXT.has(ext)) continue;
        try {
          const file = await handle.getFile();
          if (file.size > 400000) continue; // un fichero enorme suelto no monopoliza
          const text = await file.text();
          const block = `\n\n===== ${path} =====\n${text}`;
          out.push(block);
          bytes += block.length;
          files++;
        } catch { /* ilegible: se salta */ }
      }
    }
  }
  await walk(rootHandle, '');
  return out.join('').slice(0, maxBytes);
}