terminal-smart-cli 0.97.12 → 0.97.14

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/lib/meta.js CHANGED
@@ -29,6 +29,96 @@ const MAX_VISUAL_FIXES = 5; // rodadas de polimento VISUAL (o olho crítico)
29
29
  const MAX_VISUAL_FIXES_FLUTTER = 3; // Flutter: rebuild é MUITO mais pesado/lento → teto menor de polimento (economia)
30
30
  const VISUAL_ESCALATE_AT = 2; // após N reprovações visuais, a MÃO do fix vira o modelo forte (grok) — o deepseek erra layout complexo
31
31
  const MAX_BUILD_FIXES = 12; // rodadas extras de correção de build antes de desistir
32
+ const META_PROJECT_TOOLS = Object.freeze([
33
+ 'executar_comando', 'ler_arquivo', 'escrever_arquivo', 'editar_arquivo',
34
+ 'restaurar_arquivo', 'listar_diretorio', 'buscar_arquivos', 'buscar_codigo',
35
+ 'mapa_projeto', 'preciso_de_voce',
36
+ ]);
37
+
38
+ // Most meta rounds work inside one local project. Do not send schemas for
39
+ // Gmail, Outlook, Android, SSH and Drive unless the item needs integrations.
40
+ function toolsForMetaItem(item, goal) {
41
+ const text = `${item && item.desc || ''}\n${goal || ''}`.toLowerCase();
42
+ if (/\b(gmail|outlook|e-?mail|google\s+(drive|docs|sheets)|onedrive|microsoft\s*365|ssh|vps|servidor remoto|android|celular|telegram|whatsapp)\b/i.test(text)) return null;
43
+ return [...META_PROJECT_TOOLS];
44
+ }
45
+
46
+ // Connect each checklist item to a concrete artifact. Planner criteria have
47
+ // priority; deterministic fallbacks cover later items that exceed the criteria cap.
48
+ function artifactForMetaItem(item, st) {
49
+ const list = Array.isArray(st && st.checklist) ? st.checklist : [];
50
+ const index = Math.max(0, list.indexOf(item));
51
+ const criterion = Array.isArray(st && st.criteria) ? st.criteria[index] : null;
52
+ if (criterion && criterion.type === 'file_exists' && criterion.path) return String(criterion.path);
53
+ const d = String(item && item.desc || '').toLowerCase();
54
+ if (/controller.*8 dire|8 dire.*controller/.test(d)) return 'src/player_controller.gd';
55
+ if (/combate b[aá]sico|sistema de combate/.test(d)) return 'src/combat_system.gd';
56
+ if (/interface|\bui\b|\bhud\b/.test(d)) return 'docs/UI_SPEC.md';
57
+ if (/80-100 monstros|lista.*monstros/.test(d)) return 'data/monsters.json';
58
+ if (/economia|zeny|vending/.test(d)) return 'docs/ECONOMY.md';
59
+ if (/prioriza|\bmvp\b.*alpha|roadmap/.test(d)) return 'docs/ROADMAP.md';
60
+ return `artifacts/${String(item && item.id || 'item').replace(/[^a-z0-9_-]/gi, '_')}.md`;
61
+ }
62
+
63
+ function normalizeMetaArtifact(relativePath, raw) {
64
+ let text = String(raw || '').trim().replace(/^```(?:json|gdscript|markdown|md)?\s*/i, '').replace(/\s*```\s*$/i, '').trim();
65
+ if (/\.json$/i.test(relativePath)) {
66
+ const a = text.indexOf('['), o = text.indexOf('{');
67
+ const start = a >= 0 && (o < 0 || a < o) ? a : o;
68
+ const end = Math.max(text.lastIndexOf(']'), text.lastIndexOf('}'));
69
+ if (start < 0 || end < start) throw new Error('resposta sem JSON');
70
+ const parsed = JSON.parse(text.slice(start, end + 1));
71
+ text = JSON.stringify(parsed, null, 2) + '\n';
72
+ }
73
+ if (!text || text.length < 80) throw new Error('artefato vazio ou curto demais');
74
+ return text;
75
+ }
76
+
77
+ function persistMetaArtifact(dir, relativePath, raw) {
78
+ const root = path.resolve(dir);
79
+ const target = path.resolve(root, relativePath);
80
+ if (target !== root && !target.startsWith(root + path.sep)) throw new Error('artefato fora da pasta confinada');
81
+ const content = normalizeMetaArtifact(relativePath, raw);
82
+ fs.mkdirSync(path.dirname(target), { recursive: true });
83
+ fs.writeFileSync(target, content, 'utf8');
84
+ return { target, bytes: Buffer.byteLength(content) };
85
+ }
86
+
87
+ function directArtifactPlan(item) {
88
+ const d = String(item && item.desc || '').toLowerCase();
89
+ const groups = (values, size) => { const out = []; for (let i = 0; i < values.length; i += size) out.push(values.slice(i, i + size)); return out; };
90
+ const uniqueIds = (data, key) => {
91
+ const rows = data && data[key];
92
+ if (!Array.isArray(rows) || rows.some(row => !row || row.id === undefined || row.id === null || String(row.id).trim() === '')) return false;
93
+ const ids = rows.map(row => String(row.id));
94
+ return new Set(ids).size === ids.length;
95
+ };
96
+ if (/classes|novice|[aá]rvores de skills/.test(d)) return {
97
+ key: 'classes', minimum: 20,
98
+ parts: groups(['Novice','Swordsman','Mage','Archer','Merchant','Thief','Acolyte','Knight','Crusader','Wizard','Sage','Hunter','Bard','Dancer','Blacksmith','Alchemist','Assassin','Rogue','Priest','Monk'], 4),
99
+ schema: '{"classes":[{"id":"string","name":"string","tier":0,"base_attributes":{"STR":1,"AGI":1,"VIT":1,"INT":1,"DEX":1,"LUK":1},"skills":[{"id":"string","name":"string","max_level":10,"sp_cost":1,"cooldown_seconds":0,"description":"string"}]}]}',
100
+ validate: data => uniqueIds(data, 'classes') && data.classes.every(c => Array.isArray(c.skills) && c.skills.length >= 8 && new Set(c.skills.map(s => String(s.id))).size === c.skills.length),
101
+ };
102
+ if (/lista completa de mapas|cidades.*masmorras/.test(d)) return {
103
+ key: 'maps', minimum: 18,
104
+ parts: groups(['Prontera','Geffen','Payon','Morroc','Alberta','Izlude','Prontera Field North','Prontera Field South','Geffen Field','Payon Forest','Sograt Desert','Prontera Culvert','Byalan Island','Undersea Tunnel','Payon Cave','Geffen Dungeon','Pyramid','Orc Dungeon','Glast Heim'], 4),
105
+ schema: '{"maps":[{"id":"string","name":"string","type":"city|field|dungeon","level_range":[1,10],"layout":"string","npcs":["string"],"monsters":["string"],"connections":["string"],"safe_areas":["string"],"events":["string"]}]}',
106
+ validate: data => uniqueIds(data, 'maps'),
107
+ };
108
+ if (/50\+ quests|pelo menos 50 quests/.test(d)) return {
109
+ key: 'quests', minimum: 50,
110
+ parts: Array.from({ length: 5 }, (_, i) => `quests ${i * 10 + 1} a ${i * 10 + 10}`),
111
+ schema: '{"quests":[{"id":"string","title":"string","type":"main|side|job|daily|weekly","objectives":["string"],"rewards":{"exp":1,"job_exp":1,"zeny":1,"items":["string"]},"dialogue":"string","prerequisites":["string"]}]}',
112
+ validate: data => uniqueIds(data, 'quests'),
113
+ };
114
+ if (/80-100 monstros|lista detalhada.*monstros/.test(d)) return {
115
+ key: 'monsters', minimum: 80,
116
+ parts: Array.from({ length: 10 }, (_, i) => `monstros ${i * 10 + 1} a ${i * 10 + 10}`),
117
+ schema: '{"monsters":[{"id":"string","name":"string","level":1,"hp":10,"element":"Neutral","size":"Small|Medium|Large","map":"string","drops":[{"item":"string","chance_percent":1}],"is_mvp":false}]}',
118
+ validate: data => uniqueIds(data, 'monsters') && data.monsters.every(m => Number(m.level) > 0 && Number(m.hp) > 0 && Array.isArray(m.drops)),
119
+ };
120
+ return null;
121
+ }
32
122
  const WEB_CANVAS_MOVE_MIN = 0.02; // check de MOVIMENTO do canvas web (jogo/cena 3D): diferença MÍNIMA
