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/bin/ts.js +141 -10
- package/lib/agent.js +625 -38
- package/lib/api.js +9 -2
- package/lib/audit-packs.js +56 -0
- package/lib/evolution-telemetry.js +45 -0
- package/lib/i18n.js +2 -0
- package/lib/image-job.js +31 -0
- package/lib/intelligence-core.js +50 -13
- package/lib/meta.js +314 -14
- package/lib/office-editors.js +88 -14
- package/lib/office-readers.js +14 -1
- package/lib/tools.js +93 -19
- package/lib/xlsx-compat-editor.js +78 -2
- package/package.json +3 -3
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
1343
|
-
|
|
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
|
-
|
|
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 };
|
package/lib/office-editors.js
CHANGED
|
@@ -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
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
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,
|
|
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,
|
|
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 };
|
package/lib/office-readers.js
CHANGED
|
@@ -103,4 +103,17 @@ async function lerApresentacao(input = {}) {
|
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
|
|
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 };
|