terminal-smart-cli 0.98.1 → 0.99.0
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/README.md +7 -5
- package/bin/ts.js +344 -196
- package/hub/cli.js +255 -0
- package/hub/controle.js +262 -0
- package/hub/dpapi.js +55 -0
- package/hub/estado.js +274 -0
- package/hub/exportar-telas.js +149 -0
- package/hub/hub.js +717 -0
- package/hub/mascara.js +67 -0
- package/hub/parar.js +151 -0
- package/hub/principal.js +5 -0
- package/hub/proto/PROTOCOLO.md +789 -0
- package/hub/proto/ts-proto.js +1643 -0
- package/hub/relay-conexao.js +200 -0
- package/hub/telas-arquivo.js +130 -0
- package/hub/tmux.js +189 -0
- package/hub/vps/README.md +109 -0
- package/hub/vps/tmux-agentes.conf +5 -0
- package/hub/vps/ts-hub-exportar-telas.service +30 -0
- package/hub/vps/ts-hub-parar-consumir +67 -0
- package/hub/vps/ts-hub-parar.path +16 -0
- package/hub/vps/ts-hub-parar.service +23 -0
- package/hub/vps/ts-hub-parar.tmpfiles.conf +10 -0
- package/hub/vps/ts-hub.service +39 -0
- package/hub/vps/ts-tmux.service +19 -0
- package/lib/acp.js +8 -2
- package/lib/agent.js +183 -85
- package/lib/chat-local.js +139 -0
- package/lib/core.js +336 -23
- package/lib/doctor.js +13 -5
- package/lib/i18n.js +69 -31
- package/lib/ia-local.js +388 -0
- package/lib/intelligence-core.js +6 -1
- package/lib/keyring.js +0 -1
- package/lib/meta.js +8 -8
- package/lib/personal-agent-llm.js +99 -26
- package/lib/sentinela.js +2 -2
- package/lib/tools.js +638 -8
- package/package.json +4 -2
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// lib/chat-local.js — o `ts chat` rodando no PC do usuário, com a IA DELE.
|
|
2
|
+
//
|
|
3
|
+
// Ordem de escolha da IA (sem a IA da casa como reserva no chat):
|
|
4
|
+
// 1) modo da conta escolhido em `ts ia usar` (chatgpt/claude/paralelo) → a CLI oficial
|
|
5
|
+
// logada neste PC (lib/ia-local); no paralelo, a preferida é o ChatGPT/Codex e o Claude
|
|
6
|
+
// entra se ela falhar;
|
|
7
|
+
// 2) senão, a chave própria ativa (`ts conectar`, inclusive Ollama local) — chamada
|
|
8
|
+
// OpenAI-compatível, a mesma do agente;
|
|
9
|
+
// 3) senão, erro amigável dizendo como configurar.
|
|
10
|
+
//
|
|
11
|
+
// O histórico da conversa fica SÓ neste PC: um arquivo por pasta de trabalho (escopo
|
|
12
|
+
// `chat:<hash>`) em ~/.ts/chat/, com as últimas N mensagens e permissão 0600.
|
|
13
|
+
'use strict';
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
const LIMITE_MENSAGENS = 20; // 10 trocas
|
|
18
|
+
const LIMITE_CONTEUDO = 12000; // por mensagem guardada (transcrição de vídeo, pipe grande…)
|
|
19
|
+
|
|
20
|
+
function sistemaChat(lang) {
|
|
21
|
+
return lang === 'en'
|
|
22
|
+
? 'You are Terminal Smart, a DevOps assistant in the user\'s terminal. Answer in plain text (Markdown is fine), directly, in the user\'s language. In this conversation you do not run commands or change files: when something needs action on the machine, suggest the command or tell the user to use `ts agente "..."`.'
|
|
23
|
+
: 'Você é o Terminal Smart, assistente DevOps no terminal do usuário. Responda em texto (Markdown é permitido), de forma direta, no idioma do usuário. Nesta conversa você não executa comandos nem altera arquivos: quando algo exigir ação na máquina, sugira o comando ou diga para usar `ts agente "..."`.';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function nomeArquivo(escopo) {
|
|
27
|
+
return String(escopo || 'chat').replace(/[^a-z0-9_-]+/gi, '-').slice(0, 80) + '.json';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function criarChatLocal(opcoes = {}) {
|
|
31
|
+
const dir = opcoes.dir || path.join(path.dirname(require('./config').FILE), 'chat');
|
|
32
|
+
const limite = Math.max(2, Number(opcoes.limite) || LIMITE_MENSAGENS);
|
|
33
|
+
const iaLocal = opcoes.iaLocal || require('./ia-local');
|
|
34
|
+
const keyring = opcoes.keyring || require('./keyring');
|
|
35
|
+
const providers = opcoes.providers || require('./providers');
|
|
36
|
+
const llm = opcoes.llm || ((args) => require('./agent').llm(args));
|
|
37
|
+
|
|
38
|
+
const arquivo = (escopo) => path.join(dir, nomeArquivo(escopo));
|
|
39
|
+
|
|
40
|
+
function carregar(escopo) {
|
|
41
|
+
try {
|
|
42
|
+
const j = JSON.parse(fs.readFileSync(arquivo(escopo), 'utf8'));
|
|
43
|
+
const msgs = Array.isArray(j && j.mensagens) ? j.mensagens : [];
|
|
44
|
+
return msgs.filter(m => m && (m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string').slice(-limite);
|
|
45
|
+
} catch (_) { return []; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function salvar(escopo, mensagens) {
|
|
49
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
50
|
+
const alvo = arquivo(escopo);
|
|
51
|
+
const tmp = alvo + '.tmp-' + process.pid;
|
|
52
|
+
const dados = { escopo, atualizadoEm: new Date().toISOString(), mensagens: mensagens.slice(-limite) };
|
|
53
|
+
fs.writeFileSync(tmp, JSON.stringify(dados, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
54
|
+
fs.renameSync(tmp, alvo);
|
|
55
|
+
try { fs.chmodSync(alvo, 0o600); } catch (_) { /* Windows: sem chmod */ }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function limpar(escopo) {
|
|
59
|
+
try { fs.unlinkSync(arquivo(escopo)); return true; }
|
|
60
|
+
catch (e) { if (e && e.code === 'ENOENT') return false; throw e; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Qual IA responde: { tipo:'conta', modo } | { tipo:'chave', chave } | null.
|
|
64
|
+
function escolherFonte(modo) {
|
|
65
|
+
if (modo && modo !== 'automatico' && iaLocal.MODOS.includes(modo)) return { tipo: 'conta', modo };
|
|
66
|
+
const a = keyring.ativo();
|
|
67
|
+
if (a && (a.key || (providers.info(a.id) || {}).semChave)) return { tipo: 'chave', chave: a };
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function semIa(lang) {
|
|
72
|
+
const e = new Error(lang === 'en'
|
|
73
|
+
? 'No AI configured for the chat on this PC. Use your key with `ts conectar <provider>` (or local Ollama: `ts conectar ollama`) or your account with `ts ia usar claude|chatgpt` (Claude Code or Codex logged in on this PC).'
|
|
74
|
+
: 'Nenhuma IA configurada para o chat neste PC. Use sua chave com `ts conectar <provedor>` (ou o Ollama local: `ts conectar ollama`) ou sua conta com `ts ia usar claude|chatgpt` (Claude Code ou Codex logado neste PC).');
|
|
75
|
+
e.code = 'sem_ia_local';
|
|
76
|
+
return e;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Histórico + mensagem nova num prompt só (as CLIs rodam um turno por execução).
|
|
80
|
+
function montarPrompt(historico, conteudo, lang) {
|
|
81
|
+
const en = lang === 'en';
|
|
82
|
+
const linhas = historico.map(m => `${m.role === 'user' ? (en ? 'USER' : 'USUÁRIO') : (en ? 'ASSISTANT' : 'ASSISTENTE')}:\n${m.content}`);
|
|
83
|
+
return [
|
|
84
|
+
linhas.length ? (en ? 'CONVERSATION SO FAR (oldest first):\n\n' : 'CONVERSA ATÉ AQUI (mais antiga primeiro):\n\n') + linhas.join('\n\n') : '',
|
|
85
|
+
(en ? 'NEW USER MESSAGE:\n' : 'NOVA MENSAGEM DO USUÁRIO:\n') + conteudo,
|
|
86
|
+
].filter(Boolean).join('\n\n---\n\n');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function viaConta(modo, prompt, lang, timeoutMs) {
|
|
90
|
+
const st = await iaLocal.status();
|
|
91
|
+
const ordem = modo === 'paralelo' ? ['chatgpt', 'claude'] : [modo];
|
|
92
|
+
const prontas = ordem.filter(p => iaLocal.pronto(p, st));
|
|
93
|
+
if (!prontas.length) {
|
|
94
|
+
const e = new Error(`${lang === 'en' ? 'mode' : 'modo'} ${modo}: ${iaLocal.faltando(modo === 'paralelo' ? 'paralelo' : modo, st)}. ` + (lang === 'en'
|
|
95
|
+
? `Log in with \`ts ia conectar ${modo === 'paralelo' ? 'chatgpt|claude' : modo}\` or switch with \`ts ia usar automatico\` (then your key from \`ts conectar\` is used).`
|
|
96
|
+
: `Faça o login com \`ts ia conectar ${modo === 'paralelo' ? 'chatgpt|claude' : modo}\` ou troque com \`ts ia usar automatico\` (aí vale a sua chave do \`ts conectar\`).`));
|
|
97
|
+
e.code = 'ia_local_indisponivel';
|
|
98
|
+
throw e;
|
|
99
|
+
}
|
|
100
|
+
let ultimoErro = null;
|
|
101
|
+
for (const prov of prontas) {
|
|
102
|
+
try { return await iaLocal.executar(prov, prompt, { sistema: sistemaChat(lang), timeoutMs }); }
|
|
103
|
+
catch (e) { ultimoErro = e; }
|
|
104
|
+
}
|
|
105
|
+
throw ultimoErro;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function viaChave(chave, historico, conteudo, lang, timeoutMs) {
|
|
109
|
+
const modelo = chave.modelo || (providers.info(chave.id) || {}).modeloPadrao || null;
|
|
110
|
+
const r = await llm({
|
|
111
|
+
baseUrl: chave.baseUrl || providers.baseUrlDe(chave.id), key: chave.key || 'local', model: modelo,
|
|
112
|
+
messages: [{ role: 'system', content: sistemaChat(lang) }, ...historico, { role: 'user', content: conteudo }],
|
|
113
|
+
noTools: true, signalMs: timeoutMs, tries: 2,
|
|
114
|
+
});
|
|
115
|
+
const texto = String((r && r.msg && r.msg.content) || '').replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
|
|
116
|
+
if (!texto) throw new Error(lang === 'en' ? 'your provider returned an empty answer.' : 'o seu provedor devolveu uma resposta vazia.');
|
|
117
|
+
return { provedor: chave.id, texto, uso: (r && r.usage) || {}, modelo: (r && r.model) || modelo || '' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Responde e grava a troca no histórico local do escopo.
|
|
121
|
+
async function responder({ escopo, conteudo, modo = 'automatico', lang = 'pt', timeoutMs = 180000 } = {}) {
|
|
122
|
+
const pergunta = String(conteudo || '').trim();
|
|
123
|
+
if (!pergunta) throw new Error(lang === 'en' ? 'empty message.' : 'mensagem vazia.');
|
|
124
|
+
const fonte = escolherFonte(modo);
|
|
125
|
+
if (!fonte) throw semIa(lang);
|
|
126
|
+
const historico = carregar(escopo);
|
|
127
|
+
const r = fonte.tipo === 'conta'
|
|
128
|
+
? await viaConta(fonte.modo, montarPrompt(historico, pergunta, lang), lang, timeoutMs)
|
|
129
|
+
: await viaChave(fonte.chave, historico, pergunta, lang, timeoutMs);
|
|
130
|
+
salvar(escopo, [...historico,
|
|
131
|
+
{ role: 'user', content: pergunta.slice(0, LIMITE_CONTEUDO) },
|
|
132
|
+
{ role: 'assistant', content: String(r.texto).slice(0, LIMITE_CONTEUDO) }]);
|
|
133
|
+
return { texto: r.texto, fonte: fonte.tipo, provedor: r.provedor, modelo: r.modelo, uso: r.uso };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { responder, carregar, salvar, limpar, escolherFonte, montarPrompt, arquivo, dir };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = { criarChatLocal, sistemaChat, LIMITE_MENSAGENS };
|
package/lib/core.js
CHANGED
|
@@ -111,10 +111,107 @@ function winFor(model) {
|
|
|
111
111
|
// heurística chars/4 — suficiente pra decidir QUANDO compactar
|
|
112
112
|
const estMsgsTok = (msgs) => msgs.reduce((n, m) => n + Math.ceil(((typeof m.content === 'string' ? m.content : JSON.stringify(m.content || '')).length + (m.tool_calls ? JSON.stringify(m.tool_calls).length : 0)) / 4), 0);
|
|
113
113
|
|
|
114
|
+
// ── 2b) RESUMO FIXO e CORTE SEGURO do histórico ──────────────────────────────
|
|
115
|
+
// Depois de compactar, o histórico vira [system, RESUMO, ...rabo]. O resumo é a única
|
|
116
|
+
// memória da parte antiga: se um corte posterior (sessão salva, --continuar, prompt do
|
|
117
|
+
// modo pessoal) o descartar, o agente perde o objetivo e o que já fez. Ele vai marcado
|
|
118
|
+
// com _tsPinned (o llm() tira o campo antes de enviar) e com um rótulo fixo — o rótulo
|
|
119
|
+
// é o que sobrevive em sessões antigas, gravadas antes da marca existir.
|
|
120
|
+
const SUMMARY_LABEL = {
|
|
121
|
+
pt: 'RESUMO DA CONVERSA ATÉ AQUI (contexto antigo, condensado automaticamente):\n',
|
|
122
|
+
en: 'CONVERSATION SUMMARY SO FAR (older context, auto-condensed):\n',
|
|
123
|
+
};
|
|
124
|
+
const SUMMARY_RE = /^(?:RESUMO DA CONVERSA ATÉ AQUI|CONVERSATION SUMMARY SO FAR)\b[^\n]*\n?/;
|
|
125
|
+
function isPinnedSummary(m) {
|
|
126
|
+
if (!m || typeof m !== 'object') return false;
|
|
127
|
+
if (m._tsPinned === true) return true;
|
|
128
|
+
return m.role === 'user' && typeof m.content === 'string' && SUMMARY_RE.test(m.content);
|
|
129
|
+
}
|
|
130
|
+
// texto do resumo sem o rótulo (pra recompactar ou montar a seção própria do modo pessoal)
|
|
131
|
+
function pinnedSummaryText(m) {
|
|
132
|
+
if (!isPinnedSummary(m)) return '';
|
|
133
|
+
return String(m.content || '').replace(SUMMARY_RE, '').trim();
|
|
134
|
+
}
|
|
135
|
+
// A TAREFA desta execução também é fixa (_tsTask, o llm() tira o campo antes de enviar):
|
|
136
|
+
// depois de compactar, o pedido original iria só para dentro do resumo e o rabo costuma ter
|
|
137
|
+
// apenas assistant/tool — o modelo (sobretudo o pessoal, que vê 8 mensagens) perdia a missão.
|
|
138
|
+
function isPinnedTask(m) {
|
|
139
|
+
return !!(m && typeof m === 'object' && m._tsTask === true);
|
|
140
|
+
}
|
|
141
|
+
// Últimas `n` mensagens SEM perder as fixas (o último resumo e a última tarefa marcada) e
|
|
142
|
+
// SEM começar numa resposta de ferramenta órfã ('tool' sem o assistant que a pediu → 400
|
|
143
|
+
// no provedor). Mesma regra do corte da compactação. Cada fixa que fica fora da janela
|
|
144
|
+
// ocupa uma das `n` vagas e volta na ordem original, antes do rabo.
|
|
145
|
+
function safeTail(msgs, n) {
|
|
146
|
+
const list = Array.isArray(msgs) ? msgs.filter(Boolean) : [];
|
|
147
|
+
const max = Math.max(1, Math.floor(Number(n) || 1));
|
|
148
|
+
let pinS = -1, pinT = -1;
|
|
149
|
+
for (let i = list.length - 1; i >= 0 && (pinS < 0 || pinT < 0); i--) {
|
|
150
|
+
if (pinS < 0 && isPinnedSummary(list[i])) pinS = i;
|
|
151
|
+
if (pinT < 0 && isPinnedTask(list[i])) pinT = i;
|
|
152
|
+
}
|
|
153
|
+
const pins = [pinS, pinT].filter(i => i >= 0).sort((a, b) => a - b);
|
|
154
|
+
let start = Math.max(0, list.length - max);
|
|
155
|
+
for (let round = 0; round < 3; round++) { // no máximo 2 fixas → estabiliza em poucas voltas
|
|
156
|
+
const next = Math.max(0, list.length - Math.max(1, max - pins.filter(i => i < start).length));
|
|
157
|
+
if (next === start) break;
|
|
158
|
+
start = next;
|
|
159
|
+
}
|
|
160
|
+
while (start < list.length && list[start].role === 'tool') start++;
|
|
161
|
+
return [...pins.filter(i => i < start).map(i => list[i]), ...list.slice(start)];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── 2c) MEDIDOR DE CONTEXTO ───────────────────────────────────────────────────
|
|
165
|
+
// A conta é sobre o limite ÚTIL (janela menos a reserva da resposta), não sobre a janela
|
|
166
|
+
// cheia: um prompt que ocupa a janela inteira não deixa espaço pro modelo responder.
|
|
167
|
+
const CTX_OUTPUT_RESERVE = 8000;
|
|
168
|
+
function usableWindow(window) {
|
|
169
|
+
const w = Math.max(1, Number(window) || CTX_WINDOW_DEFAULT);
|
|
170
|
+
return Math.max(Math.floor(w * 0.75), w - CTX_OUTPUT_RESERVE);
|
|
171
|
+
}
|
|
172
|
+
// ctx = { used, window, usable?, measured? } → números prontos pra exibir, ou null.
|
|
173
|
+
// measured=true só quando o provedor devolveu prompt_tokens da última chamada.
|
|
174
|
+
function contextMeter(ctx) {
|
|
175
|
+
if (!ctx || typeof ctx !== 'object') return null;
|
|
176
|
+
const window = Number(ctx.window) || 0;
|
|
177
|
+
const used = Math.max(0, Math.round(Number(ctx.used) || 0));
|
|
178
|
+
if (!window || !used) return null;
|
|
179
|
+
const usable = Number(ctx.usable) > 0 ? Number(ctx.usable) : usableWindow(window);
|
|
180
|
+
const pct = Math.round(100 * used / usable);
|
|
181
|
+
return { used, window, usable, pct, measured: ctx.measured === true, level: pct >= 95 ? 'full' : pct >= 80 ? 'warn' : 'ok' };
|
|
182
|
+
}
|
|
183
|
+
function _fmtK(n) {
|
|
184
|
+
if (n >= 10000) return Math.round(n / 1000) + 'k';
|
|
185
|
+
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
|
|
186
|
+
return String(n);
|
|
187
|
+
}
|
|
188
|
+
// Ao RETOMAR uma sessão (--continuar/REPL): a partir de 95% do limite útil (contando a
|
|
189
|
+
// mensagem nova) pede decisão a quem está no terminal; sem pessoa na frente, compacta
|
|
190
|
+
// sozinho. action: 'none' | 'ask' | 'auto'. No meio da execução vale a compactação automática.
|
|
191
|
+
const CTX_DECISION_AT = 95;
|
|
192
|
+
function resumeContextDecision(ctx, { extraTok = 0, interactive = false } = {}) {
|
|
193
|
+
const m = contextMeter(ctx);
|
|
194
|
+
if (!m) return { action: 'none', pct: 0, meter: null };
|
|
195
|
+
const pct = Math.round(100 * (m.used + Math.max(0, Number(extraTok) || 0)) / m.usable);
|
|
196
|
+
if (pct < CTX_DECISION_AT) return { action: 'none', pct, meter: m };
|
|
197
|
+
return { action: interactive ? 'ask' : 'auto', pct, meter: m };
|
|
198
|
+
}
|
|
199
|
+
// "contexto 52k/120k (43%) medido" | "contexto 52k/120k (43%) ≈estimado"
|
|
200
|
+
function formatContextMeter(ctx, lang = 'pt') {
|
|
201
|
+
const m = contextMeter(ctx);
|
|
202
|
+
if (!m) return '';
|
|
203
|
+
const en = lang === 'en';
|
|
204
|
+
const seal = m.measured ? (en ? 'measured' : 'medido') : (en ? '≈estimated' : '≈estimado');
|
|
205
|
+
return `${en ? 'context' : 'contexto'} ${_fmtK(m.used)}/${_fmtK(m.usable)} (${m.pct}%) ${seal}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
114
208
|
// ── 3) ENVELOPE DE EVENTOS (contrato interno único) ───────────────────────────
|
|
115
209
|
// Formaliza os eventos que o modo headless (stream-json) já emite. Este é o CONTRATO
|
|
116
210
|
// canônico: quando App (IPC ai:event) e Web (SSE) convergirem, mapeiam pra ESTAS formas.
|
|
117
|
-
// Tipos: system(init) · tool(started|blocked|approval_denied|
|
|
211
|
+
// Tipos: system(init) · tool(started|blocked|retry|loop|auto_approved|approval_denied|
|
|
212
|
+
// approval_remote|context_auto_compact) · assistant · result. system(init) é SEMPRE o
|
|
213
|
+
// primeiro evento da execução: avisos anteriores a ele (ex.: context_auto_compact, quando a
|
|
214
|
+
// sessão retomada passou de 95% do contexto útil) esperam o init e saem logo depois.
|
|
118
215
|
const AgentEvents = {
|
|
119
216
|
systemInit: ({ model, cwd, mode }) => ({ type: 'system', subtype: 'init', model, cwd, mode }),
|
|
120
217
|
tool: ({ subtype, tool, detail, reason, command, ttl }) => {
|
|
@@ -252,37 +349,253 @@ function classifyToolResult(raw) {
|
|
|
252
349
|
// segura. Aqui a saída de ferramenta passa por uma máscara determinística ANTES de
|
|
253
350
|
// virar contexto/log. Mantém o prefixo (o agente ainda reconhece "é uma chave sk-…")
|
|
254
351
|
// mas o valor não circula. Não altera o que é ENVIADO ao comando, só o que é EXIBIDO.
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
352
|
+
// Valor de flag/variável (exibição): entre aspas escapadas de um texto JSON (\"…\"), entre
|
|
353
|
+
// aspas simples, entre aspas duplas (com escapes) ou, sem aspas, até o espaço.
|
|
354
|
+
const _SECRET_VAL = String.raw`(\\"(?:[^"\\]|\\.)*?\\"|'[^']*'|"(?:[^"\\]|\\.)*"|(?:[^\s'"\\]|\\(?!"))+)`;
|
|
355
|
+
// Variante da APROVAÇÃO REMOTA: o valor mascarado nunca contém a outra aspa, barra invertida,
|
|
356
|
+
// quebra de linha nem metacaractere do shell (; & | $ ` < > ( )). O regex não acompanha o
|
|
357
|
+
// estado de aspas do shell; se a senha tiver um desses caracteres, ela aparece para quem
|
|
358
|
+
// aprova — melhor do que esconder um pedaço que o shell executa.
|
|
359
|
+
const _SECRET_VAL_SAFE = String.raw`('[^'"\\\n;&|$\x60<>()]*'|"[^"'\\\n;&|$\x60<>()]*"|[^\s'"\\;&|$\x60<>()]+)`;
|
|
360
|
+
const _MASK = '••••REDACTED';
|
|
361
|
+
// troca só o valor e mantém as aspas: a estrutura do comando/JSON continua legível
|
|
362
|
+
function _maskVal(v) {
|
|
363
|
+
if (v.startsWith('\\"')) return '\\"' + _MASK + '\\"';
|
|
364
|
+
if (v[0] === "'" || v[0] === '"') return v[0] + _MASK + v[0];
|
|
365
|
+
return _MASK;
|
|
366
|
+
}
|
|
367
|
+
// cliente MySQL/MariaDB como COMANDO: o nome seguido de espaço, depois de espaço, ; | & ( ` ou
|
|
368
|
+
// aspas, com sudo opcional e caminho opcional (/usr/bin/mysql, "C:\…\bin\mysql.exe"). O nome
|
|
369
|
+
// dentro de caminho (/var/lib/mysql:/x) ou de imagem (mysql:8) não conta, e `-p` com
|
|
370
|
+
// mapeamento de porta (`-p3306:3306`) passa. Também não conta o nome que é VALOR de flag
|
|
371
|
+
// (`docker run --name mysql -p3306 mysql:8`, `-v /srv/mysql`, `sudo -u mysql`) ou o host de
|
|
372
|
+
// ssh/nc/telnet (`ssh mysql -p2222`): aí o `-p` é porta. Até 4 espaços entre a flag e o nome —
|
|
373
|
+
// o lookbehind roda em toda posição e não pode varrer uma sequência longa de espaços.
|
|
374
|
+
const _NOT_CLIENT_AFTER = '--name|--hostname|-h|--network|--net|--network-alias|--link|--pod|-n|--namespace|-u|--user|-g|--group|-v|--volume|--volumes-from|--tmpfs|-w|--workdir|-l|--label|ssh|nmap|nc|ncat|telnet';
|
|
375
|
+
const _MYSQL_CLIENT = String.raw`(?<=^|[\s;|&(\x60"'])(?<!(?:^|[\s;|&(\x60"'])(?:${_NOT_CLIENT_AFTER})\s{1,4}["']?)((?:sudo\s+)?(?:[^\s;|&(\x60"'<>]*[\\\/])?(?:mysql|mariadb|mysqldump|mysqladmin|mariadb-dump|mysqlimport|mysqlcheck|mysqlshow)(?:\.exe)?["']?(?=\s)[^\n;|&]*?\s-p)(?!\s)(?![\d.]+(?::[\d.]+)+(?:\/(?:tcp|udp))?(?:\s|$))`;
|
|
376
|
+
function _secretRules(safe) {
|
|
377
|
+
const VAL = safe ? _SECRET_VAL_SAFE : _SECRET_VAL;
|
|
378
|
+
const flag = (m, p, v) => p + _maskVal(v);
|
|
379
|
+
return [
|
|
380
|
+
...(safe ? [
|
|
381
|
+
// APROVAÇÃO: só o corpo base64 (linhas reais ou \n literal de JSON) até o END; sem END,
|
|
382
|
+
// só a sequência base64 colada ao cabeçalho. Espaço ou metacaractere encerram o corpo.
|
|
383
|
+
[/(-----BEGIN ([A-Z0-9 ]*)PRIVATE KEY-----)(?:\r?\n|\\[rn])*[A-Za-z0-9+/=]+(?:(?:\r?\n|\\[rn])+[A-Za-z0-9+/=]+)*(?:\r?\n|\\[rn])*(-----END \2PRIVATE KEY-----)/g,
|
|
384
|
+
(m, b, t, e) => `${b} ${_MASK} ${e}`],
|
|
385
|
+
[/(-----BEGIN ([A-Z0-9 ]*)PRIVATE KEY-----)(?:\r?\n|\\[rn])*[A-Za-z0-9+/=]{16,}(?:(?:\r?\n|\\[rn])+[A-Za-z0-9+/=]+)*/g,
|
|
386
|
+
(m, b) => `${b} ${_MASK}`],
|
|
387
|
+
] : [
|
|
388
|
+
// bloco de chave privada (PEM/OpenSSH) inteiro — vem antes das regras de token,
|
|
389
|
+
// que mascarariam só pedaços do base64. Sem END (saída cortada): mascara até o fim.
|
|
390
|
+
[/-----BEGIN ([A-Z0-9 ]*)PRIVATE KEY-----[\s\S]*?(?:-----END \1PRIVATE KEY-----|$)/g,
|
|
391
|
+
(m, t) => `-----BEGIN ${t}PRIVATE KEY----- ${_MASK} -----END ${t}PRIVATE KEY-----`],
|
|
392
|
+
]),
|
|
393
|
+
// senha COLADA na flag do cliente MySQL/MariaDB: `mysql -uroot -pSEGREDO`. Só nesses
|
|
394
|
+
// comandos — `-p` é porta/pasta em ssh, docker, mkdir. `-p` sozinho (pede a senha) passa.
|
|
395
|
+
[new RegExp(_MYSQL_CLIENT + VAL, 'g'), flag],
|
|
396
|
+
// sshpass -p SEGREDO ssh … (com ou sem espaço; outras flags antes do -p)
|
|
397
|
+
[new RegExp(String.raw`(\bsshpass\s+(?:-[a-oq-zA-Z]\S*\s+)*-p\s*)` + VAL, 'g'), flag],
|
|
398
|
+
// --password=x / --password x (a regra genérica abaixo não pega o espaço nem senha curta)
|
|
399
|
+
[new RegExp(String.raw`(--pass(?:word|wd)?(?:=|\s+))(?!-)` + VAL, 'gi'), flag],
|
|
400
|
+
// variáveis de ambiente que SÃO a senha: PGPASSWORD=x psql …, MYSQL_PWD, SSHPASS
|
|
401
|
+
[new RegExp(String.raw`\b((?:PGPASSWORD|MYSQL_PWD|SSHPASS)\s*=\s*)` + VAL, 'g'), flag],
|
|
402
|
+
// chave "pass" em JSON/objeto (perfil SSH do ts: { "vps": { "pass": "…" } }). Uma regra por
|
|
403
|
+
// aspa de abertura: entre aspas duplas vale o escape do JSON (apóstrofo no meio não encerra);
|
|
404
|
+
// entre aspas simples não há escape, como no shell.
|
|
405
|
+
[safe ? /((?<![\w-])["']?pass["']?\s*:\s*")([^"'\\\n;&|$\x60<>()]+)(?=")/gi
|
|
406
|
+
: /((?<![\w-])["']?pass["']?\s*:\s*")((?:[^"\\\n]|\\.)+)(?=")/gi, (m, p) => p + _MASK],
|
|
407
|
+
[safe ? /((?<![\w-])["']?pass["']?\s*:\s*')([^'"\\\n;&|$\x60<>()]+)(?=')/gi
|
|
408
|
+
: /((?<![\w-])["']?pass["']?\s*:\s*')([^'\n]+)(?=')/gi, (m, p) => p + _MASK],
|
|
409
|
+
// usuário:senha do curl (-u / --user): a senha some, o usuário fica. Só no curl — em
|
|
410
|
+
// `docker run -u 1000:1000` o par é uid:gid. Entre aspas, o valor vai até a aspa.
|
|
411
|
+
[safe ? /(\bcurl(?:\.exe)?\b[^\n|;&]*?\s(?:-u|--user(?![\w-]))(?:\s*|=)(["'])[^\s:"'@]*:)([^"'\\\n;&|$\x60<>()]+)(?=\2)/gi
|
|
412
|
+
: /(\bcurl(?:\.exe)?\b[^\n|;&]*?\s(?:-u|--user(?![\w-]))(?:\s*|=)(["'])[^\s:"'@]*:)([^"'\n]+)(?=\2)/gi, (m, p) => p + _MASK],
|
|
413
|
+
[safe ? /(\bcurl(?:\.exe)?\b[^\n|;&]*?\s(?:-u|--user(?![\w-]))(?:\s*|=)(?!["'])[^\s:"'@]*:)([^\s"'\\;&|$\x60<>()]+)/gi
|
|
414
|
+
: /(\bcurl(?:\.exe)?\b[^\n|;&]*?\s(?:-u|--user(?![\w-]))(?:\s*|=)(?!["'])[^\s:"'@]*:)([^\s"']+)/gi, (m, p) => p + _MASK],
|
|
415
|
+
// campo "key" de JSON com valor de cara de chave (providers.json: "key": "<valor sem
|
|
416
|
+
// prefixo>"): 16+ caracteres, sem espaço, com letra e dígito — "key": "app.kubernetes.io/name"
|
|
417
|
+
// e chaves curtas de dicionário (PROJ-123) continuam legíveis.
|
|
418
|
+
[/((?<![\w-])["']key["']\s*:\s*["'])(?=[A-Za-z0-9._~+\/=-]*\d)(?=[A-Za-z0-9._~+\/=-]*[A-Za-z])[A-Za-z0-9._~+\/=-]{16,}(?=["'])/gi, (m, p) => p + _MASK],
|
|
419
|
+
// chaves com prefixo conhecido: sk-…, sk-cdc-…, ghp_…, xoxb-…, AKIA…, AIza…
|
|
420
|
+
[/\b((?:sk|pk|rk)-(?:[a-z0-9]+-)?)[A-Za-z0-9_-]{16,}/gi, (m, p) => p + _MASK],
|
|
421
|
+
// NVIDIA (nvapi-), Groq (gsk_), Cerebras (csk-), xAI (xai-), Perplexity (pplx-): com dígito
|
|
422
|
+
// no corpo, para não pegar nome de pacote ou de função
|
|
423
|
+
[/\b(nvapi-|gsk_|csk-|xai-|pplx-)(?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]{16,}/g, (m, p) => p + _MASK],
|
|
424
|
+
[/\b(gh[pousr]_)[A-Za-z0-9]{16,}/g, (m, p) => p + _MASK],
|
|
425
|
+
[/\b(xox[baprs]-)[A-Za-z0-9-]{10,}/gi, (m, p) => p + _MASK],
|
|
426
|
+
[/\b(AKIA)[0-9A-Z]{12,}/g, (m, p) => p + _MASK],
|
|
427
|
+
[/\b(AIza)[A-Za-z0-9_-]{20,}/g, (m, p) => p + _MASK],
|
|
428
|
+
// JWT (header.payload.signature)
|
|
429
|
+
[/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, () => 'eyJ••••REDACTED_JWT'],
|
|
430
|
+
// atribuição de env/JSON com nome sensível: API_KEY=…, "password": "…", Bearer …
|
|
431
|
+
// O mínimo era 8 e deixava passar senha CURTA — `senha=hunter2` (7) vazava inteira pro
|
|
432
|
+
// log e pro contexto do modelo. Agora 4, com uma exceção explícita para valores que são
|
|
433
|
+
// metadado e não segredo (`password: true`, `token: null`), senão o ganho viraria ruído.
|
|
434
|
+
// O esquema de autenticação (`Authorization: Bearer <token>`, Basic, Token) fica no
|
|
435
|
+
// prefixo: antes a regra mascarava a PALAVRA "Bearer" e deixava o token inteiro à mostra.
|
|
436
|
+
[safe
|
|
437
|
+
? /((?:api[_-]?key|apikey|secret|token|password|passwd|senha|authorization|auth[_-]?token|private[_-]?key)["']?\s*[:=]\s*["']?(?:(?:Bearer|Basic|Token|Digest|Bot)\s+)?)(?!(?:true|false|null|none|nil|undefined|yes|no|N\/A)\b)([^\s"',;&|)($\x60<>\\]{4,})/gi
|
|
438
|
+
: /((?:api[_-]?key|apikey|secret|token|password|passwd|senha|authorization|auth[_-]?token|private[_-]?key)["']?\s*[:=]\s*["']?(?:(?:Bearer|Basic|Token|Digest|Bot)\s+)?)(?!(?:true|false|null|none|nil|undefined|yes|no|N\/A)\b)([^\s"',;&|)]{4,})/gi,
|
|
439
|
+
(m, p) => p + _MASK],
|
|
440
|
+
[/\b(Bearer\s+)[A-Za-z0-9._~+/-]{16,}=*/g, (m, p) => p + _MASK],
|
|
441
|
+
];
|
|
442
|
+
}
|
|
443
|
+
const _SECRET_RES = _secretRules(false);
|
|
444
|
+
const _SECRET_RES_APPROVAL = _secretRules(true);
|
|
445
|
+
const _applySecretRules = (rules, s) => rules.reduce((acc, [re, fn]) => acc.replace(re, fn), s);
|
|
446
|
+
|
|
447
|
+
// Objeto: máscara FOLHA A FOLHA. O caminho antigo (stringify → regras → parse) devolvia o
|
|
448
|
+
// objeto ORIGINAL, sem máscara nenhuma, quando uma troca quebrava o JSON. Campo com nome de
|
|
449
|
+
// segredo (pass, password, token, api_key…) tem o valor inteiro mascarado; as demais strings
|
|
450
|
+
// passam pelas regras de texto.
|
|
451
|
+
const _SECRET_KEY_RE = /(?:^pass|api[_-]?key|apikey|secret|token|password|passwd|senha|authorization|auth[_-]?token|private[_-]?key)$/i;
|
|
452
|
+
const _META_VAL_RE = /^(?:true|false|null|none|nil|undefined|yes|no|N\/A)$/i;
|
|
453
|
+
// Campo chamado só "key" (providers.json guarda a chave assim): mascara quando o valor tem
|
|
454
|
+
// cara de chave — 16+ caracteres sem espaço, com letra e dígito. "key" também é nome comum de
|
|
455
|
+
// campo que não é segredo (chave de dicionário, id de issue), por isso o critério.
|
|
456
|
+
const _TOKENISH_RE = /^(?=[^\s]*\d)(?=[^\s]*[A-Za-z])[A-Za-z0-9._~+\/=-]{16,}$/;
|
|
457
|
+
function _redactDeep(v, key) {
|
|
458
|
+
const sensitive = key != null && _SECRET_KEY_RE.test(key);
|
|
459
|
+
if (typeof v === 'string') {
|
|
460
|
+
if (sensitive && (/^pass$/i.test(key) ? v.length > 0 : v.length >= 4 && !_META_VAL_RE.test(v))) return _MASK;
|
|
461
|
+
if (key != null && /^key$/i.test(key) && _TOKENISH_RE.test(v)) return _MASK;
|
|
462
|
+
return _applySecretRules(_SECRET_RES, v);
|
|
463
|
+
}
|
|
464
|
+
if (typeof v === 'number') return sensitive && String(v).length >= 4 ? _MASK : v;
|
|
465
|
+
if (Array.isArray(v)) return v.map(x => _redactDeep(x, null));
|
|
466
|
+
if (v && typeof v === 'object') {
|
|
467
|
+
const out = {};
|
|
468
|
+
for (const k of Object.keys(v)) out[k] = _redactDeep(v[k], k);
|
|
469
|
+
return out;
|
|
470
|
+
}
|
|
471
|
+
return v;
|
|
472
|
+
}
|
|
272
473
|
function redactSecrets(input) {
|
|
273
474
|
if (input == null) return input;
|
|
274
475
|
if (typeof input !== 'string') {
|
|
275
|
-
|
|
476
|
+
// cópia JSON (Date vira texto, função some), como antes; objeto circular segue intacto
|
|
477
|
+
let plain;
|
|
478
|
+
try { plain = JSON.parse(JSON.stringify(input)); } catch (_) { return input; }
|
|
479
|
+
return _redactDeep(plain, null);
|
|
480
|
+
}
|
|
481
|
+
return _applySecretRules(_SECRET_RES, input);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ── SEGREDOS CONHECIDOS: rede de segurança por VALOR ─────────────────────────
|
|
485
|
+
// As regras acima reconhecem segredo pelo FORMATO (prefixo, nome do campo). O ts conhece os
|
|
486
|
+
// próprios segredos pelo VALOR — token de sessão, chaves do providers.json, senha SSH do
|
|
487
|
+
// config.json, auth de MCP — e qualquer ocorrência deles numa saída de ferramenta (grep no
|
|
488
|
+
// home, tar | base64, hexdump, backup lido por outro caminho) é vazamento, seja qual for o
|
|
489
|
+
// formato em volta. Quem lê os arquivos é o chamador (aqui não há I/O): esta função só recebe
|
|
490
|
+
// os valores e devolve o mascarador. Cada valor (8+ caracteres) vira também as variações
|
|
491
|
+
// óbvias: escapado em JSON, codificado para URL, hexadecimal e base64 (padrão e URL) — o
|
|
492
|
+
// base64 nas três posições possíveis do valor dentro de um texto maior (o alinhamento de 3
|
|
493
|
+
// bytes muda os caracteres), tolerando a quebra de linha que `base64`/`xxd -p` inserem.
|
|
494
|
+
const SECRET_MIN_LEN = 8;
|
|
495
|
+
const _reEsc = (s) => String(s).replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&');
|
|
496
|
+
const _NL_OPT = '(?:\\r?\\n)?';
|
|
497
|
+
function _b64Cores(buf) {
|
|
498
|
+
const out = [];
|
|
499
|
+
for (let o = 0; o < 3; o++) {
|
|
500
|
+
const enc = Buffer.concat([Buffer.alloc(o), buf]).toString('base64').replace(/=+$/, '');
|
|
501
|
+
// só os caracteres que dependem APENAS do valor (as bordas misturam bits dos vizinhos)
|
|
502
|
+
const start = Math.ceil(4 * o / 3), end = Math.floor(8 * (o + buf.length) / 6);
|
|
503
|
+
if (end - start >= SECRET_MIN_LEN) out.push(enc.slice(start, end));
|
|
276
504
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
505
|
+
return out;
|
|
506
|
+
}
|
|
507
|
+
function _maskDeepWith(v, fn, depth, seen) {
|
|
508
|
+
if (typeof v === 'string') return fn(v);
|
|
509
|
+
if (!v || typeof v !== 'object' || depth > 40) return v;
|
|
510
|
+
if (Buffer.isBuffer(v) || v instanceof Date || seen.has(v)) return v;
|
|
511
|
+
seen.add(v);
|
|
512
|
+
if (Array.isArray(v)) return v.map(x => _maskDeepWith(x, fn, depth + 1, seen));
|
|
513
|
+
const proto = Object.getPrototypeOf(v);
|
|
514
|
+
if (proto !== Object.prototype && proto !== null) return v;
|
|
515
|
+
const out = {};
|
|
516
|
+
for (const k of Object.keys(v)) out[k] = _maskDeepWith(v[k], fn, depth + 1, seen);
|
|
517
|
+
return out;
|
|
518
|
+
}
|
|
519
|
+
// values → { size, text(str), deep(valor) }. size=0: nada a mascarar (devolve a entrada).
|
|
520
|
+
function buildSecretMasker(values) {
|
|
521
|
+
const seen = new Set(), parts = [];
|
|
522
|
+
const add = (lit, wrap, b64) => {
|
|
523
|
+
if (!lit || lit.length < SECRET_MIN_LEN) return;
|
|
524
|
+
const k = (wrap ? 'w' : 'x') + (b64 ? 'b' : '') + lit;
|
|
525
|
+
if (seen.has(k)) return;
|
|
526
|
+
seen.add(k);
|
|
527
|
+
const body = wrap ? lit.split('').map(_reEsc).join(_NL_OPT) : _reEsc(lit);
|
|
528
|
+
// no base64, os 1-2 caracteres de borda (bits do valor misturados aos vizinhos) também somem
|
|
529
|
+
parts.push({ lit, wrap, len: lit.length, src: b64 ? '[A-Za-z0-9+/_-]{0,2}' + body + '[A-Za-z0-9+/_-]{0,2}={0,2}' : body });
|
|
530
|
+
};
|
|
531
|
+
for (const raw of Array.isArray(values) ? values : []) {
|
|
532
|
+
if (typeof raw !== 'string') continue;
|
|
533
|
+
for (const v of new Set([raw, raw.trim()])) {
|
|
534
|
+
if (v.length < SECRET_MIN_LEN) continue;
|
|
535
|
+
add(v, false, false);
|
|
536
|
+
add(JSON.stringify(v).slice(1, -1), false, false);
|
|
537
|
+
try { add(encodeURIComponent(v), false, false); } catch (_) {}
|
|
538
|
+
const buf = Buffer.from(v, 'utf8');
|
|
539
|
+
for (const c of _b64Cores(buf)) {
|
|
540
|
+
add(c, true, true);
|
|
541
|
+
add(c.replace(/\+/g, '-').replace(/\//g, '_'), true, true);
|
|
542
|
+
}
|
|
543
|
+
const hex = buf.toString('hex');
|
|
544
|
+
add(hex, true, false);
|
|
545
|
+
add(hex.toUpperCase(), true, false);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
if (!parts.length) return { size: 0, text: (s) => s, deep: (x) => x };
|
|
549
|
+
parts.sort((a, b) => b.len - a.len);
|
|
550
|
+
// Triagem barata antes da regex (que é lenta num texto grande com dezenas de variações):
|
|
551
|
+
// busca literal de cada variação — as que toleram quebra de linha, no texto sem as quebras —
|
|
552
|
+
// e a regex é montada só com as que apareceram.
|
|
553
|
+
const reCache = new Map();
|
|
554
|
+
const text = (s) => {
|
|
555
|
+
if (typeof s !== 'string' || s.length < SECRET_MIN_LEN) return s;
|
|
556
|
+
let flat = null;
|
|
557
|
+
const hits = parts.filter(p => {
|
|
558
|
+
if (!p.wrap) return s.includes(p.lit);
|
|
559
|
+
if (flat === null) flat = /[\r\n]/.test(s) ? s.replace(/\r?\n/g, '') : s;
|
|
560
|
+
return flat.includes(p.lit);
|
|
561
|
+
});
|
|
562
|
+
if (!hits.length) return s;
|
|
563
|
+
const src = hits.map(p => p.src).join('|');
|
|
564
|
+
let re = reCache.get(src);
|
|
565
|
+
if (!re) { re = new RegExp(src, 'g'); if (reCache.size < 64) reCache.set(src, re); }
|
|
566
|
+
return s.replace(re, _MASK);
|
|
567
|
+
};
|
|
568
|
+
return { size: parts.length, text, deep: (x) => _maskDeepWith(x, text, 0, new WeakSet()) };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Texto mostrado para alguém APROVAR (e que pode sair da máquina num aviso): a máscara nunca atravessa
|
|
572
|
+
// metacaractere do shell — quem aprova precisa ver tudo o que vai rodar. Defesa extra: compara
|
|
573
|
+
// os operadores de controle e as aspas do original com os do mascarado; se algum sumiu, a
|
|
574
|
+
// máscara escondeu um pedaço que o shell pode executar → hidden=true, e o chamador NÃO pede
|
|
575
|
+
// aprovação com o comando mascarado (quem aprova não veria o que roda).
|
|
576
|
+
// opts.known: valores dos segredos do ts (ver buildSecretMasker) — mascarados antes das regras
|
|
577
|
+
// de formato; um valor com metacaractere do shell também marca hidden pela mesma comparação.
|
|
578
|
+
const _CTRL_RE = /[;&|$\x60<>()'"\n]/g;
|
|
579
|
+
function _ctrlCounts(s) {
|
|
580
|
+
const c = {};
|
|
581
|
+
for (const ch of String(s).match(_CTRL_RE) || []) c[ch] = (c[ch] || 0) + 1;
|
|
582
|
+
return c;
|
|
583
|
+
}
|
|
584
|
+
function redactForApproval(cmd, opts = {}) {
|
|
585
|
+
const original = String(cmd == null ? '' : cmd);
|
|
586
|
+
const known = opts && Array.isArray(opts.known) && opts.known.length ? buildSecretMasker(opts.known) : null;
|
|
587
|
+
const text = _applySecretRules(_SECRET_RES_APPROVAL, known && known.size ? known.text(original) : original);
|
|
588
|
+
const before = _ctrlCounts(original), after = _ctrlCounts(text);
|
|
589
|
+
return { text, hidden: Object.keys(before).some(ch => (after[ch] || 0) < before[ch]) };
|
|
280
590
|
}
|
|
281
591
|
|
|
282
592
|
module.exports = {
|
|
283
|
-
DESTRUCTIVE, isDestructive, touchesTsConfig, redactSecrets,
|
|
593
|
+
DESTRUCTIVE, isDestructive, touchesTsConfig, redactSecrets, redactForApproval,
|
|
594
|
+
SECRET_MASK: _MASK, SECRET_MIN_LEN, buildSecretMasker,
|
|
284
595
|
GLOBAL_STATE, globalStateWarning, describeDestructive,
|
|
285
596
|
MODEL_WINDOWS, CTX_WINDOW_DEFAULT, DEFAULT_EXECUTOR, COMPACT_AT, KEEP_TAIL, winFor, estMsgsTok,
|
|
597
|
+
SUMMARY_LABEL, isPinnedSummary, pinnedSummaryText, isPinnedTask, safeTail,
|
|
598
|
+
CTX_OUTPUT_RESERVE, usableWindow, contextMeter, formatContextMeter, resumeContextDecision,
|
|
286
599
|
AgentEvents, EVENT_TYPES,
|
|
287
600
|
ERROR_CLASSES, RETRYABLE_CLASSES, classifyError, classifyToolResult,
|
|
288
601
|
};
|
package/lib/doctor.js
CHANGED
|
@@ -67,9 +67,12 @@ async function run(opts = {}) {
|
|
|
67
67
|
const dir = path.join(os.homedir(), '.ts');
|
|
68
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)' });
|
|
69
69
|
|
|
70
|
-
// 2b) de onde vem a IA: chave própria (BYOK) ou
|
|
71
|
-
//
|
|
72
|
-
//
|
|
70
|
+
// 2b) de onde vem a IA: a chave própria (BYOK/Ollama) ou, sem ela, a IA da conta — a sua
|
|
71
|
+
// assinatura (ChatGPT/Claude), se estiver selecionada e conectada, ou os créditos legados do
|
|
72
|
+
// TS. O TS não vende IA: sem IA própria confirmada é aviso, com o caminho para conectar.
|
|
73
|
+
// opts.fonteIa (opcional, rede) confirma a assinatura pelo mesmo critério do `ts agente`.
|
|
74
|
+
// Provedor ativo cujo modelo NÃO chama ferramenta também é aviso: o agente vira um chat que
|
|
75
|
+
// narra o que faria, sem executar nada.
|
|
73
76
|
try {
|
|
74
77
|
const keyring = require('./keyring');
|
|
75
78
|
const a = keyring.ativo();
|
|
@@ -78,9 +81,14 @@ async function run(opts = {}) {
|
|
|
78
81
|
results.push(a.toolsOk
|
|
79
82
|
? { nome: 'IA (sua chave)', level: 'ok', detail: prov + (a.modelo ? ' · ' + a.modelo : '') }
|
|
80
83
|
: { nome: 'IA (sua chave)', level: 'warn', detail: prov + ' — o modelo não chama ferramentas',
|
|
81
|
-
dica: 'troque o modelo
|
|
84
|
+
dica: 'troque o modelo: ts conectar ' + a.id });
|
|
82
85
|
} else {
|
|
83
|
-
|
|
86
|
+
let fonte = null;
|
|
87
|
+
if (typeof opts.fonteIa === 'function') { try { fonte = await opts.fonteIa(); } catch (_) {} }
|
|
88
|
+
if (fonte && fonte.via) results.push({ nome: 'IA', level: 'ok', detail: fonte.via });
|
|
89
|
+
else results.push({ nome: 'IA', level: 'warn',
|
|
90
|
+
detail: (fonte && fonte.hint) || 'sem IA própria confirmada — sem chave, a execução usa a IA da conta (sua assinatura, se selecionada, ou os créditos legados do TS)',
|
|
91
|
+
dica: 'ts conectar <provedor> (sua chave ou Ollama) · ts ia conectar chatgpt|claude (sua assinatura)' });
|
|
84
92
|
}
|
|
85
93
|
} catch (_) { /* keyring é opcional pro doctor: nunca derruba o diagnóstico */ }
|
|
86
94
|
|