33
123
  // entre 2 quadros pra a cena contar como VIVA. Abaixo disso = parada/congelada (T-pose, mixer parado,
34
124
  // loop sem avançar o tempo). SÓ se aplica a canvas de JOGO/3D — dashboard/gráfico pode ficar estático.
@@ -819,7 +909,7 @@ async function _llmJson({ token, system, user, maxTokens = 900 }) {
819
909
  }
820
910
 
821
911
  // Chamada de TEXTO com modelo arbitrário — usada pelo PENSADOR (escalonamento).
822
- async function _llmText({ token, model, system, user, maxTokens = 1200 }) {
912
+ async function _llmText({ token, model, system, user, maxTokens = 1200, creditBudget = null }) {
823
913
  if (!_key) _key = await keyring.resolve(token, { feature: 'cli_agent' });
824
914
  const ctrl = new AbortController();
825
915
  const timer = setTimeout(() => ctrl.abort(), 120000);
@@ -827,7 +917,8 @@ async function _llmText({ token, model, system, user, maxTokens = 1200 }) {
827
917
  try {
828
918
  res = await fetch(String(_key.baseUrl).replace(/\/+$/, '') + '/chat/completions', {
829
919
  method: 'POST', signal: ctrl.signal,
830
- headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + _key.key },
920
+ headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + _key.key,
921
+ ...(Number.isFinite(Number(creditBudget)) && Number(creditBudget) > 0 ? { 'X-TS-Credit-Budget': String(Math.floor(Number(creditBudget))) } : {}) },
831
922
  body: JSON.stringify({ model: keyring.modeloPara(_key, model), stream: false, max_completion_tokens: maxTokens, messages: [{ role: 'system', content: system }, { role: 'user', content: user }] }),
832
923
  });
833
924
  } finally { clearTimeout(timer); }
@@ -836,7 +927,16 @@ async function _llmText({ token, model, system, user, maxTokens = 1200 }) {
836
927
  const u = j?.usage || {};
837
928
  let credits = (j?.ts_billing && j.ts_billing.charged) || 0;
838
929
  if (u.prompt_tokens && !_key.billingAuthoritative) { try { const c = await api('/api/credit/charge', { method: 'POST', token, body: { model, inTok: u.prompt_tokens || 0, outTok: u.completion_tokens || 0 } }); credits = (c && c.charged) || 0; } catch (_) {} }
839
- return { text: String(j?.choices?.[0]?.message?.content || '').trim(), credits };
930
+ const text = String(j?.choices?.[0]?.message?.content || '').trim();
931
+ // Some providers may return HTTP 200 without `choices` during an internal
932
+ // failure. Empty output is not a valid brief and must never become success.
933
+ if (!text) {
934
+ const providerMessage = String(j?.error?.message || j?.message || '').trim();
935
+ throw new ApiError(`O modelo ${model || 'selecionado'} retornou uma resposta vazia${providerMessage ? ': ' + providerMessage : '.'}`, {
936
+ status: 502, code: 'empty_model_response',
937
+ });
938
+ }
939
+ return { text, credits };
840
940
  }
841
941
 
842
942
  // ESCALONAMENTO: o executor barato travou no mesmo erro. Chama o modelo CARO
@@ -1057,7 +1157,7 @@ async function run(goal, opts = {}) {
1057
1157
  const feasModel = thinker || 'grok-4.5';
1058
1158
  if (feasMode === true || (feasMode === 'auto' && looksComplex(goal))) {
1059
1159
  onRound({ n: 0, item: (lang === 'en' ? `Checking feasibility with ${feasModel}…` : `Avaliando viabilidade com ${feasModel}…`), attempt: 1 });
1060
- try { const fe = await feasibilityPhase(goal, feasModel, token); feasBrief = fe.brief; feasCred = fe.credits || 0; try { fs.writeFileSync(path.join(dir, 'VIABILIDADE.md'), feasBrief); } catch (_) {} onAlert({ type: 'design', text: `🔎 ts: viabilidade avaliada (${feasModel}, ${feasCred} créditos) — o que dá e o que não dá está fixado. Salvo em VIABILIDADE.md.` }); } catch (_e) { if (_e && _e.code === 'no_credits') throw _e; }
1160
+ try { const fe = await feasibilityPhase(goal, feasModel, token); feasBrief = fe.brief; feasCred = fe.credits || 0; try { fs.writeFileSync(path.join(dir, 'VIABILIDADE.md'), feasBrief); } catch (_) {} onAlert({ type: 'design', text: `🔎 ts: viabilidade avaliada (${feasModel}, ${feasCred} créditos) — o que dá e o que não dá está fixado. Salvo em VIABILIDADE.md.` }); } catch (_e) { if (_e && _e.code === 'no_credits') throw _e; onAlert({ type: 'phase_error', text: `⚠ ts: viabilidade não concluída: ${String(_e && _e.message || _e).slice(0, 220)}` }); }
1061
1161
  }
1062
1162
  // FASE DE DESIGN (nova missão visual): o designer forte projeta o design system
1063
1163
  // ANTES do checklist e do código, pra o app sair bonito de fábrica.
@@ -1067,7 +1167,7 @@ async function run(goal, opts = {}) {
1067
1167
  onRound({ n: 0, item: (lang === 'en' ? `Designing the visual with ${designer}…` : `Projetando o visual com ${designer}…`), attempt: 1 });
1068
1168
  let mockupB64 = null;
1069
1169
  if (opts.mockup) { try { mockupB64 = fs.readFileSync(opts.mockup).toString('base64'); onAlert({ type: 'design', text: `🖼 ts: mockup de referência carregado (${path.basename(opts.mockup)}) — o designer vai projetar a partir dele e o olho vai cobrar fidelidade.` }); } catch (_) { onAlert({ type: 'design', text: `⚠ ts: não consegui ler o mockup ${opts.mockup} — seguindo sem ele.` }); } }
1070
- try { const d = await designPhase(goal, designer, token, { dir, mockupB64, kind: _projKind(goal, dir) }); designBrief = d.brief; designCred = d.credits || 0; try { fs.writeFileSync(path.join(dir, 'DESIGN.md'), designBrief); } catch (_) {} onAlert({ type: 'design', text: `🎨 ts: design system criado pelo ${designer} (${designCred} créditos) — o app vai seguir esse visual. Salvo em DESIGN.md.` }); } catch (_e) { if (_e && _e.code === 'no_credits') throw _e; }
1170
+ try { const d = await designPhase(goal, designer, token, { dir, mockupB64, kind: _projKind(goal, dir) }); designBrief = d.brief; designCred = d.credits || 0; try { fs.writeFileSync(path.join(dir, 'DESIGN.md'), designBrief); } catch (_) {} onAlert({ type: 'design', text: `🎨 ts: design system criado pelo ${designer} (${designCred} créditos) — o app vai seguir esse visual. Salvo em DESIGN.md.` }); } catch (_e) { if (_e && _e.code === 'no_credits') throw _e; onAlert({ type: 'phase_error', text: `⚠ ts: design não concluído: ${String(_e && _e.message || _e).slice(0, 220)}` }); }
1071
1171
  }
1072
1172
  // FASE DE ARQUITETURA (3º pilar): app complexo ganha um CONTRATO antes do checklist
1073
1173
  let archBrief = null, archCred = 0;
@@ -1075,7 +1175,7 @@ async function run(goal, opts = {}) {
1075
1175
  const archModel = thinker || 'grok-4.5';
1076
1176
  if (archMode === true || (archMode === 'auto' && looksComplex(goal))) {
1077
1177
  onRound({ n: 0, item: (lang === 'en' ? `Designing the architecture with ${archModel}…` : `Projetando a arquitetura com ${archModel}…`), attempt: 1 });
1078
- try { const a = await archPhase(goal, archModel, token, { dir }); archBrief = a.brief; archCred = a.credits || 0; try { fs.writeFileSync(path.join(dir, 'ARQUITETURA.md'), archBrief); } catch (_) {} onAlert({ type: 'design', text: `📐 ts: contrato de arquitetura criado pelo ${archModel} (${archCred} créditos) — componentes, arquivos e assinaturas fixados. Salvo em ARQUITETURA.md.` }); } catch (_e) { if (_e && _e.code === 'no_credits') throw _e; }
1178
+ try { const a = await archPhase(goal, archModel, token, { dir }); archBrief = a.brief; archCred = a.credits || 0; try { fs.writeFileSync(path.join(dir, 'ARQUITETURA.md'), archBrief); } catch (_) {} onAlert({ type: 'design', text: `📐 ts: contrato de arquitetura criado pelo ${archModel} (${archCred} créditos) — componentes, arquivos e assinaturas fixados. Salvo em ARQUITETURA.md.` }); } catch (_e) { if (_e && _e.code === 'no_credits') throw _e; onAlert({ type: 'phase_error', text: `⚠ ts: arquitetura não concluída: ${String(_e && _e.message || _e).slice(0, 220)}` }); }
1079
1179
  }
1080
1180
  const mk = await makeChecklist(goal + (designBrief ? '\n\n[Há um DESIGN SYSTEM definido — o checklist deve refletir a aplicação desse visual]' : '') + (archBrief ? '\n\n[Há um CONTRATO DE ARQUITETURA definido — os itens devem seguir os componentes/arquivos dele]' : ''), token);
1081
1181
  // CRITÉRIOS (TaskSpec): usuário (--criterios) manda; senão, com --provar, combina o que o
@@ -1121,6 +1221,16 @@ async function run(goal, opts = {}) {
1121
1221
  // ABRE sem crashar E o "olho" aprova o layout. O portão REENTRA enquanto o visual não passa.
1122
1222
  if (!pend.length) {
1123
1223
  const b = detectBuild(dir);
1224
+ // A documentation/data/code-sample package has no build target by
1225
+ // design. Verify its executable file criteria and finish honestly;
1226
+ // absence of a build system is not a "build-fix ceiling" failure.
1227
+ if (!b) {
1228
+ const criteriaOk = await _checkCriteria(st, dir);
1229
+ st.verification = criteriaOk ? ((st.criteria || []).length ? 'verified' : 'no_gate') : 'unverified';
1230
+ st.status = 'done'; st.finished_at = new Date().toISOString();
1231
+ st.runNote = criteriaOk ? 'pacote sem alvo compilavel; criterios de artefato verificados' : 'criterios de artefato falharam';
1232
+ save(st, dir); onRoundDone({ checklist: st.checklist, spent: st.creditsSpent }); return st;
1233
+ }
1124
1234
  const buildCapLeft = (st.buildFixes || 0) < MAX_BUILD_FIXES;
1125
1235
  // teto de polimento visual: menor pra Flutter (rebuild caro/lento) que pra Android
1126
1236
  const visualCap = (b && b.kind === 'flutter') ? MAX_VISUAL_FIXES_FLUTTER : MAX_VISUAL_FIXES;
@@ -1256,9 +1366,119 @@ async function run(goal, opts = {}) {
1256
1366
  }
1257
1367
 
1258
1368
  const item = pend[0];
1369
+ const expectedArtifact = artifactForMetaItem(item, st);
1370
+ // A process can be interrupted after a read-only round but before the
1371
+ // no-artifact branch persists `directArtifact`. Recover that evidence on
1372
+ // resume instead of repeating the same exploratory attempt.
1373
+ const expectedOnDisk = expectedArtifact
1374
+ ? (path.isAbsolute(expectedArtifact) ? expectedArtifact : path.resolve(dir, expectedArtifact))
1375
+ : null;
1376
+ if (!item.directArtifact && (item.attempts || 0) > 0 && expectedOnDisk && !fs.existsSync(expectedOnDisk)) {
1377
+ item.directArtifact = true;
1378
+ item.directModel = item.directModel || 'glm-5.2';
1379
+ item.attempts = Math.max(0, item.attempts - 1);
1380
+ }
1259
1381
  item.attempts = (item.attempts || 0) + 1;
1260
1382
  onRound({ n: st.rounds.length + 1, item: item.desc, attempt: item.attempts });
1261
1383
 
1384
+ // Known large structured deliverables are cheaper and more reliable when
1385
+ // generated in validated batches from the start (classes/maps/quests/monsters).
1386
+ if (!item.directArtifact && directArtifactPlan(item)) {
1387
+ item.directArtifact = true;
1388
+ item.directModel = 'glm-5.2';
1389
+ }
1390
+
1391
+ // Some providers can generate a large document but refuse to place that
1392
+ // content inside tool-call arguments. After a proven zero-tool response,
1393
+ // generate content-only, validate it and let the confined harness persist it.
1394
+ if (item.directArtifact) {
1395
+ const directModel = item.directModel || 'glm-5.2';
1396
+ const plan = directArtifactPlan(item);
1397
+ let totalCredits = 0, finalText = '';
1398
+ if (plan) {
1399
+ const merged = [];
1400
+ item.directParts = item.directParts || {};
1401
+ for (let partIndex = 0; partIndex < plan.parts.length; partIndex++) {
1402
+ if (Array.isArray(item.directParts[partIndex])) {
1403
+ merged.push(...item.directParts[partIndex]);
1404
+ continue;
1405
+ }
1406
+ const part = plan.parts[partIndex];
1407
+ let generated = null, parsedPart = null, lastDirectError = null;
1408
+ for (let directAttempt = 0; directAttempt < 2 && !parsedPart; directAttempt++) {
1409
+ try {
1410
+ const remainingBudget = Math.max(1, Math.floor(st.budget - st.creditsSpent));
1411
+ generated = await _llmText({
1412
+ token, model: directModel, maxTokens: 7500, creditBudget: remainingBudget,
1413
+ system: `Gere SOMENTE JSON estritamente valido no schema pedido. Sem introducao, sem markdown fence, sem comentarios, sem virgula sobrando e com todas as aspas/chaves fechadas. Entregue apenas a parte solicitada. SCHEMA: ${plan.schema}`,
1414
+ user: `OBJETIVO:\n${item.desc}\n\nPARTE ${partIndex + 1}/${plan.parts.length}: ${Array.isArray(part) ? part.join(', ') : part}. Inclua exatamente estas entidades e detalhe cada uma.`,
1415
+ });
1416
+ totalCredits += generated.credits || 0;
1417
+ st.creditsSpent += generated.credits || 0;
1418
+ save(st, dir); // custo existe mesmo se a saida abaixo for invalida
1419
+ const parsed = JSON.parse(normalizeMetaArtifact(expectedArtifact, generated.text));
1420
+ if (!Array.isArray(parsed[plan.key])) throw new Error(`parte ${partIndex + 1} sem array ${plan.key}`);
1421
+ parsedPart = parsed[plan.key];
1422
+ } catch (e) {
1423
+ lastDirectError = e;
1424
+ generated = null;
1425
+ if (directAttempt === 0) await new Promise(r => setTimeout(r, 1500));
1426
+ }
1427
+ }
1428
+ if (!parsedPart) {
1429
+ item.attempts = Math.max(0, (item.attempts || 1) - 1);
1430
+ const transient = /abort|timeout|fetch|network/i.test(String(lastDirectError && lastDirectError.message || lastDirectError)) || Number(lastDirectError && lastDirectError.status) >= 500;
1431
+ st.status = 'paused'; st.pause_reason = transient ? 'connection' : 'invalid_artifact';
1432
+ st.budget = st.creditsSpent;
1433
+ save(st, dir);
1434
+ onAlert({ type: transient ? 'conn' : 'retry', text: `Geracao em lotes pausada na parte ${partIndex + 1}/${plan.parts.length}: ${String(lastDirectError && lastDirectError.message || lastDirectError).slice(0, 180)}. As partes ja validadas e os custos foram preservados.` });
1435
+ return st;
1436
+ }
1437
+ item.directParts[partIndex] = parsedPart;
1438
+ merged.push(...item.directParts[partIndex]);
1439
+ save(st, dir);
1440
+ }
1441
+ const data = { [plan.key]: merged };
1442
+ if (merged.length < plan.minimum) throw new Error(`${plan.key}: esperado minimo ${plan.minimum}, recebido ${merged.length}`);
1443
+ if (plan.validate && !plan.validate(data)) throw new Error(`${plan.key}: validacao semantica falhou`);
1444
+ finalText = JSON.stringify(data, null, 2);
1445
+ } else {
1446
+ let generated = null, lastDirectError = null;
1447
+ for (let directAttempt = 0; directAttempt < 2 && !generated; directAttempt++) {
1448
+ try {
1449
+ const remainingBudget = Math.max(1, Math.floor(st.budget - st.creditsSpent));
1450
+ generated = await _llmText({
1451
+ token, model: directModel, maxTokens: /\.json$/i.test(expectedArtifact) ? 16000 : 14000,
1452
+ creditBudget: remainingBudget,
1453
+ system: 'Gere o CONTEUDO COMPLETO do artefato solicitado. Responda SOMENTE com o conteudo final, sem introducao, sem promessas e sem markdown fence. Se for JSON, devolva JSON estritamente valido. Nao use ferramentas.',
1454
+ user: `OBJETIVO MAIOR:\n${st.goal.slice(0, 1800)}\n\nITEM ATUAL:\n${item.desc}\n\nCAMINHO/FORMATO OBRIGATORIO: ${expectedArtifact}`,
1455
+ });
1456
+ } catch (e) {
1457
+ lastDirectError = e;
1458
+ if (directAttempt === 0) await new Promise(r => setTimeout(r, 1500));
1459
+ }
1460
+ }
1461
+ if (!generated) {
1462
+ item.attempts = Math.max(0, (item.attempts || 1) - 1);
1463
+ const transient = /abort|timeout|fetch|network/i.test(String(lastDirectError && lastDirectError.message || lastDirectError)) || Number(lastDirectError && lastDirectError.status) >= 500;
1464
+ st.status = 'paused'; st.pause_reason = transient ? 'connection' : 'invalid_artifact';
1465
+ st.budget = st.creditsSpent;
1466
+ save(st, dir);
1467
+ onAlert({ type: transient ? 'conn' : 'retry', text: `Geracao direta pausada: ${String(lastDirectError && lastDirectError.message || lastDirectError).slice(0, 180)}. O item continua pendente e sera retomado sem consumir tentativa.` });
1468
+ return st;
1469
+ }
1470
+ totalCredits += generated.credits || 0;
1471
+ finalText = generated.text;
1472
+ }
1473
+ if (!plan) st.creditsSpent += totalCredits;
1474
+ const persisted = persistMetaArtifact(dir, expectedArtifact, finalText);
1475
+ item.passes = true; item.blocked = false; item.directArtifact = false; delete item.directParts;
1476
+ st.rounds.push({ item: item.desc, id: item.id, result: `Artefato validado e persistido pelo harness: ${persisted.target} (${persisted.bytes} bytes)`, credits: totalCredits, steps: plan ? plan.parts.length : 1, at: new Date().toISOString() });
1477
+ save(st, dir);
1478
+ onRoundDone({ marks: [item.id], credits: totalCredits, checklist: st.checklist, spent: st.creditsSpent });
1479
+ continue;
1480
+ }
1481
+
1262
1482
  // Rodada com CONTEXTO ZERADO: o agente recebe só o essencial (objetivo resumido +
1263
1483
  // estado do checklist + resultado da rodada anterior) — nunca o histórico inteiro.
1264
1484
  const last = st.rounds[st.rounds.length - 1];
@@ -1295,7 +1515,10 @@ async function run(goal, opts = {}) {
1295
1515
  const minimalBlock = (lang === 'en'
1296
1516
  ? `\nMINIMAL-CODE RULES (economy — mandatory, think like a pragmatic lazy senior):\n1) Does this really need to exist? If not, don't build it.\n2) Does it already exist in this project? If yes, REUSE it — never rewrite a second version.\n3) Does the language/platform/browser already do it natively? If yes, use the native thing (e.g. <input type="date"> instead of a datepicker lib; fetch instead of axios; CSS instead of a JS animation lib).\n4) Only then write code — the MINIMUM that solves the item. NO extra library without real need, no gratuitous abstractions/wrappers, no speculative "future-proofing".\n`
1297
1517
  : `\nREGRAS DE CÓDIGO MÍNIMO (economia — obrigatórias; pense como um sênior pragmático e "preguiçoso"):\n1) Isso precisa mesmo existir? Se não precisa, NÃO construa.\n2) Já existe neste projeto? Se sim, REUSE — nunca escreva uma segunda versão.\n3) A linguagem/plataforma/navegador já faz isso nativo? Se sim, use o nativo (ex: <input type="date"> em vez de lib de datepicker; fetch em vez de axios; CSS em vez de lib JS de animação).\n4) Só então escreva código — o MÍNIMO que resolve o item. SEM biblioteca extra sem necessidade real, sem abstração/wrapper gratuito, sem "preparar pro futuro" especulativo.\n`);
1298
- const task = feasBlock + archBlock + designBlock + seedBlock + rulesBlock + minimalBlock + (lang === 'en'
1518
+ const artifactBlock = lang === 'en'
1519
+ ? `\nMANDATORY PROOF ARTIFACT FOR THIS ITEM: ${expectedArtifact}\nYou MUST call escrever_arquivo or editar_arquivo and persist the deliverable at exactly this relative path. A text-only answer does NOT complete the item. Create parent directories through the file tool as needed.\n`
1520
+ : `\nARTEFATO DE PROVA OBRIGATORIO DESTE ITEM: ${expectedArtifact}\nVoce DEVE chamar escrever_arquivo ou editar_arquivo e persistir a entrega exatamente nesse caminho relativo. Resposta apenas em texto NAO conclui o item. Crie os diretorios-pai pela ferramenta de arquivo quando necessario.\n`;
1521
+ const task = feasBlock + archBlock + designBlock + seedBlock + rulesBlock + minimalBlock + artifactBlock + (lang === 'en'
1299
1522
  ? `Bigger goal (context — do NOT do everything now):\n${st.goal.slice(0, 800)}\n\nCHECKLIST (state):\n${fmtChecklist(st.checklist)}\n${mapBlock}`
1300
1523
  : `Objetivo maior (contexto — NÃO faça tudo agora):\n${st.goal.slice(0, 800)}\n\nCHECKLIST (estado):\n${fmtChecklist(st.checklist)}\n${mapBlock}`)
1301
1524
  + (last ? `\n${lang === 'en' ? 'Previous round' : 'Rodada anterior'}: ${String(last.result || '').slice(0, 600)}\n` : '')
@@ -1324,13 +1547,34 @@ async function run(goal, opts = {}) {
1324
1547
  // ser o modelo forte (o pensador/olho, grok-4.5) — que sabe compor layout. Volta ao
1325
1548
  // barato assim que o olho aprovar (visual_fix some do checklist). Só nas rodadas visuais.
1326
1549
  let roundModel = useModel;
1550
+ // If the automatic executor family was just proven unavailable, do not
1551
+ // spend three HTTP retries on it again for every checklist item. Keep the
1552
+ // cooldown mission-local and short so normal routing recovers naturally.
1553
+ if (!model && Number(st.autoExecutorDegradedUntil || 0) > Date.now()) {
1554
+ roundModel = 'glm-5.2';
1555
+ }
1556
+ // A model that answered with promises but executed zero tools is avoided
1557
+ // for this item on the next window. This is a capability failure, not a
1558
+ // reason to burn the item's second implementation attempt.
1559
+ if (!model && Array.isArray(item.avoidModels) && item.avoidModels.length) {
1560
+ const avoid = item.avoidModels.map(v => String(v).toLowerCase());
1561
+ const candidates = ['glm-5.2', 'claude-haiku-4-5', 'deepseek-v4-pro'];
1562
+ roundModel = candidates.find(c => !avoid.some(a => a.includes(c) || c.includes(a))) || roundModel;
1563
+ }
1327
1564
  if (visualLadder && item.id === 'visual_fix' && (st.visualFixes || 0) >= VISUAL_ESCALATE_AT && (useThinker || eye)) {
1328
1565
  roundModel = useThinker || eye;
1329
1566
  onAlert({ type: 'escalate', text: `🎨 ts: o olho reprovou o layout ${st.visualFixes}x — passando a MÃO do fix visual pro ${roundModel} (mais forte em layout) até aprovar.` });
1330
1567
  }
1331
1568
  let out = null, connErr = null;
1332
1569
  for (let att = 0; att < 3 && !out; att++) {
1333
- try { out = await agent.run(task, { token, lang, yes, autoAll, model: roundModel, confineDir: dir, skipSessionStart: true, onStep: opts.onStep, onStepDone: opts.onStepDone, askApprove: opts.askApprove, onRemote: opts.onRemote, onThinking: opts.onThinking }); }
1570
+ try {
1571
+ // `agent.run` defaults to a defensive 40-credit cap. Complex meta rounds
1572
+ // can have a minimum context larger than that, so use the remaining
1573
+ // window budget while preserving the mission-wide ceiling.
1574
+ const roundCreditBudget = Math.max(1, Math.min(5000, Math.floor(st.budget - st.creditsSpent)));
1575
+ const roundAllowedTools = Array.isArray(opts.allowedTools) ? opts.allowedTools : toolsForMetaItem(item, st.goal);
1576
+ out = await agent.run(task, { token, lang, yes, autoAll, model: roundModel, maxCredits: roundCreditBudget, allowedTools: roundAllowedTools, confineDir: dir, skipSessionStart: true, onStep: opts.onStep, onStepDone: opts.onStepDone, askApprove: opts.askApprove, onRemote: opts.onRemote, onThinking: opts.onThinking });
1577
+ }
1334
1578
  catch (e) {
1335
1579
  connErr = e;
1336
1580
  // teto de IA estourado → PAUSA limpa e resumível com CTA de upgrade (nunca segue em silêncio)
@@ -1339,13 +1583,63 @@ async function run(goal, opts = {}) {
1339
1583
  onAlert({ type: 'no_credits', text: `💳 ts: seu limite de IA acabou — a missão foi PAUSADA e salva. Assine um plano em terminalsmart.com.br/planos e retome com "ts meta".` });
1340
1584
  return st;
1341
1585
  }
1342
- if (e && (e.code === 'conn' || String(e.message).includes('fetch') || String(e.message).includes('network'))) { onAlert({ type: 'retry', text: `📡 ts: falha de conexão na rodada — tentando de novo (${att + 1}/3)…` }); await new Promise(r => setTimeout(r, 4000 * (att + 1))); }
1343
- else throw e; // erro não-transitório: sobe
1586
+ const transientStatus = e && Number(e.status);
1587
+ const transientProvider = e && ([408, 425, 429, 500, 502, 503, 504].includes(transientStatus) || (transientStatus >= 520 && transientStatus <= 527) || e.code === 'conn' || /fetch|network|temporariamente indispon[ií]vel/i.test(String(e.message)));
1588
+ if (transientProvider) {
1589
+ // Automatic Pro mode: once the selected executor exhausts its HTTP
1590
+ // retries, switch to another tool-capable model before pausing.
1591
+ // Change provider family first: if Flash is down, Pro from the same
1592
+ // family is likely affected too. GLM is the economical independent fallback.
1593
+ const fallbacks = ['glm-5.2', 'deepseek-v4-pro', 'claude-haiku-4-5'];
1594
+ if (!model && att === 0 && !roundModel) {
1595
+ st.autoExecutorDegradedUntil = Date.now() + (10 * 60 * 1000);
1596
+ save(st, dir);
1597
+ }
1598
+ if (!model && att < fallbacks.length) roundModel = fallbacks[att];
1599
+ onAlert({ type: 'retry', text: `📡 ts: provedor indisponível — tentando de novo (${att + 1}/3)${roundModel ? ` com ${roundModel}` : ''}…` });
1600
+ await new Promise(r => setTimeout(r, 4000 * (att + 1)));
1601
+ } else throw e; // erro não-transitório: sobe
1344
1602
  }
1345
1603
  }
1346
1604
  if (!out) { st.status = 'paused'; st.pause_reason = 'connection'; save(st, dir); onAlert({ type: 'conn', text: `📡 ts: sem conexão com o servidor após 3 tentativas. Missão PAUSADA e salva. Retome com "ts meta" quando a internet voltar.` }); return st; }
1347
1605
  st.creditsSpent += out.credits || 0;
1348
1606
 
1607
+ // A preflight budget stop is financial state, not an implementation
1608
+ // failure. Do not consume an attempt or block a valid checklist item.
1609
+ if (out.guard && out.guard.kind === 'credits' && !(out.steps > 0)) {
1610
+ item.attempts = Math.max(0, (item.attempts || 1) - 1);
1611
+ st.status = 'paused';
1612
+ st.pause_reason = 'budget';
1613
+ // Signals night mode to open a fresh window without inventing spend.
1614
+ st.budget = st.creditsSpent;
1615
+ save(st, dir);
1616
+ const need = out.guard.required ? ` (minimo estimado: ${out.guard.required}; disponivel: ${out.guard.available || 0})` : '';
1617
+ onAlert({ type: 'budget', text: `A rodada precisava de mais contexto que o saldo desta janela${need}. Pausei sem marcar o item como falho; a proxima janela retoma limpo.` });
1618
+ return st;
1619
+ }
1620
+
1621
+ const expectedNow = String(expectedArtifact || '').replace(/\\/g, '/').toLowerCase();
1622
+ const wroteExpectedNow = (out.actions || []).some(a => {
1623
+ if (!['escrever_arquivo', 'editar_arquivo'].includes(a && a.name)) return false;
1624
+ const target = String(a.target || '').replace(/\\/g, '/').toLowerCase();
1625
+ return target === expectedNow || target.endsWith('/' + expectedNow);
1626
+ });
1627
+ // Reading/searching is not implementation progress. A model can consume
1628
+ // several tool steps exploring the project and still fail to create the
1629
+ // promised deliverable; treat that exactly like a zero-tool promise.
1630
+ if (expectedNow && !wroteExpectedNow) {
1631
+ item.attempts = Math.max(0, (item.attempts || 1) - 1);
1632
+ item.avoidModels = [...new Set([...(item.avoidModels || []), String(out.model || roundModel || 'automatico')])];
1633
+ item.directArtifact = true;
1634
+ const failedModel = String(out.model || roundModel || 'glm-5.2');
1635
+ item.directModel = /glm/i.test(failedModel) ? 'glm-5.2' : failedModel;
1636
+ st.status = 'paused'; st.pause_reason = 'model_no_action';
1637
+ st.budget = st.creditsSpent;
1638
+ save(st, dir);
1639
+ onAlert({ type: 'retry', text: `O modelo ${out.model || roundModel || 'automatico'} respondeu, mas executou zero ferramentas. Nao marquei falha do item; a retomada usara outro executor.` });
1640
+ return st;
1641
+ }
1642
+
1349
1643
  // ── BLOQUEIO HUMANO: o agente pediu uma ação externa que só o usuário faz ──
1350
1644
  // A missão PAUSA (gasto zero enquanto espera) e chama o usuário. Retoma com "ts meta".
1351
1645
  if (out.needHuman) {
@@ -1364,7 +1658,7 @@ async function run(goal, opts = {}) {
1364
1658
  let marks = [];
1365
1659
  const written = (out.actions || []).filter(a => a.name === 'escrever_arquivo' || a.name === 'editar_arquivo').map(a => String(a.target || ''));
1366
1660
  const _basename = (p) => String(p).replace(/\\/g, '/').split('/').pop().toLowerCase();
1367
- const _fileHints = (txt) => (String(txt).match(/[\w.\-]+\.(kt|java|xml|gradle|json|md|txt|kts|properties|pro|png|webp|py|js|ts|html|css|sh)\b/gi) || []).map(s => s.toLowerCase());
1661
+ const _fileHints = (txt) => (String(txt).match(/[\w.\-]+\.(kt|java|xml|gradle|json|md|txt|kts|properties|pro|png|webp|py|gd|js|ts|html|css|sh)\b/gi) || []).map(s => s.toLowerCase());
1368
1662
  const itemFiles = _fileHints(item.desc);
1369
1663
  // Uma escrita/comando genérico não prova um item sem vínculo. Só há marcação automática
1370
1664
  // quando o próprio texto do item identifica o artefato e a rodada gravou esse artefato.
@@ -1372,7 +1666,13 @@ async function run(goal, opts = {}) {
1372
1666
  // para outros itens só porque algum arquivo/comando apareceu na mesma rodada.
1373
1667
  const wroteForItem = itemFiles.length > 0
1374
1668
  && itemFiles.some(f => written.some(w => _basename(w) === f));
1375
- if (wroteForItem) marks.push(item.id);
1669
+ const _normPath = (p) => String(p || '').replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase();
1670
+ const expectedNorm = _normPath(expectedArtifact);
1671
+ const wroteExpectedArtifact = !!expectedNorm && written.some(w => {
1672
+ const n = _normPath(w);
1673
+ return n === expectedNorm || n.endsWith('/' + expectedNorm);
1674
+ });
1675
+ if (wroteForItem || wroteExpectedArtifact) marks.push(item.id);
1376
1676
 
1377
1677
  // Item de correção de build: NÃO usa marcador — o PORTÃO DE BUILD é a única
1378
1678
  // autoridade (recompila de verdade). Marca provisório pra reabrir o gate; se o
@@ -1394,7 +1694,7 @@ async function run(goal, opts = {}) {
1394
1694
  const mk = await _llmJson({ token, system: MARK_SYS,
1395
1695
  user: `OBJETIVO:\n${st.goal.slice(0, 600)}\n\nCHECKLIST (contexto):\n${fmtChecklist(st.checklist)}\n\nITEM ALVO: (${item.id}) ${item.desc}\n\nRESULTADO DA RODADA:\n${String(out.text || '').slice(0, 2000)}${evid}` });
1396
1696
  st.creditsSpent += mk.credits || 0;
1397
- if (mk.json && mk.json.passou === true) marks.push(item.id);
1697
+ if (mk.json && mk.json.passou === true && wroteExpectedArtifact) marks.push(item.id);
1398
1698
  } catch (_) {}
1399
1699
  marks = [...new Set(marks)];
1400
1700
  for (const it of st.checklist) if (marks.includes(it.id)) it.passes = true;
@@ -1456,4 +1756,4 @@ function trailSummary(st, lang) {
1456
1756
  };
1457
1757
  }
1458
1758
 
1459
- module.exports = { webRunGate, run, load, notify, stateFile, detectBuild, ensureToolchain, runBuild, escalate, designPhase, archPhase, looksVisual, looksComplex, runApp, visualGate, ensureEmulator, _projKind, _llmVision, verificationLabel, _checkCriteria, trailSummary };
1759
+ module.exports = { webRunGate, run, load, notify, stateFile, detectBuild, ensureToolchain, runBuild, escalate, designPhase, archPhase, looksVisual, looksComplex, runApp, visualGate, ensureEmulator, _projKind, _llmVision, toolsForMetaItem, artifactForMetaItem, normalizeMetaArtifact, persistMetaArtifact, directArtifactPlan, verificationLabel, _checkCriteria, trailSummary };
@@ -92,27 +92,82 @@ function _replaceInXmlText(xml, search, replacement, replaceAll) {
92
92
  return { xml: output, count: selected.length };
93
93
  }
94
94
 
95
+ function _replaceTableValueByLabel(xml, label, value, replaceAll = false) {
96
+ const rowRe = /<w:tr(?:\s[^>]*)?>[\s\S]*?<\/w:tr>/g;
97
+ const rows = []; let match;
98
+ while ((match = rowRe.exec(xml))) {
99
+ const raw = match[0];
100
+ const cells = [...raw.matchAll(/<w:tc(?:\s[^>]*)?>[\s\S]*?<\/w:tc>/g)].map(cell => ({
101
+ raw: cell[0], start: cell.index,
102
+ text: _decodeXml([...cell[0].matchAll(/<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>/g)].map(node => node[1]).join('')),
103
+ }));
104
+ const labelIndex = cells.findIndex(cell => cell.text.trim() === label.trim());
105
+ if (labelIndex >= 0 && cells[labelIndex + 1]) rows.push({ start: match.index, end: rowRe.lastIndex, raw, cells, labelIndex });
106
+ }
107
+ if (!rows.length) return { xml, count: 0 };
108
+ if (rows.length > 1 && !replaceAll) throw new Error(`Rótulo de tabela ambíguo: ${rows.length} ocorrências para ${label}.`);
109
+ const selected = replaceAll ? rows : rows.slice(0, 1);
110
+ let output = xml;
111
+ for (let index = selected.length - 1; index >= 0; index--) {
112
+ const row = selected[index];
113
+ const target = row.cells[row.labelIndex + 1];
114
+ const changed = _replaceInXmlText(target.raw, target.text, String(value ?? ''), false);
115
+ if (!changed.count) throw new Error(`Não foi possível alterar a célula ao lado de ${label}.`);
116
+ const rowXml = row.raw.slice(0, target.start) + changed.xml + row.raw.slice(target.start + target.raw.length);
117
+ output = output.slice(0, row.start) + rowXml + output.slice(row.end);
118
+ }
119
+ return { xml: output, count: selected.length };
120
+ }
121
+
95
122
  async function editarDocumento(input = {}) {
96
123
  try {
97
124
  const original = _existing(input.caminho, '.docx');
98
- const search = String(input.buscar || '');
99
- const replacement = String(input.substituir ?? '');
100
- if (!search) throw new Error('Informe buscar com o texto exato que será alterado.');
125
+ const rules = Array.isArray(input.substituicoes) && input.substituicoes.length
126
+ ? input.substituicoes.slice(0, 50)
127
+ : (input.buscar != null ? [{ buscar: input.buscar, substituir: input.substituir, todas: input.todas }] : []);
128
+ const tableRules = Array.isArray(input.alteracoes_tabela) ? input.alteracoes_tabela.slice(0, 50) : [];
129
+ if (!rules.length && !tableRules.length) throw new Error('Informe substituicoes ou alteracoes_tabela.');
130
+ if (rules.some(rule => !String(rule && rule.buscar || ''))) {
131
+ throw new Error('Informe buscar com o texto exato em cada substituição.');
132
+ }
101
133
  const destination = _destination(original, input, '.docx');
102
134
  const zip = await JSZip.loadAsync(fs.readFileSync(original));
103
135
  const xmlFiles = Object.keys(zip.files).filter(name => /^word\/(document|header\d+|footer\d+|footnotes|endnotes)\.xml$/.test(name));
104
- let changes = 0;
105
- for (const name of xmlFiles) {
106
- const xml = await zip.file(name).async('string');
107
- const result = _replaceInXmlText(xml, search, replacement, !!input.todas);
108
- if (result.count) { zip.file(name, result.xml); changes += result.count; }
109
- if (changes && !input.todas) break;
136
+ let changes = 0; const applied = [];
137
+ for (const rule of rules) {
138
+ const search = String(rule.buscar || '');
139
+ const replacement = String(rule.substituir ?? '');
140
+ const replaceAll = !!rule.todas;
141
+ let ruleChanges = 0;
142
+ for (const name of xmlFiles) {
143
+ const xml = await zip.file(name).async('string');
144
+ const result = _replaceInXmlText(xml, search, replacement, replaceAll);
145
+ if (result.count) { zip.file(name, result.xml); ruleChanges += result.count; }
146
+ if (ruleChanges && !replaceAll) break;
147
+ }
148
+ if (!ruleChanges) throw new Error(`Texto não encontrado no documento: ${search.slice(0, 100)}`);
149
+ changes += ruleChanges;
150
+ applied.push({ buscar: search.slice(0, 120), ocorrencias: ruleChanges });
151
+ }
152
+ for (const rule of tableRules) {
153
+ const label = String(rule && rule.rotulo || '');
154
+ if (!label) throw new Error('Informe rotulo em cada alteração de tabela.');
155
+ let ruleChanges = 0;
156
+ for (const name of xmlFiles) {
157
+ const xml = await zip.file(name).async('string');
158
+ const result = _replaceTableValueByLabel(xml, label, rule.valor, !!rule.todas);
159
+ if (result.count) { zip.file(name, result.xml); ruleChanges += result.count; }
160
+ if (ruleChanges && !rule.todas) break;
161
+ }
162
+ if (!ruleChanges) throw new Error(`Rótulo não encontrado em tabela: ${label.slice(0, 100)}`);
163
+ changes += ruleChanges;
164
+ applied.push({ rotulo: label.slice(0, 120), ocorrencias: ruleChanges });
110
165
  }
111
- if (!changes) throw new Error('Texto não encontrado no documento. Leia o arquivo e copie um trecho exato.');
112
166
  const backup = destination === original ? _backup(original) : null;
113
167
  const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
114
168
  fs.writeFileSync(destination, buffer);
115
- return { ok: true, caminho: destination, editou_original: destination === original, ocorrencias: changes, backup };
169
+ return { ok: true, caminho: destination, editou_original: destination === original,
170
+ ocorrencias: changes, substituicoes_aplicadas: applied, backup };
116
171
  } catch (error) { return { ok: false, erro: 'Falha ao editar DOCX: ' + error.message }; }
117
172
  }
118
173
 
@@ -128,7 +183,9 @@ async function editarPlanilha(input = {}) {
128
183
  const backup = destination === original ? _backup(original) : null;
129
184
  const { editXlsxCompat } = require('./xlsx-compat-editor');
130
185
  const result = await editXlsxCompat(original, destination, input);
131
- return { ok: true, caminho: destination, editou_original: destination === original, celulas_alteradas: result.changes.length, alteracoes: result.changes.slice(0, 100), backup, engine: result.engine, compatibilidade: readError.message };
186
+ return { ok: true, caminho: destination, editou_original: destination === original, celulas_alteradas: result.changes.length,
187
+ alteracoes: result.changes.slice(0, 100), formulas_invalidadas: result.formulasInvalidated,
188
+ recalcular_ao_abrir: result.recalculateOnOpen, backup, engine: result.engine, compatibilidade: readError.message };
132
189
  }
133
190
  const changes = [];
134
191
  for (const update of (Array.isArray(input.alteracoes) ? input.alteracoes : []).slice(0, 500)) {
@@ -162,10 +219,27 @@ async function editarPlanilha(input = {}) {
162
219
  if (!replaced) throw new Error(`Texto não encontrado na planilha: ${search.slice(0, 80)}`);
163
220
  }
164
221
  if (!changes.length) throw new Error('Informe alteracoes ou substituicoes.');
222
+ // ExcelJS preserves cached formula results that may depend on edited input cells.
223
+ // Those caches become stale for previews/readers until Excel recalculates. Keep
224
+ // each formula, discard only its old result and request a full calculation on open.
225
+ let formulasInvalidated = 0;
226
+ for (const sheet of workbook.worksheets) {
227
+ sheet.eachRow({ includeEmpty: false }, row => row.eachCell({ includeEmpty: false }, cell => {
228
+ const value = cell.value;
229
+ if (!value || typeof value !== 'object' || (!value.formula && !value.sharedFormula)) return;
230
+ if (Object.prototype.hasOwnProperty.call(value, 'result')) {
231
+ const fresh = { ...value };
232
+ delete fresh.result;
233
+ cell.value = fresh;
234
+ formulasInvalidated++;
235
+ }
236
+ }));
237
+ }
238
+ workbook.calcProperties.fullCalcOnLoad = true;
165
239
  const backup = destination === original ? _backup(original) : null;
166
240
  await workbook.xlsx.writeFile(destination);
167
- return { ok: true, caminho: destination, editou_original: destination === original, celulas_alteradas: changes.length, alteracoes: changes.slice(0, 100), backup, engine: 'exceljs' };
241
+ return { ok: true, caminho: destination, editou_original: destination === original, celulas_alteradas: changes.length, alteracoes: changes.slice(0, 100), formulas_invalidadas: formulasInvalidated, recalcular_ao_abrir: true, backup, engine: 'exceljs' };
168
242
  } catch (error) { return { ok: false, erro: 'Falha ao editar XLSX: ' + error.message }; }
169
243
  }
170
244
 
171
- module.exports = { editarDocumento, editarPlanilha, _replaceInXmlText };
245
+ module.exports = { editarDocumento, editarPlanilha, _replaceInXmlText, _replaceTableValueByLabel };
@@ -103,4 +103,17 @@ async function lerApresentacao(input = {}) {
103
103
  }
104
104
  }
105
105
 
106
- module.exports = { lerDocumento, lerApresentacao };
106
+ async function lerPlanilha(input = {}) {
107
+ try {
108
+ const p = path.resolve(String(input.caminho || ''));
109
+ if (!p.toLowerCase().endsWith('.xlsx')) throw new Error('O arquivo precisa terminar em .xlsx.');
110
+ if (!fs.existsSync(p) || !fs.statSync(p).isFile()) throw new Error('Arquivo não encontrado: ' + p);
111
+ if (fs.statSync(p).size > 80 * 1048576) throw new Error('Arquivo maior que 80 MB.');
112
+ const data = await require('./xlsx-compat-editor').readXlsxCompat(p, input);
113
+ return { ok: true, arquivo: p, tamanho_bytes: fs.statSync(p).size, ...data };
114
+ } catch (error) {
115
+ return { ok: false, erro: 'Falha ao ler XLSX: ' + error.message };
116
+ }
117
+ }
118
+
119
+ module.exports = { lerDocumento, lerPlanilha, lerApresentacao };