terminal-smart-cli 0.97.7 → 0.97.9

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,10 +8,12 @@ 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');
14
15
  const tools = require('./tools');
16
+ const { MissionToolCache, cacheReference } = require('./mission-tool-cache');
15
17
 
16
18
  // SKILLS INSTALADAS (~/.ts/skills/<slug>/SKILL.md): lê nome+descrição do frontmatter pra
17
19
  // oferecer ao agente. O agente LÊ o SKILL.md completo (com ler_arquivo) quando a skill é útil.
@@ -36,6 +38,44 @@ function installedSkills() {
36
38
 
37
39
  const MAX_ITER = 15;
38
40
  const TOOL_RESULT_CAP = 6000; // chars por resultado no contexto (compactor já reduz antes)
41
+ const DEFAULT_MISSION_CREDIT_CAP = 40;
42
+ const DEFAULT_MISSION_TOKEN_CAP = 180000;
43
+ const DEFAULT_MISSION_TIME_MS = 120000;
44
+ const DEFAULT_EQUIVALENT_FAILURE_CAP = 2;
45
+
46
+ function failureFingerprint(tool, classified, result) {
47
+ const evidence = String((classified && classified.evidence) || (result && (result.erro || result.stderr)) || '')
48
+ .toLowerCase()
49
+ .replace(/[a-f0-9]{16,}/g, '<id>')
50
+ .replace(/\d+/g, '#')
51
+ .replace(/\s+/g, ' ')
52
+ .trim()
53
+ .slice(0, 180);
54
+ return `${String(tool || 'tool')}|${String((classified && classified.errorClass) || 'unknown')}|${evidence}`;
55
+ }
56
+
57
+ function validarModeloByok(k) {
58
+ if (!k || k.source !== 'byok') return null;
59
+ const estado = providers.estadoModelo(k.provedor, k.modelo);
60
+ if (estado.disponivel) return null;
61
+ const dica = estado.substituto ? ' Troque para "' + estado.substituto + '"' : ' Escolha outro modelo';
62
+ return new ApiError('O modelo BYOK "' + k.modelo + '" foi aposentado: ' + estado.motivo + '.' + dica +
63
+ ' com `ts conectar ' + k.provedor + '`. O modo Automático pode usar fallback com `ts conectar nuvem`.',
64
+ { status: 410, code: 'model_retired' });
65
+ }
66
+
67
+ function missionGuardDecision({ credits = 0, maxCredits = DEFAULT_MISSION_CREDIT_CAP,
68
+ tokens = 0, maxTokens = DEFAULT_MISSION_TOKEN_CAP, elapsedMs = 0,
69
+ maxMs = DEFAULT_MISSION_TIME_MS, equivalentFailures = 0,
70
+ maxEquivalentFailures = DEFAULT_EQUIVALENT_FAILURE_CAP } = {}) {
71
+ if (maxCredits > 0 && credits >= maxCredits) return { kind: 'credits', limit: maxCredits, spent: credits };
72
+ if (maxTokens > 0 && tokens >= maxTokens) return { kind: 'tokens', limit: maxTokens, spent: tokens };
73
+ if (maxMs > 0 && elapsedMs >= maxMs) return { kind: 'time', limit: maxMs, spent: elapsedMs };
74
+ if (maxEquivalentFailures > 0 && equivalentFailures >= maxEquivalentFailures) {
75
+ return { kind: 'equivalent_failures', limit: maxEquivalentFailures, spent: equivalentFailures };
76
+ }
77
+ return null;
78
+ }
39
79
 
40
80
  // ── NÚCLEO CANÔNICO ──────────────────────────────────────────────────────────
41
81
  // Executor padrão, janelas de contexto e auto-compactação vêm de lib/core.js (fonte
@@ -122,8 +162,9 @@ RULES:
122
162
  // fechamento garantido pra o modelo não tentar chamar ferramenta de novo).
123
163
  // Ferramentas SÓ-LEITURA: usadas no modo Ask (--ler), no Plan (--plano) e no sub-agente
124
164
  // 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']);
165
+ 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
166
  const DEVICE_MUTATING = new Set(['android_parear', 'android_conectar', 'android_instalar', 'android_iniciar', 'android_capturar_tela']);
167
+ 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
168
  async function llm({ baseUrl, key, messages, model, signalMs = 180000, noTools = false, toolsOverride = null, onRetry = null }) {
128
169
  // RESILIÊNCIA: o gateway CDC pode reiniciar/oscilar no meio de uma missão longa.
129
170
  // withRetry cobre conn/timeout/5xx (backoff+jitter); NUNCA re-tenta no_credits/auth.
@@ -268,6 +309,11 @@ async function _remoteApprove(comando, token, onRemote) {
268
309
  async function run(task, opts = {}) {
269
310
  const { token, lang = 'pt', yes = false, autoAll = false, model = null, confineDir = null, onStep = () => {}, onStepDone = () => {}, askApprove = async () => false, onThinking = () => {}, onRemote = () => {} } = opts;
270
311
  const maxIter = Math.max(1, Math.min(Number(opts.maxIter) || MAX_ITER, 120)); // --passos N (teto 120)
312
+ const maxCredits = Math.max(1, Math.min(Number(opts.maxCredits) || DEFAULT_MISSION_CREDIT_CAP, 5000));
313
+ const maxTokens = Math.max(1000, Math.min(Number(opts.maxTokens) || DEFAULT_MISSION_TOKEN_CAP, 2000000));
314
+ const maxDurationMs = Math.max(10000, Math.min(Number(opts.maxDurationMs) || DEFAULT_MISSION_TIME_MS, 3600000));
315
+ const maxEquivalentFailures = Math.max(1, Math.min(Number(opts.maxEquivalentFailures) || DEFAULT_EQUIVALENT_FAILURE_CAP, 10));
316
+ const missionStartedAt = Date.now();
271
317
  // FULL-AUTO (autoridade total): aprova destrutivos sem perguntar. Começa por --yolo/--full-auto
272
318
  // (opts.autoAll) e pode ser LIGADO no meio da sessão quando o humano responde "a" (aprovar tudo).
273
319
  let autoApproveDestructive = !!autoAll;
@@ -296,6 +342,11 @@ async function run(task, opts = {}) {
296
342
  // o gate por plano no backend (402 plan_limit → mensagem de upgrade).
297
343
  const k = await keyring.resolve(token, { feature: 'cli_agent' });
298
344
  if (!k || !k.key) throw new ApiError('ai_key', {});
345
+ // BYOK é uma escolha explícita do usuário: nunca substituímos silenciosamente
346
+ // o modelo. Se ele foi aposentado, paramos ANTES da primeira chamada e indicamos
347
+ // como trocar. No modo TS Cloud/Automático, o gateway continua livre para fallback.
348
+ const erroModeloByok = validarModeloByok(k);
349
+ if (erroModeloByok) throw erroModeloByok;
299
350
  let selectedModel = model;
300
351
  // BYOK: o catálogo do roteador é do GATEWAY (ids tipo "deepseek-v4-flash"); o provedor
301
352
  // do usuário tem os seus ("deepseek-ai/deepseek-v4-flash") e devolve 404 pro id errado.
@@ -444,6 +495,7 @@ async function run(task, opts = {}) {
444
495
  // ação (mesmo comando falhando, ciclo A/B/A/B). WARN = empurra a mudar de abordagem; 2º strike
445
496
  // (ou já avisado e ainda em ciclo) = encerra o loop e cai no fechamento honesto garantido.
446
497
  const _callCounts = new Map(); const _recentSigs = []; const _warnedSigs = new Set();
498
+ const _missionCache = new MissionToolCache();
447
499
  let _loopWarned = false, loopedOut = false;
448
500
  const LOOP_WARN = Number(process.env.TS_LOOP_WARN) > 0 ? Number(process.env.TS_LOOP_WARN) : 3;
449
501
  const LOOP_BREAK = Number(process.env.TS_LOOP_BREAK) > 0 ? Number(process.env.TS_LOOP_BREAK) : 5;
@@ -491,7 +543,15 @@ async function run(task, opts = {}) {
491
543
  const _mcpSeen = new Set(); // servidores MCP já autorizados NESTA sessão (1ª chamada pede OK)
492
544
  const actions = []; // ações REAIS bem-sucedidas (evidência objetiva pro marcador do meta)
493
545
  const _toolErrs = []; // erros de ferramenta TIPADOS na run (core.classifyToolResult) — sinal duro anti-done-falso
546
+ const _failureCounts = new Map();
494
547
  let finalText = '', usedModel = 'smart', steps = 0, charged = 0, _visionCredits = 0;
548
+ let guardStopped = null;
549
+ const _guard = (equivalentFailures = 0) => missionGuardDecision({
550
+ credits: charged + _visionCredits, maxCredits,
551
+ tokens: acc.inTok + acc.outTok, maxTokens,
552
+ elapsedMs: Date.now() - missionStartedAt, maxMs: maxDurationMs,
553
+ equivalentFailures, maxEquivalentFailures,
554
+ });
495
555
  const ctxWindow = winFor(selectedModel);
496
556
  // MEMÓRIA EPISÓDICA: ao fim da run, grava UM episódio (o que fez aqui) → a próxima run
497
557
  // deste projeto LEMBRA e dá continuidade (resolve o "esquecimento entre execuções").
@@ -569,6 +629,8 @@ async function run(task, opts = {}) {
569
629
  let stopped = false;
570
630
  for (let iter = 0; iter < maxIter; iter++) {
571
631
  if (opts.shouldStop && opts.shouldStop()) { stopped = true; break; }
632
+ guardStopped = _guard();
633
+ if (guardStopped) break;
572
634
  // passa o snapshot de progresso pra a status line ao vivo (tempo é contado no cliente)
573
635
  onThinking({ iter, inTok: acc.inTok, outTok: acc.outTok, cachedTok: acc.cachedTok, steps, model: usedModel });
574
636
  await _compactIfNeeded(false); // proativo: compacta ao cruzar ~65% da janela
@@ -600,12 +662,29 @@ async function run(task, opts = {}) {
600
662
  }
601
663
  messages.push({ role: 'assistant', content: r.msg.content || '', tool_calls: tcs });
602
664
 
665
+ // A chamada que acabou de responder já consumiu créditos/tokens. Se ela atingiu o
666
+ // teto, não executamos as ferramentas propostas nem fazemos outra chamada cara.
667
+ guardStopped = _guard();
668
+ if (guardStopped) {
669
+ for (const tc of tcs) messages.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify({
670
+ erro: 'MISSÃO INTERROMPIDA PELO LIMITE DE CUSTO/TEMPO. Nenhuma ferramenta deste lote foi executada.',
671
+ guard: guardStopped,
672
+ }) });
673
+ break;
674
+ }
675
+
603
676
  for (const tc of tcs) {
604
677
  const name = (tc.function && tc.function.name) || '';
605
678
  let input = {}; try { input = JSON.parse((tc.function && tc.function.arguments) || '{}'); } catch (_) {}
606
679
  let result;
680
+ let _cachedContent = '';
607
681
  let _ran = false; // true só quando uma ferramenta REALMENTE executou (não gate/bloqueio) → status ✓/✗
608
682
 
683
+ guardStopped = _guard();
684
+ if (guardStopped) {
685
+ result = { erro: 'MISSÃO INTERROMPIDA PELO LIMITE DE CUSTO/TEMPO. Esta ferramenta não foi executada.', guard: guardStopped };
686
+ }
687
+
609
688
  // ── WATCHDOG anti-loop: mede repetição ANTES de qualquer gate/execução ──
610
689
  const _sig = loopSig(name, input);
611
690
  _recentSigs.push(_sig); if (_recentSigs.length > 8) _recentSigs.shift();
@@ -722,6 +801,24 @@ async function run(task, opts = {}) {
722
801
  // Dispositivos físicos: parear/conectar/instalar/iniciar/capturar alteram o
723
802
  // aparelho ou coletam sua tela. Passam por consentimento explícito sem
724
803
  // transformar IP/código/caminho em comando shell.
804
+ // Nuvem pessoal: cada alteração exige consentimento específico. Nem --yes nem
805
+ // "aprovar tudo" silenciam este gate.
806
+ if (result === undefined && CLOUD_MUTATING.has(name)) {
807
+ const payload = JSON.stringify(input || {});
808
+ const provider = name.includes('outlook') || name.includes('microsoft365') ? 'MICROSOFT 365' : 'GOOGLE WORKSPACE';
809
+ const label = `${provider} → ${name}: ${payload.length > 1500 ? payload.slice(0, 1500) + ` …(+${payload.length - 1500} chars)` : payload}`;
810
+ let approved = false, remoteTried = false;
811
+ if (!yes) {
812
+ 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.` });
813
+ approved = decideApproval({ autoAll: false, decision: dec }).approved;
814
+ } else ({ approved, remoteTried } = await _remoteApprove(label, token, onRemote));
815
+ if (!approved) {
816
+ result = { erro: yes
817
+ ? (remoteTried ? `RECUSADO: o dono negou ou não respondeu à alteração ${provider}.` : `RECUSADO: alteração ${provider} exige aprovação remota no modo --yes.`)
818
+ : `O usuário recusou a alteração na conta ${provider}. Não repita nesta sessão.` };
819
+ onStep({ name, detail: argsShort(name, input), blocked: true });
820
+ }
821
+ }
725
822
  if (result === undefined && DEVICE_MUTATING.has(name)) {
726
823
  const safe = name === 'android_parear'
727
824
  ? `${input.host || '?'}:${input.porta || '?'} (código oculto)`
@@ -877,6 +974,15 @@ async function run(task, opts = {}) {
877
974
  result = { resumo: sub.resumo };
878
975
  steps++; _ran = true;
879
976
  }
977
+ if (result === undefined && READONLY.has(name)) {
978
+ const _cached = _missionCache.get(name, input);
979
+ if (_cached) {
980
+ const _visible = messages.some(m => m && m.role === 'tool' && m.tool_call_id === _cached.toolCallId);
981
+ _cachedContent = cacheReference(_cached, name, _visible);
982
+ result = { cached: true, tool: name };
983
+ onStep({ name, detail: 'cache da missão: ' + argsShort(name, input) });
984
+ }
985
+ }
880
986
  if (result === undefined) {
881
987
  onStep({ name, detail: argsShort(name, input) });
882
988
  result = await tools.execute(name, input, { confineDir, baseDir: cwd, token, allowedTools: _allowedToolNames });
@@ -887,6 +993,16 @@ async function run(task, opts = {}) {
887
993
  const _tr = core.classifyToolResult(result);
888
994
  if (!_tr.ok && _tr.status !== 'blocked') {
889
995
  _toolErrs.push({ tool: name, class: _tr.errorClass, retryable: _tr.retryable, evidence: _tr.evidence });
996
+ const _failureKey = failureFingerprint(name, _tr, result);
997
+ const _equivalentFailures = (_failureCounts.get(_failureKey) || 0) + 1;
998
+ _failureCounts.set(_failureKey, _equivalentFailures);
999
+ const _failureGuard = _guard(_equivalentFailures);
1000
+ if (_failureGuard && _failureGuard.kind === 'equivalent_failures') {
1001
+ guardStopped = Object.assign({}, _failureGuard, { tool: name, evidence: String(_tr.evidence || '').slice(0, 240) });
1002
+ result = Object.assign({}, result, {
1003
+ _guard: `A mesma falha operacional ocorreu ${_equivalentFailures} vezes. O agente foi interrompido para não gastar tentando variações sem prova de progresso.`,
1004
+ });
1005
+ }
890
1006
  // LEDGER DE ERROS (Evolve 3): registro determinístico, zero IA.
891
1007
  try { require('./memoria').logErro(confineDir || cwd, name, String(result.erro || result.stderr || ('exit ' + result.codigo))); } catch (_) {}
892
1008
  // RECOVERY ENGINE: erro de classe CONHECIDA → injeta a estratégia de conserto no
@@ -901,7 +1017,7 @@ async function run(task, opts = {}) {
901
1017
  if (result && result._setCwd) { cwd = result._setCwd; delete result._setCwd; }
902
1018
  // registra a ação se deu certo (arquivo escrito / comando com código 0)
903
1019
  if (!result.erro) {
904
- if ((name === 'escrever_arquivo' || name === 'editar_arquivo') && result.ok) {
1020
+ if (['escrever_arquivo', 'editar_arquivo', 'editar_documento', 'editar_planilha'].includes(name) && result.ok) {
905
1021
  actions.push({ name, target: result.caminho });
906
1022
  // CHECKPOINT da sessão: anota o que mudou e onde está o backup (que o _snapshot
907
1023
  // já criou). É o que permite "desfaz tudo o que o agente fez", em vez de só um
@@ -919,6 +1035,12 @@ async function run(task, opts = {}) {
919
1035
  if (result && (result._backup !== undefined || result._acao !== undefined)) {
920
1036
  result = Object.assign({}, result); delete result._backup; delete result._acao;
921
1037
  }
1038
+ const _cacheClass = core.classifyToolResult(result);
1039
+ if (_cacheClass.ok && READONLY.has(name)) {
1040
+ _missionCache.remember(name, input, { toolCallId: tc.id, content: JSON.stringify(result).slice(0, TOOL_RESULT_CAP) });
1041
+ } else if (_cacheClass.ok && !READONLY.has(name)) {
1042
+ _missionCache.invalidate();
1043
+ }
922
1044
  }
923
1045
  // AVISO SUAVE de convergência: a partir de 2/3 do teto de pesquisa, empurra o modelo a
924
1046
  // concluir (o limite DURO acima corta de vez; este só sinaliza antes, sem bloquear).
@@ -938,15 +1060,24 @@ async function run(task, opts = {}) {
938
1060
  _logEp('human');
939
1061
  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
1062
  }
941
- messages.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result).slice(0, TOOL_RESULT_CAP) });
1063
+ messages.push({ role: 'tool', tool_call_id: tc.id, content: _cachedContent || JSON.stringify(result).slice(0, TOOL_RESULT_CAP) });
1064
+ if (guardStopped) break;
942
1065
  }
943
- if (loopedOut) break; // watchdog cortou → vai pro fechamento honesto garantido
1066
+ if (loopedOut || guardStopped) break; // watchdog/trava de orçamento cortou → fechamento determinístico
944
1067
 
945
1068
  }
946
1069
 
947
1070
  // CANCELADO cooperativamente: não faz a chamada de fechamento (não gastar mais IA);
948
1071
  // devolve um texto curto e o que já rolou.
949
1072
  if (stopped && !finalText) finalText = lang !== 'en' ? '(execução cancelada)' : '(run cancelled)';
1073
+ if (guardStopped && !finalText) {
1074
+ const labels = lang !== 'en'
1075
+ ? { credits: 'o teto de créditos', tokens: 'o teto de tokens', time: 'o tempo máximo', equivalent_failures: 'a mesma falha repetida' }
1076
+ : { credits: 'the credit cap', tokens: 'the token cap', time: 'the time limit', equivalent_failures: 'the same repeated failure' };
1077
+ finalText = lang !== 'en'
1078
+ ? `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.`
1079
+ : `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.`;
1080
+ }
950
1081
  // FECHAMENTO GARANTIDO: o loop NUNCA termina em silêncio. Se saiu sem texto
951
1082
  // (esgotou MAX_ITER ainda chamando ferramentas, ou o modelo devolveu vazio),
952
1083
  // faz UMA chamada final SEM ferramentas pedindo um resumo honesto ao usuário.
@@ -974,7 +1105,7 @@ async function run(task, opts = {}) {
974
1105
  }
975
1106
 
976
1107
  if (_hooks._any) { try { _hooksMod.run(_hooks, 'Stop', { cwd, text: finalText, steps }); } catch (_) {} }
977
- _logEp(stopped ? 'cancel' : (loopedOut ? 'stuck' : 'done'));
1108
+ _logEp(stopped ? 'cancel' : ((loopedOut || guardStopped) ? 'stuck' : 'done'));
978
1109
  await _bill();
979
1110
  // toolErrors: erros de ferramenta que NÃO foram seguidos de uma ação bem-sucedida da MESMA
980
1111
  // ferramenta depois (heurística leve de "não-recuperado") — sinal duro pro meta não marcar done falso.
@@ -1000,12 +1131,12 @@ async function run(task, opts = {}) {
1000
1131
  const _handoff = {
1001
1132
  workstreamId: _workstreamId,
1002
1133
  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),
1134
+ status: stopped ? 'paused' : ((loopedOut || guardStopped) ? 'blocked' : 'done'),
1135
+ changedFiles: actions.filter(a => ['escrever_arquivo', 'editar_arquivo', 'editar_documento', 'editar_planilha'].includes(a.name)).map(a => a.target),
1005
1136
  evidence: actions.map(a => `${a.name}: ${a.target}`),
1006
1137
  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 },
1138
+ nextActions: (loopedOut || guardStopped) ? ['Revisar o último erro e retomar com outra abordagem ou orçamento explícito.'] : [],
1139
+ budget: { currency: 'credits', spent: charged + _visionCredits, limit: maxCredits, remaining: Math.max(0, maxCredits - charged - _visionCredits), stoppedBy: guardStopped && guardStopped.kind },
1009
1140
  };
1010
1141
  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
1142
  const _syncTasks = [
@@ -1021,8 +1152,8 @@ async function run(task, opts = {}) {
1021
1152
  model: usedModel,
1022
1153
  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
1154
  usedTools: steps > 0,
1024
- success: !stopped && !loopedOut,
1025
- falseDone: !stopped && !loopedOut && _actionExpected && actions.length === 0,
1155
+ success: !stopped && !loopedOut && !guardStopped,
1156
+ falseDone: !stopped && !loopedOut && !guardStopped && _actionExpected && actions.length === 0,
1026
1157
  protocolError: _toolErrs.some(e => e.class === 'invalid_schema'),
1027
1158
  usage: { input_tokens: acc.inTok, output_tokens: acc.outTok, cached_tokens: acc.cachedTok },
1028
1159
  source: 'cli',
@@ -1030,7 +1161,7 @@ async function run(task, opts = {}) {
1030
1161
  }));
1031
1162
  await Promise.all(_syncTasks);
1032
1163
  } catch (_) {}
1033
- return { text: finalText, steps, credits: charged + _visionCredits, tokens: acc, model: usedModel, actions, messages, cwd, context: lastCtx, toolErrors: _toolErrs, lastToolError: _lastErr };
1164
+ return { text: finalText, steps, credits: charged + _visionCredits, tokens: acc, model: usedModel, actions, messages, cwd, context: lastCtx, toolErrors: _toolErrs, lastToolError: _lastErr, guard: guardStopped };
1034
1165
  }
1035
1166
 
1036
- module.exports = { run, llm, _test: { winFor, estMsgsTok, COMPACT_AT, KEEP_TAIL, loopSig, isCycle, loopDecision, decideApproval } };
1167
+ 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
  ] },