terminal-smart-cli 0.97.7 → 0.97.8

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 CHANGED
@@ -119,7 +119,37 @@ function help() {
119
119
  console.log(out.join('\n'));
120
120
  }
121
121
 
122
+ function privacyCmd() {
123
+ const privacyUrl = 'https://terminalsmart.com.br/privacidade';
124
+ console.log((cfg.lang === 'en' ? 'Privacy Policy: ' : 'Política de Privacidade: ') + privacyUrl);
125
+ }
126
+
122
127
  // ── Login por código (device-code) ───────────────────────────────────────────
128
+ async function accountCmd(args) {
129
+ const action = String((args && args[0]) || '').toLowerCase();
130
+ const accountUrl = base() + '/minha-conta';
131
+ if (!action || ['abrir', 'open', 'excluir', 'delete'].includes(action)) {
132
+ console.log((cfg.lang === 'en' ? 'Account, data export and deletion: ' : 'Conta, exportação de dados e exclusão: ') + accountUrl);
133
+ if (process.env.TS_NO_BROWSER !== '1') try {
134
+ const { exec } = require('child_process');
135
+ const cmd = process.platform === 'win32' ? `start "" "${accountUrl}"` : process.platform === 'darwin' ? `open "${accountUrl}"` : `xdg-open "${accountUrl}"`;
136
+ exec(cmd, () => {});
137
+ } catch (_) {}
138
+ return;
139
+ }
140
+ if (['exportar', 'export', 'baixar', 'download'].includes(action)) {
141
+ const token = needToken();
142
+ const data = await api('/api/account/export', { token, retry: false });
143
+ const fs = require('fs'), path = require('path');
144
+ const safeUser = String(cfg.username || 'usuario').replace(/[^a-z0-9_-]/gi, '-');
145
+ const file = path.resolve(`terminal-smart-dados-${safeUser}-${new Date().toISOString().slice(0, 10)}.json`);
146
+ fs.writeFileSync(file, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 });
147
+ console.log(ui.okLine((cfg.lang === 'en' ? 'Data saved to: ' : 'Dados salvos em: ') + file));
148
+ return;
149
+ }
150
+ console.log(ui.infoLine(cfg.lang === 'en' ? 'Use: ts account export | ts account open' : 'Use: ts conta exportar | ts conta abrir'));
151
+ }
152
+
123
153
  async function login() {
124
154
  if (cfg.token) {
125
155
  try {
@@ -1134,16 +1164,19 @@ async function agentCmd(words, { askFn, cwd: cwdIn = null, onCwd = null, maxIter
1134
1164
  const token = needToken();
1135
1165
  // flags que levam VALOR: o filtro remove só a flag (começa com "-"), deixando o VALOR nos
1136
1166
  // words → tira o valor daqui pra ele não virar parte do texto da tarefa.
1137
- for (const flag of ['--modelo', '--model', '--output-format']) {
1167
+ for (const flag of ['--modelo', '--model', '--output-format', '--orcamento-creditos', '--max-creditos', '--tempo-max-seg', '--max-tokens']) {
1138
1168
  const _i = process.argv.findIndex(a => a === flag); const _v = _i >= 0 ? process.argv[_i + 1] : null;
1139
1169
  if (_v) { const j = words.indexOf(_v); if (j >= 0) words = words.slice(0, j).concat(words.slice(j + 1)); }
1140
1170
  }
1141
1171
  let task = words.join(' ').trim();
1142
1172
  // Flags INLINE no texto (pro REPL, onde não há argv): --passos N / --steps N / --yolo.
1143
1173
  // Assim "faça tudo ... --passos 60 --yolo" digitado no chat vale igual à linha de comando.
1144
- let _inlinePassos, _inlineYolo = false;
1174
+ let _inlinePassos, _inlineYolo = false, _inlineMaxCredits, _inlineMaxSeconds, _inlineMaxTokens;
1145
1175
  task = task
1146
1176
  .replace(/(?:^|\s)--(?:passos|steps|max-passos)[\s=]+(\d{1,3})\b/gi, (_m, n) => { _inlinePassos = Number(n); return ' '; })
1177
+ .replace(/(?:^|\s)--(?:orcamento-creditos|max-creditos)[\s=]+(\d{1,5})\b/gi, (_m, n) => { _inlineMaxCredits = Number(n); return ' '; })
1178
+ .replace(/(?:^|\s)--tempo-max-seg[\s=]+(\d{1,5})\b/gi, (_m, n) => { _inlineMaxSeconds = Number(n); return ' '; })
1179
+ .replace(/(?:^|\s)--max-tokens[\s=]+(\d{3,7})\b/gi, (_m, n) => { _inlineMaxTokens = Number(n); return ' '; })
1147
1180
  .replace(/(?:^|\s)--(?:yolo|full-auto|auto-tudo)\b/gi, () => { _inlineYolo = true; return ' '; })
1148
1181
  .replace(/\s{2,}/g, ' ').trim();
1149
1182
  const piped = await readStdin();
@@ -1221,8 +1254,15 @@ async function agentCmd(words, { askFn, cwd: cwdIn = null, onCwd = null, maxIter
1221
1254
  // Pra missões grandes ("faça tudo de uma vez") sem o pára-e-continua.
1222
1255
  const _pi = process.argv.findIndex(a => a === '--passos' || a === '--steps' || a === '--max-passos');
1223
1256
  const _maxPassos = _pi >= 0 ? Number(process.argv[_pi + 1]) : (_inlinePassos ?? maxIterIn ?? undefined);
1257
+ const _ci = process.argv.findIndex(a => a === '--orcamento-creditos' || a === '--max-creditos');
1258
+ const _ti = process.argv.findIndex(a => a === '--tempo-max-seg');
1259
+ const _tki = process.argv.findIndex(a => a === '--max-tokens');
1260
+ const _maxCredits = _ci >= 0 ? Number(process.argv[_ci + 1]) : _inlineMaxCredits;
1261
+ const _maxDurationMs = 1000 * (_ti >= 0 ? Number(process.argv[_ti + 1]) : (_inlineMaxSeconds || 0));
1262
+ const _maxTokens = _tki >= 0 ? Number(process.argv[_tki + 1]) : _inlineMaxTokens;
1224
1263
  out = await agent.run(task, {
1225
1264
  token, lang: cfg.lang || 'pt', yes: YES, autoAll: (YOLO || _inlineYolo || autoAllIn), model, priorMessages, cwd: startCwd, readOnly, plan, browser: useBrowser, maxIter: _maxPassos,
1265
+ maxCredits: _maxCredits, maxDurationMs: _maxDurationMs || undefined, maxTokens: _maxTokens,
1226
1266
  onThinking: (p) => {
1227
1267
  if (p && typeof p === 'object') {
1228
1268
  _live.steps = p.steps ?? _live.steps; _live.inTok = p.inTok ?? _live.inTok; _live.outTok = p.outTok ?? _live.outTok;
@@ -1288,10 +1328,10 @@ async function agentCmd(words, { askFn, cwd: cwdIn = null, onCwd = null, maxIter
1288
1328
  // HEADLESS stream-json: fecha com assistant (texto) + result (métricas) e sai.
1289
1329
  if (streamJson) {
1290
1330
  _emit(core.AgentEvents.assistant(out.text || ''));
1291
- _emit(core.AgentEvents.result({ steps: out.steps, credits: out.credits, tokens: out.tokens, context: out.context || null, needHuman: out.needHuman || null, toolErrors: out.toolErrors || [], cwd: effCwd, duration_ms: Date.now() - t0, worktree: _wt ? { path: _wt.path, branch: _wt.branch, base: _wt.base, changed: (_wtInfo && _wtInfo.changed) || 0 } : null }));
1331
+ _emit(core.AgentEvents.result({ steps: out.steps, credits: out.credits, tokens: out.tokens, context: out.context || null, needHuman: out.needHuman || null, toolErrors: out.toolErrors || [], guard: out.guard || null, cwd: effCwd, duration_ms: Date.now() - t0, worktree: _wt ? { path: _wt.path, branch: _wt.branch, base: _wt.base, changed: (_wtInfo && _wtInfo.changed) || 0 } : null }));
1292
1332
  return;
1293
1333
  }
1294
- if (JSON_OUT) { console.log(JSON.stringify({ ok: true, result: out.text, steps: out.steps, credits: out.credits, tokens: out.tokens, context: out.context, needHuman: out.needHuman })); return; }
1334
+ if (JSON_OUT) { console.log(JSON.stringify({ ok: true, result: out.text, steps: out.steps, credits: out.credits, tokens: out.tokens, context: out.context, needHuman: out.needHuman, guard: out.guard || null })); return; }
1295
1335
  // BLOQUEIO HUMANO: o agente pediu uma ação que só o usuário faz — mostra o pedido
1296
1336
  // (antes o retorno vinha com texto vazio e o usuário via só um cabeçalho em branco).
1297
1337
  if (out.needHuman) {
@@ -2594,6 +2634,9 @@ async function doctorCmd() {
2594
2634
  cfg, version: pkg.version, base: base(),
2595
2635
  // backend no ar? 401 conta como "respondeu" (só não autenticado).
2596
2636
  pingBackend: async () => { try { await api('/api/auth/check', { token: cfg.token, timeoutMs: 8000 }); return true; } catch (e) { return (e && e.status === 401); } },
2637
+ // Plano autoritativo. O de ~/.ts é do último login e envelhece a cada assinatura,
2638
+ // troca ou cancelamento — o diagnóstico mostrava um plano que já não existe mais.
2639
+ planoAtual: async () => { try { const r = await api('/api/credits', { token: cfg.token, timeoutMs: 8000 }); return (r && r.plan) || null; } catch (_) { return null; } },
2597
2640
  // última versão publicada no npm (best-effort).
2598
2641
  latestVersion: async () => { try { const r = await fetch('https://registry.npmjs.org/terminal-smart-cli/latest', { signal: AbortSignal.timeout(8000) }); const j = await r.json(); return j && j.version; } catch (_) { return null; } },
2599
2642
  });
@@ -2662,9 +2705,10 @@ async function siteCmd(args) {
2662
2705
  if (JSON_OUT) return console.log(JSON.stringify(r));
2663
2706
  if (!r.sites || !r.sites.length) return console.log(ui.infoLine('Nenhum site criado. Use: ts site criar <nome> "<descrição>"'));
2664
2707
  console.log('\n' + ui.box(r.sites.map(s =>
2665
- C.bold(s.slug) + C.dim(' · ') + (s.status === 'active' ? C.ok('online') : C.dim(s.status)) +
2666
- C.dim(s.preview ? ' · prévia grátis' : '') + '\n ' + C.cyan(s.url)
2667
- ), { title: `ts site · ${r.plan} · limite ${r.siteLimit || 'demo'}` }) + '\n');
2708
+ C.bold(s.name || s.slug) + C.dim(' · ') + (s.status === 'active' ? C.ok('online') : C.dim(s.status)) +
2709
+ C.dim(` · ${s.kind || (s.preview ? 'preview' : 'permanente')}`) + '\n ' + C.cyan(s.url) +
2710
+ (s.expiresAt ? C.dim('\n expira: ' + s.expiresAt) : '')
2711
+ ), { title: `ts site · ${r.plan} · ${(r.limits && r.limits.used) ?? r.sites.length}/${(r.limits && r.limits.maximum) || r.siteLimit || 'demo'}` }) + '\n');
2668
2712
  return;
2669
2713
  }
2670
2714
  if (['criar', 'create', 'gerar', 'generate', 'editar', 'edit', 'alterar', 'update'].includes(sub)) {
@@ -2871,7 +2915,8 @@ async function uso() {
2871
2915
  '',
2872
2916
  unlimited
2873
2917
  ? C.ok(T.uso_unlimited) + C.dim(` · ${r.used || 0} ${T.uso_credits}`)
2874
- : ui.bar((r.used || 0) / Math.max(1, r.granted || 1)) + ` ${C.bold(r.remaining ?? '?')} ${C.dim(`/ ${r.granted} ${T.uso_remaining}`)}`,
2918
+ // A barra mostra o que RESTA, igual ao número ao lado dela.
2919
+ : ui.bar(Math.max(0, r.remaining ?? 0) / Math.max(1, r.granted || 1), 22, { restante: true }) + ` ${C.bold(r.remaining ?? '?')} ${C.dim(`/ ${r.granted} ${T.uso_remaining}`)}`,
2875
2920
  ];
2876
2921
  const ledger = (r.ledger || []).slice(0, 5);
2877
2922
  if (ledger.length) {
@@ -2893,6 +2938,43 @@ async function quem() {
2893
2938
  ], { title: T.quem_title }) + '\n');
2894
2939
  }
2895
2940
 
2941
+ async function integracoesCmd(args) {
2942
+ const token = needToken(); const en = cfg.lang === 'en';
2943
+ const sub = String(args[0] || 'status').toLowerCase();
2944
+ const rawProvider = String(args[1] || '').toLowerCase();
2945
+ const provider = ['google', 'gmail', 'drive'].includes(rawProvider) ? 'google'
2946
+ : ['microsoft', 'outlook', 'hotmail', 'office'].includes(rawProvider) ? 'microsoft' : '';
2947
+ const names = { google: 'Google Workspace', microsoft: 'Microsoft/Outlook' };
2948
+ const getStatus = async p => {
2949
+ try { return await api(`/api/integrations/${p}/status`, { token }); }
2950
+ catch (e) { return { connected: false, error: e.message }; }
2951
+ };
2952
+ if (['status', 'listar', 'list', 'ls'].includes(sub)) {
2953
+ const [google, microsoft] = await Promise.all([getStatus('google'), getStatus('microsoft')]);
2954
+ const data = { google, microsoft };
2955
+ if (JSON_OUT) { console.log(JSON.stringify(data)); return; }
2956
+ const line = (label, item) => C.bold(label.padEnd(22)) + (item.connected ? C.ok(en ? 'connected' : 'conectado') + C.dim(item.email ? ` · ${item.email}` : '') : C.dim(en ? 'not connected' : 'não conectado'));
2957
+ console.log('\n' + ui.box([line('Google Workspace', google), line('Microsoft/Outlook', microsoft), '', C.dim(en ? 'Connect: ts integrations connect google|microsoft' : 'Conectar: ts integracoes conectar google|microsoft'), C.dim(en ? 'Manage on web: terminalsmart.com.br/integracoes' : 'Gerenciar na Web: terminalsmart.com.br/integracoes')], { title: en ? 'Integrations' : 'Integrações' }) + '\n');
2958
+ return;
2959
+ }
2960
+ if (!provider) { console.error(ui.infoLine(en ? 'Choose google or microsoft.' : 'Escolha google ou microsoft.')); process.exit(2); }
2961
+ if (['conectar', 'connect', 'reconectar', 'reconnect'].includes(sub)) {
2962
+ const data = await api(`/api/integrations/${provider}/connect`, { method: 'POST', token, body: { source: 'cli' } });
2963
+ if (JSON_OUT) { console.log(JSON.stringify({ provider, authUrl: data.authUrl })); return; }
2964
+ console.log('\n' + ui.box([C.bold(en ? `Authorize ${names[provider]}` : `Autorize ${names[provider]}`), '', data.authUrl, '', C.dim(en ? 'After authorization, run: ts integrations' : 'Depois de autorizar, rode: ts integracoes')], { title: 'OAuth' }) + '\n');
2965
+ try { require('child_process').exec(process.platform === 'win32' ? `start "" "${data.authUrl}"` : process.platform === 'darwin' ? `open "${data.authUrl}"` : `xdg-open "${data.authUrl}"`); } catch (_) {}
2966
+ return;
2967
+ }
2968
+ if (['desconectar', 'disconnect', 'remover', 'remove'].includes(sub)) {
2969
+ let confirmed = FLAGS.has('--confirmar') || FLAGS.has('--confirm');
2970
+ if (!confirmed && process.stdin.isTTY) confirmed = ['s','sim','y','yes'].includes(String(await ui.ask(C.warn(`Desconectar ${names[provider]}? (s/N) `))).trim().toLowerCase());
2971
+ if (!confirmed) { console.log(ui.infoLine(en ? 'Cancelled. Use --confirm to automate this specific action.' : 'Cancelado. Use --confirmar para automatizar esta ação específica.')); return; }
2972
+ await api(`/api/integrations/${provider}/disconnect`, { method: 'POST', token, body: { confirmed: true } });
2973
+ console.log(ui.okLine(`${names[provider]} ${en ? 'disconnected' : 'desconectado'}`)); return;
2974
+ }
2975
+ console.log(ui.infoLine(en ? 'Usage: ts integrations [status|connect|disconnect] [google|microsoft]' : 'Uso: ts integracoes [status|conectar|desconectar] [google|microsoft]'));
2976
+ }
2977
+
2896
2978
  function idioma(l) {
2897
2979
  const lang = String(l || '').toLowerCase();
2898
2980
  if (!['pt', 'en'].includes(lang)) { console.error(ui.infoLine(T.lang_invalid)); process.exit(2); }
@@ -2993,6 +3075,9 @@ function recallCmd(args) {
2993
3075
  case 'runbook': case 'runbooks': case 'procedimento': return runbookCmd(POS.slice(1));
2994
3076
  case 'trilha': case 'trail': case 'auditoria': return trilhaCmd();
2995
3077
  case 'doctor': case 'diagnostico-ambiente': case 'checkup': return doctorCmd();
3078
+ case 'privacidade': case 'privacy': return privacyCmd();
3079
+ case 'conta': case 'account': return accountCmd(POS.slice(1));
3080
+ case 'integracoes': case 'integrações': case 'integrations': return integracoesCmd(POS.slice(1));
2996
3081
  case 'meta': case 'missao': case 'mission': return metaCmd();
2997
3082
  case 'runs': return runsCmd();
2998
3083
  case 'status': return statusCmd(POS[1]);
package/lib/agent.js CHANGED
@@ -8,6 +8,7 @@ const fs = require('fs');
8
8
  const path = require('path');
9
9
  const { api, ApiError, withRetry } = require('./api');
10
10
  const keyring = require('./keyring');
11
+ const providers = require('./providers');
11
12
  const policy = require('./policy');
12
13
  const checkpoint = require('./checkpoint');
13
14
  const skillIndex = require('./skill-index');
@@ -36,6 +37,44 @@ function installedSkills() {
36
37
 
37
38
  const MAX_ITER = 15;
38
39
  const TOOL_RESULT_CAP = 6000; // chars por resultado no contexto (compactor já reduz antes)
40
+ const DEFAULT_MISSION_CREDIT_CAP = 40;
41
+ const DEFAULT_MISSION_TOKEN_CAP = 180000;
42
+ const DEFAULT_MISSION_TIME_MS = 120000;
43
+ const DEFAULT_EQUIVALENT_FAILURE_CAP = 2;
44
+
45
+ function failureFingerprint(tool, classified, result) {
46
+ const evidence = String((classified && classified.evidence) || (result && (result.erro || result.stderr)) || '')
47
+ .toLowerCase()
48
+ .replace(/[a-f0-9]{16,}/g, '<id>')
49
+ .replace(/\d+/g, '#')
50
+ .replace(/\s+/g, ' ')
51
+ .trim()
52
+ .slice(0, 180);
53
+ return `${String(tool || 'tool')}|${String((classified && classified.errorClass) || 'unknown')}|${evidence}`;
54
+ }
55
+
56
+ function validarModeloByok(k) {
57
+ if (!k || k.source !== 'byok') return null;
58
+ const estado = providers.estadoModelo(k.provedor, k.modelo);
59
+ if (estado.disponivel) return null;
60
+ const dica = estado.substituto ? ' Troque para "' + estado.substituto + '"' : ' Escolha outro modelo';
61
+ return new ApiError('O modelo BYOK "' + k.modelo + '" foi aposentado: ' + estado.motivo + '.' + dica +
62
+ ' com `ts conectar ' + k.provedor + '`. O modo Automático pode usar fallback com `ts conectar nuvem`.',
63
+ { status: 410, code: 'model_retired' });
64
+ }
65
+
66
+ function missionGuardDecision({ credits = 0, maxCredits = DEFAULT_MISSION_CREDIT_CAP,
67
+ tokens = 0, maxTokens = DEFAULT_MISSION_TOKEN_CAP, elapsedMs = 0,
68
+ maxMs = DEFAULT_MISSION_TIME_MS, equivalentFailures = 0,
69
+ maxEquivalentFailures = DEFAULT_EQUIVALENT_FAILURE_CAP } = {}) {
70
+ if (maxCredits > 0 && credits >= maxCredits) return { kind: 'credits', limit: maxCredits, spent: credits };
71
+ if (maxTokens > 0 && tokens >= maxTokens) return { kind: 'tokens', limit: maxTokens, spent: tokens };
72
+ if (maxMs > 0 && elapsedMs >= maxMs) return { kind: 'time', limit: maxMs, spent: elapsedMs };
73
+ if (maxEquivalentFailures > 0 && equivalentFailures >= maxEquivalentFailures) {
74
+ return { kind: 'equivalent_failures', limit: maxEquivalentFailures, spent: equivalentFailures };
75
+ }
76
+ return null;
77
+ }
39
78
 
40
79
  // ── NÚCLEO CANÔNICO ──────────────────────────────────────────────────────────
41
80
  // Executor padrão, janelas de contexto e auto-compactação vêm de lib/core.js (fonte
@@ -122,8 +161,9 @@ RULES:
122
161
  // fechamento garantido pra o modelo não tentar chamar ferramenta de novo).
123
162
  // Ferramentas SÓ-LEITURA: usadas no modo Ask (--ler), no Plan (--plano) e no sub-agente
124
163
  // de exploração (nunca escrevem/rodam comando destrutivo → seguras por construção).
125
- const READONLY = new Set(['ler_arquivo', 'ler_documento', 'ler_apresentacao', 'listar_diretorio', 'buscar_arquivos', 'buscar_codigo', 'mapa_projeto', 'info_sistema', 'buscar_web', 'buscar_skill', 'android_dispositivos', 'android_logs']);
164
+ const READONLY = new Set(['ler_arquivo', 'ler_documento', 'ler_apresentacao', 'listar_diretorio', 'buscar_arquivos', 'buscar_codigo', 'mapa_projeto', 'info_sistema', 'buscar_web', 'buscar_skill', 'android_dispositivos', 'android_logs', 'status_microsoft365', 'listar_emails_outlook', 'ler_email_outlook', 'listar_pastas_outlook', 'obter_anexo_outlook', 'status_google_workspace', 'listar_emails_gmail', 'ler_email_gmail', 'listar_pastas_gmail', 'obter_anexo_gmail', 'listar_arquivos_drive', 'ler_google_docs', 'ler_google_sheets']);
126
165
  const DEVICE_MUTATING = new Set(['android_parear', 'android_conectar', 'android_instalar', 'android_iniciar', 'android_capturar_tela']);
166
+ const CLOUD_MUTATING = new Set(['conectar_microsoft365', 'alterar_email_outlook', 'criar_resposta_outlook', 'criar_encaminhamento_outlook', 'criar_rascunho_outlook', 'enviar_rascunho_outlook', 'alterar_email_gmail', 'criar_resposta_gmail', 'criar_encaminhamento_gmail', 'criar_rascunho_gmail', 'enviar_rascunho_gmail', 'editar_google_docs', 'editar_google_sheets']);
127
167
  async function llm({ baseUrl, key, messages, model, signalMs = 180000, noTools = false, toolsOverride = null, onRetry = null }) {
128
168
  // RESILIÊNCIA: o gateway CDC pode reiniciar/oscilar no meio de uma missão longa.
129
169
  // withRetry cobre conn/timeout/5xx (backoff+jitter); NUNCA re-tenta no_credits/auth.
@@ -268,6 +308,11 @@ async function _remoteApprove(comando, token, onRemote) {
268
308
  async function run(task, opts = {}) {
269
309
  const { token, lang = 'pt', yes = false, autoAll = false, model = null, confineDir = null, onStep = () => {}, onStepDone = () => {}, askApprove = async () => false, onThinking = () => {}, onRemote = () => {} } = opts;
270
310
  const maxIter = Math.max(1, Math.min(Number(opts.maxIter) || MAX_ITER, 120)); // --passos N (teto 120)
311
+ const maxCredits = Math.max(1, Math.min(Number(opts.maxCredits) || DEFAULT_MISSION_CREDIT_CAP, 5000));
312
+ const maxTokens = Math.max(1000, Math.min(Number(opts.maxTokens) || DEFAULT_MISSION_TOKEN_CAP, 2000000));
313
+ const maxDurationMs = Math.max(10000, Math.min(Number(opts.maxDurationMs) || DEFAULT_MISSION_TIME_MS, 3600000));
314
+ const maxEquivalentFailures = Math.max(1, Math.min(Number(opts.maxEquivalentFailures) || DEFAULT_EQUIVALENT_FAILURE_CAP, 10));
315
+ const missionStartedAt = Date.now();
271
316
  // FULL-AUTO (autoridade total): aprova destrutivos sem perguntar. Começa por --yolo/--full-auto
272
317
  // (opts.autoAll) e pode ser LIGADO no meio da sessão quando o humano responde "a" (aprovar tudo).
273
318
  let autoApproveDestructive = !!autoAll;
@@ -296,6 +341,11 @@ async function run(task, opts = {}) {
296
341
  // o gate por plano no backend (402 plan_limit → mensagem de upgrade).
297
342
  const k = await keyring.resolve(token, { feature: 'cli_agent' });
298
343
  if (!k || !k.key) throw new ApiError('ai_key', {});
344
+ // BYOK é uma escolha explícita do usuário: nunca substituímos silenciosamente
345
+ // o modelo. Se ele foi aposentado, paramos ANTES da primeira chamada e indicamos
346
+ // como trocar. No modo TS Cloud/Automático, o gateway continua livre para fallback.
347
+ const erroModeloByok = validarModeloByok(k);
348
+ if (erroModeloByok) throw erroModeloByok;
299
349
  let selectedModel = model;
300
350
  // BYOK: o catálogo do roteador é do GATEWAY (ids tipo "deepseek-v4-flash"); o provedor
301
351
  // do usuário tem os seus ("deepseek-ai/deepseek-v4-flash") e devolve 404 pro id errado.
@@ -491,7 +541,15 @@ async function run(task, opts = {}) {
491
541
  const _mcpSeen = new Set(); // servidores MCP já autorizados NESTA sessão (1ª chamada pede OK)
492
542
  const actions = []; // ações REAIS bem-sucedidas (evidência objetiva pro marcador do meta)
493
543
  const _toolErrs = []; // erros de ferramenta TIPADOS na run (core.classifyToolResult) — sinal duro anti-done-falso
544
+ const _failureCounts = new Map();
494
545
  let finalText = '', usedModel = 'smart', steps = 0, charged = 0, _visionCredits = 0;
546
+ let guardStopped = null;
547
+ const _guard = (equivalentFailures = 0) => missionGuardDecision({
548
+ credits: charged + _visionCredits, maxCredits,
549
+ tokens: acc.inTok + acc.outTok, maxTokens,
550
+ elapsedMs: Date.now() - missionStartedAt, maxMs: maxDurationMs,
551
+ equivalentFailures, maxEquivalentFailures,
552
+ });
495
553
  const ctxWindow = winFor(selectedModel);
496
554
  // MEMÓRIA EPISÓDICA: ao fim da run, grava UM episódio (o que fez aqui) → a próxima run
497
555
  // deste projeto LEMBRA e dá continuidade (resolve o "esquecimento entre execuções").
@@ -569,6 +627,8 @@ async function run(task, opts = {}) {
569
627
  let stopped = false;
570
628
  for (let iter = 0; iter < maxIter; iter++) {
571
629
  if (opts.shouldStop && opts.shouldStop()) { stopped = true; break; }
630
+ guardStopped = _guard();
631
+ if (guardStopped) break;
572
632
  // passa o snapshot de progresso pra a status line ao vivo (tempo é contado no cliente)
573
633
  onThinking({ iter, inTok: acc.inTok, outTok: acc.outTok, cachedTok: acc.cachedTok, steps, model: usedModel });
574
634
  await _compactIfNeeded(false); // proativo: compacta ao cruzar ~65% da janela
@@ -600,12 +660,28 @@ async function run(task, opts = {}) {
600
660
  }
601
661
  messages.push({ role: 'assistant', content: r.msg.content || '', tool_calls: tcs });
602
662
 
663
+ // A chamada que acabou de responder já consumiu créditos/tokens. Se ela atingiu o
664
+ // teto, não executamos as ferramentas propostas nem fazemos outra chamada cara.
665
+ guardStopped = _guard();
666
+ if (guardStopped) {
667
+ for (const tc of tcs) messages.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify({
668
+ erro: 'MISSÃO INTERROMPIDA PELO LIMITE DE CUSTO/TEMPO. Nenhuma ferramenta deste lote foi executada.',
669
+ guard: guardStopped,
670
+ }) });
671
+ break;
672
+ }
673
+
603
674
  for (const tc of tcs) {
604
675
  const name = (tc.function && tc.function.name) || '';
605
676
  let input = {}; try { input = JSON.parse((tc.function && tc.function.arguments) || '{}'); } catch (_) {}
606
677
  let result;
607
678
  let _ran = false; // true só quando uma ferramenta REALMENTE executou (não gate/bloqueio) → status ✓/✗
608
679
 
680
+ guardStopped = _guard();
681
+ if (guardStopped) {
682
+ result = { erro: 'MISSÃO INTERROMPIDA PELO LIMITE DE CUSTO/TEMPO. Esta ferramenta não foi executada.', guard: guardStopped };
683
+ }
684
+
609
685
  // ── WATCHDOG anti-loop: mede repetição ANTES de qualquer gate/execução ──
610
686
  const _sig = loopSig(name, input);
611
687
  _recentSigs.push(_sig); if (_recentSigs.length > 8) _recentSigs.shift();
@@ -722,6 +798,24 @@ async function run(task, opts = {}) {
722
798
  // Dispositivos físicos: parear/conectar/instalar/iniciar/capturar alteram o
723
799
  // aparelho ou coletam sua tela. Passam por consentimento explícito sem
724
800
  // transformar IP/código/caminho em comando shell.
801
+ // Nuvem pessoal: cada alteração exige consentimento específico. Nem --yes nem
802
+ // "aprovar tudo" silenciam este gate.
803
+ if (result === undefined && CLOUD_MUTATING.has(name)) {
804
+ const payload = JSON.stringify(input || {});
805
+ const provider = name.includes('outlook') || name.includes('microsoft365') ? 'MICROSOFT 365' : 'GOOGLE WORKSPACE';
806
+ const label = `${provider} → ${name}: ${payload.length > 1500 ? payload.slice(0, 1500) + ` …(+${payload.length - 1500} chars)` : payload}`;
807
+ let approved = false, remoteTried = false;
808
+ if (!yes) {
809
+ const dec = await askApprove({ kind: 'cloud_mutation', cmd: label, warning: name.includes('enviar_') ? 'O e-mail será enviado ao destinatário.' : `Dados da conta ${provider} serão alterados.` });
810
+ approved = decideApproval({ autoAll: false, decision: dec }).approved;
811
+ } else ({ approved, remoteTried } = await _remoteApprove(label, token, onRemote));
812
+ if (!approved) {
813
+ result = { erro: yes
814
+ ? (remoteTried ? `RECUSADO: o dono negou ou não respondeu à alteração ${provider}.` : `RECUSADO: alteração ${provider} exige aprovação remota no modo --yes.`)
815
+ : `O usuário recusou a alteração na conta ${provider}. Não repita nesta sessão.` };
816
+ onStep({ name, detail: argsShort(name, input), blocked: true });
817
+ }
818
+ }
725
819
  if (result === undefined && DEVICE_MUTATING.has(name)) {
726
820
  const safe = name === 'android_parear'
727
821
  ? `${input.host || '?'}:${input.porta || '?'} (código oculto)`
@@ -887,6 +981,16 @@ async function run(task, opts = {}) {
887
981
  const _tr = core.classifyToolResult(result);
888
982
  if (!_tr.ok && _tr.status !== 'blocked') {
889
983
  _toolErrs.push({ tool: name, class: _tr.errorClass, retryable: _tr.retryable, evidence: _tr.evidence });
984
+ const _failureKey = failureFingerprint(name, _tr, result);
985
+ const _equivalentFailures = (_failureCounts.get(_failureKey) || 0) + 1;
986
+ _failureCounts.set(_failureKey, _equivalentFailures);
987
+ const _failureGuard = _guard(_equivalentFailures);
988
+ if (_failureGuard && _failureGuard.kind === 'equivalent_failures') {
989
+ guardStopped = Object.assign({}, _failureGuard, { tool: name, evidence: String(_tr.evidence || '').slice(0, 240) });
990
+ result = Object.assign({}, result, {
991
+ _guard: `A mesma falha operacional ocorreu ${_equivalentFailures} vezes. O agente foi interrompido para não gastar tentando variações sem prova de progresso.`,
992
+ });
993
+ }
890
994
  // LEDGER DE ERROS (Evolve 3): registro determinístico, zero IA.
891
995
  try { require('./memoria').logErro(confineDir || cwd, name, String(result.erro || result.stderr || ('exit ' + result.codigo))); } catch (_) {}
892
996
  // RECOVERY ENGINE: erro de classe CONHECIDA → injeta a estratégia de conserto no
@@ -901,7 +1005,7 @@ async function run(task, opts = {}) {
901
1005
  if (result && result._setCwd) { cwd = result._setCwd; delete result._setCwd; }
902
1006
  // registra a ação se deu certo (arquivo escrito / comando com código 0)
903
1007
  if (!result.erro) {
904
- if ((name === 'escrever_arquivo' || name === 'editar_arquivo') && result.ok) {
1008
+ if (['escrever_arquivo', 'editar_arquivo', 'editar_documento', 'editar_planilha'].includes(name) && result.ok) {
905
1009
  actions.push({ name, target: result.caminho });
906
1010
  // CHECKPOINT da sessão: anota o que mudou e onde está o backup (que o _snapshot
907
1011
  // já criou). É o que permite "desfaz tudo o que o agente fez", em vez de só um
@@ -939,14 +1043,23 @@ async function run(task, opts = {}) {
939
1043
  return { text: finalText, steps, credits: charged + _visionCredits, tokens: acc, model: usedModel, actions, cwd, context: lastCtx, needHuman: { motivo: result.motivo, o_que_fazer: result.o_que_fazer } };
940
1044
  }
941
1045
  messages.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result).slice(0, TOOL_RESULT_CAP) });
1046
+ if (guardStopped) break;
942
1047
  }
943
- if (loopedOut) break; // watchdog cortou → vai pro fechamento honesto garantido
1048
+ if (loopedOut || guardStopped) break; // watchdog/trava de orçamento cortou → fechamento determinístico
944
1049
 
945
1050
  }
946
1051
 
947
1052
  // CANCELADO cooperativamente: não faz a chamada de fechamento (não gastar mais IA);
948
1053
  // devolve um texto curto e o que já rolou.
949
1054
  if (stopped && !finalText) finalText = lang !== 'en' ? '(execução cancelada)' : '(run cancelled)';
1055
+ if (guardStopped && !finalText) {
1056
+ const labels = lang !== 'en'
1057
+ ? { credits: 'o teto de créditos', tokens: 'o teto de tokens', time: 'o tempo máximo', equivalent_failures: 'a mesma falha repetida' }
1058
+ : { credits: 'the credit cap', tokens: 'the token cap', time: 'the time limit', equivalent_failures: 'the same repeated failure' };
1059
+ finalText = lang !== 'en'
1060
+ ? `Interrompi esta missão porque ela atingiu ${labels[guardStopped.kind] || 'o limite de segurança'}. Foram executados ${steps} passo(s) e consumidos ${charged + _visionCredits} crédito(s). O que já foi concluído permanece válido; a etapa que falhou não foi declarada como pronta. Revise o último erro antes de continuar ou aumente o orçamento explicitamente.`
1061
+ : `I stopped this mission because it reached ${labels[guardStopped.kind] || 'the safety limit'}. ${steps} step(s) ran and ${charged + _visionCredits} credit(s) were consumed. Completed work remains valid; the failed step was not reported as done. Review the last error before continuing or explicitly raise the budget.`;
1062
+ }
950
1063
  // FECHAMENTO GARANTIDO: o loop NUNCA termina em silêncio. Se saiu sem texto
951
1064
  // (esgotou MAX_ITER ainda chamando ferramentas, ou o modelo devolveu vazio),
952
1065
  // faz UMA chamada final SEM ferramentas pedindo um resumo honesto ao usuário.
@@ -974,7 +1087,7 @@ async function run(task, opts = {}) {
974
1087
  }
975
1088
 
976
1089
  if (_hooks._any) { try { _hooksMod.run(_hooks, 'Stop', { cwd, text: finalText, steps }); } catch (_) {} }
977
- _logEp(stopped ? 'cancel' : (loopedOut ? 'stuck' : 'done'));
1090
+ _logEp(stopped ? 'cancel' : ((loopedOut || guardStopped) ? 'stuck' : 'done'));
978
1091
  await _bill();
979
1092
  // toolErrors: erros de ferramenta que NÃO foram seguidos de uma ação bem-sucedida da MESMA
980
1093
  // ferramenta depois (heurística leve de "não-recuperado") — sinal duro pro meta não marcar done falso.
@@ -1000,12 +1113,12 @@ async function run(task, opts = {}) {
1000
1113
  const _handoff = {
1001
1114
  workstreamId: _workstreamId,
1002
1115
  goal: taskText,
1003
- status: stopped ? 'paused' : (loopedOut ? 'blocked' : 'done'),
1004
- changedFiles: actions.filter(a => a.name === 'escrever_arquivo' || a.name === 'editar_arquivo').map(a => a.target),
1116
+ status: stopped ? 'paused' : ((loopedOut || guardStopped) ? 'blocked' : 'done'),
1117
+ changedFiles: actions.filter(a => ['escrever_arquivo', 'editar_arquivo', 'editar_documento', 'editar_planilha'].includes(a.name)).map(a => a.target),
1005
1118
  evidence: actions.map(a => `${a.name}: ${a.target}`),
1006
1119
  failedApproaches: _toolErrs.map(e => `${e.tool || 'tool'}: ${e.errorClass || e.message || 'failed'}`),
1007
- nextActions: loopedOut ? ['Retomar com outra abordagem; a execução anterior entrou em repetição.'] : [],
1008
- budget: { currency: 'credits', spent: charged + _visionCredits, remaining: null },
1120
+ nextActions: (loopedOut || guardStopped) ? ['Revisar o último erro e retomar com outra abordagem ou orçamento explícito.'] : [],
1121
+ budget: { currency: 'credits', spent: charged + _visionCredits, limit: maxCredits, remaining: Math.max(0, maxCredits - charged - _visionCredits), stoppedBy: guardStopped && guardStopped.kind },
1009
1122
  };
1010
1123
  const _actionExpected = /\b(cri|fa[cç]|implemente|corrija|edite|altere|instale|execute|rode|deploy|publique|remova|apague|delete|escreva|salve)\w*/i.test(taskText);
1011
1124
  const _syncTasks = [
@@ -1021,8 +1134,8 @@ async function run(task, opts = {}) {
1021
1134
  model: usedModel,
1022
1135
  taskKind: /\b(c[oó]digo|code|bug|teste|test|node|javascript|typescript|python|html|css|api|arquivo|projeto)\b/i.test(taskText) ? 'code' : 'general',
1023
1136
  usedTools: steps > 0,
1024
- success: !stopped && !loopedOut,
1025
- falseDone: !stopped && !loopedOut && _actionExpected && actions.length === 0,
1137
+ success: !stopped && !loopedOut && !guardStopped,
1138
+ falseDone: !stopped && !loopedOut && !guardStopped && _actionExpected && actions.length === 0,
1026
1139
  protocolError: _toolErrs.some(e => e.class === 'invalid_schema'),
1027
1140
  usage: { input_tokens: acc.inTok, output_tokens: acc.outTok, cached_tokens: acc.cachedTok },
1028
1141
  source: 'cli',
@@ -1030,7 +1143,7 @@ async function run(task, opts = {}) {
1030
1143
  }));
1031
1144
  await Promise.all(_syncTasks);
1032
1145
  } catch (_) {}
1033
- return { text: finalText, steps, credits: charged + _visionCredits, tokens: acc, model: usedModel, actions, messages, cwd, context: lastCtx, toolErrors: _toolErrs, lastToolError: _lastErr };
1146
+ return { text: finalText, steps, credits: charged + _visionCredits, tokens: acc, model: usedModel, actions, messages, cwd, context: lastCtx, toolErrors: _toolErrs, lastToolError: _lastErr, guard: guardStopped };
1034
1147
  }
1035
1148
 
1036
- module.exports = { run, llm, _test: { winFor, estMsgsTok, COMPACT_AT, KEEP_TAIL, loopSig, isCycle, loopDecision, decideApproval } };
1149
+ module.exports = { run, llm, _test: { winFor, estMsgsTok, COMPACT_AT, KEEP_TAIL, loopSig, isCycle, loopDecision, decideApproval, failureFingerprint, missionGuardDecision, validarModeloByok } };
package/lib/doctor.js CHANGED
@@ -12,10 +12,16 @@ const { execFileSync } = require('child_process');
12
12
  // ── helpers PUROS (testáveis) ────────────────────────────────────────────────
13
13
  function _nodeMajor(v) { const m = String(v || '').replace(/^v/, '').split('.'); return Number(m[0]) || 0; }
14
14
  function _nodeOk(v) { return _nodeMajor(v) >= 18; }
15
- // estado de autenticação a partir do config carregado
16
- function _authLevel(cfg) {
15
+ // Estado de autenticação. O plano vem do SERVIDOR quando disponível: o valor em ~/.ts é
16
+ // o que estava valendo no login, e continua lá depois de assinar, trocar ou cancelar
17
+ // um plano — o diagnóstico exibia "ultra" para uma conta que o servidor já tratava como
18
+ // "pro". Sem rede, o valor guardado ainda é melhor que nada, mas fica marcado como tal.
19
+ function _authLevel(cfg, planoDoServidor) {
17
20
  if (!cfg || !cfg.token) return { level: 'fail', detail: 'não logado', dica: 'rode: ts login' };
18
- return { level: 'ok', detail: 'logado' + (cfg.username ? ' como ' + cfg.username : '') + (cfg.plan ? ' · plano ' + cfg.plan : '') };
21
+ const plano = planoDoServidor || cfg.plan;
22
+ const local = !planoDoServidor && cfg.plan;
23
+ return { level: 'ok', detail: 'logado' + (cfg.username ? ' como ' + cfg.username : '')
24
+ + (plano ? ' · plano ' + plano + (local ? ' (do último login)' : '') : '') };
19
25
  }
20
26
  // compara versão instalada × última do npm → ok / warn (desatualizado)
21
27
  function _versionLevel(current, latest) {
@@ -55,7 +61,9 @@ async function run(opts = {}) {
55
61
  results.push({ nome: 'Node.js', level: _nodeOk(nv) ? 'ok' : 'fail', detail: nv + (_nodeOk(nv) ? '' : ' (precisa 18+)'), dica: _nodeOk(nv) ? undefined : 'instale Node 18 ou superior' });
56
62
 
57
63
  // 2) config ~/.ts + login
58
- const auth = _authLevel(cfg); results.push(Object.assign({ nome: 'Login' }, auth));
64
+ let planoDoServidor = null;
65
+ if (typeof opts.planoAtual === 'function') { try { planoDoServidor = await opts.planoAtual(); } catch (_) {} }
66
+ const auth = _authLevel(cfg, planoDoServidor); results.push(Object.assign({ nome: 'Login' }, auth));
59
67
  const dir = path.join(os.homedir(), '.ts');
60
68
  results.push({ nome: 'Config (~/.ts)', level: fs.existsSync(dir) ? 'ok' : 'warn', detail: fs.existsSync(dir) ? dir : 'ainda não criado (normal se nunca rodou nada)' });
61
69
 
package/lib/i18n.js CHANGED
@@ -75,7 +75,10 @@ const STR = {
75
75
  ['ts login · ts logout', 'conecta / desconecta este terminal'],
76
76
  ['ts uso', 'créditos e consumo do mês'],
77
77
  ['ts quem', 'conta conectada'],
78
+ ['ts conta exportar', 'baixa seus dados sem senhas, tokens ou chaves privadas'],
79
+ ['ts conta abrir', 'abre Minha Conta para privacidade e exclusão segura'],
78
80
  ['ts doctor', 'diagnóstico do ambiente (Node, login, gateway, versão, deps opcionais)'],
81
+ ['ts privacidade', 'mostra o endereço da Política de Privacidade'],
79
82
  ['ts tema', 'paleta do terminal (7 temas; a cor indica o ESTADO)'],
80
83
  ['ts idioma pt|en', 'idioma (padrão pt-BR)'],
81
84
  ] },
@@ -240,6 +243,9 @@ const STR = {
240
243
  ['ts login · ts logout', 'connect / disconnect this terminal'],
241
244
  ['ts uso', 'monthly credits and usage'],
242
245
  ['ts quem', 'connected account'],
246
+ ['ts account export', 'download your data without passwords, tokens or private keys'],
247
+ ['ts account open', 'open Account for privacy controls and safe deletion'],
248
+ ['ts privacy', 'shows the Privacy Policy address'],
243
249
  ['ts tema', 'terminal palette (7 themes; color means STATE)'],
244
250
  ['ts idioma pt|en', 'language (default pt-BR)'],
245
251
  ] },