terminal-smart-cli 0.94.2 → 0.97.1
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 +9 -0
- package/bin/ts.js +501 -8
- package/lib/agent.js +142 -9
- package/lib/checkpoint.js +155 -0
- package/lib/conhecimento.js +224 -0
- package/lib/core.js +4 -1
- package/lib/device-tools.js +226 -0
- package/lib/diagnose.js +8 -4
- package/lib/doctor.js +17 -0
- package/lib/eval.js +4 -2
- package/lib/i18n.js +28 -2
- package/lib/keyring.js +235 -0
- package/lib/meta.js +11 -5
- package/lib/office-readers.js +106 -0
- package/lib/policy.js +162 -0
- package/lib/providers.js +118 -0
- package/lib/router.js +3 -2
- package/lib/skill-index.js +414 -0
- package/lib/temas.js +101 -0
- package/lib/tools.js +100 -3
- package/lib/ui.js +19 -23
- package/package.json +3 -2
package/lib/keyring.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// lib/keyring.js — de ONDE vem a credencial de IA de cada run.
|
|
2
|
+
//
|
|
3
|
+
// Antes: todo caminho do CLI chamava `/api/ai/key` direto (9 pontos). Agora todos passam
|
|
4
|
+
// por `resolve()`, que decide entre a chave PRÓPRIA do usuário (BYOK) e o gateway do TS.
|
|
5
|
+
// O formato de retorno é o MESMO da rota ({key, baseUrl}) — por isso o encaixe é transparente
|
|
6
|
+
// e, SEM provedor configurado, o caminho é idêntico ao anterior (garantido por teste).
|
|
7
|
+
//
|
|
8
|
+
// SEGREDO: as chaves vivem em ~/.ts/providers.json com chmod 600, NUNCA em config.json —
|
|
9
|
+
// aquele é lido e regravado por merge em quase todo comando, e acabaria em log/backup.
|
|
10
|
+
'use strict';
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const providers = require('./providers');
|
|
15
|
+
|
|
16
|
+
const DIR = path.join(os.homedir(), '.ts');
|
|
17
|
+
const FILE = path.join(DIR, 'providers.json');
|
|
18
|
+
|
|
19
|
+
function _load() {
|
|
20
|
+
try { const j = JSON.parse(fs.readFileSync(FILE, 'utf8')); return (j && typeof j === 'object') ? j : {}; }
|
|
21
|
+
catch (_) { return {}; }
|
|
22
|
+
}
|
|
23
|
+
function _save(obj) {
|
|
24
|
+
fs.mkdirSync(DIR, { recursive: true });
|
|
25
|
+
fs.writeFileSync(FILE, JSON.stringify(obj, null, 2));
|
|
26
|
+
try { fs.chmodSync(FILE, 0o600); } catch (_) { /* Windows: sem chmod */ }
|
|
27
|
+
return obj;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── estado ──────────────────────────────────────────────────────────────────
|
|
31
|
+
function listar() {
|
|
32
|
+
const st = _load();
|
|
33
|
+
const provs = st.provedores || {};
|
|
34
|
+
return Object.keys(provs).map(id => ({
|
|
35
|
+
id,
|
|
36
|
+
ativo: st.ativo === id,
|
|
37
|
+
modelo: provs[id].modelo || '',
|
|
38
|
+
baseUrl: provs[id].baseUrl || '',
|
|
39
|
+
toolsOk: provs[id].toolsOk === true,
|
|
40
|
+
testadoEm: provs[id].testadoEm || '',
|
|
41
|
+
chaveMascarada: mascarar(provs[id].key || ''),
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function ativo() {
|
|
46
|
+
const st = _load();
|
|
47
|
+
if (!st.ativo) return null;
|
|
48
|
+
const p = (st.provedores || {})[st.ativo];
|
|
49
|
+
if (!p) return null;
|
|
50
|
+
return Object.assign({ id: st.ativo }, p);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function salvar(id, { key = '', baseUrl = '', modelo = '', toolsOk = null, testadoEm = '' } = {}) {
|
|
54
|
+
const st = _load();
|
|
55
|
+
st.provedores = st.provedores || {};
|
|
56
|
+
const anterior = st.provedores[id] || {};
|
|
57
|
+
st.provedores[id] = {
|
|
58
|
+
key: key || anterior.key || '',
|
|
59
|
+
baseUrl: providers.baseUrlDe(id, baseUrl || anterior.baseUrl),
|
|
60
|
+
modelo: modelo || anterior.modelo || (providers.info(id) || {}).modeloPadrao || '',
|
|
61
|
+
toolsOk: toolsOk === null ? (anterior.toolsOk === true) : !!toolsOk,
|
|
62
|
+
testadoEm: testadoEm || anterior.testadoEm || '',
|
|
63
|
+
};
|
|
64
|
+
_save(st);
|
|
65
|
+
return st.provedores[id];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function usar(id) {
|
|
69
|
+
const st = _load();
|
|
70
|
+
if (id && !((st.provedores || {})[id])) throw new Error('provedor não configurado: ' + id);
|
|
71
|
+
if (id) st.ativo = id; else delete st.ativo; // sem id = volta pro TS Cloud
|
|
72
|
+
_save(st);
|
|
73
|
+
return id || null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function remover(id) {
|
|
77
|
+
const st = _load();
|
|
78
|
+
if (!st.provedores || !st.provedores[id]) return false;
|
|
79
|
+
delete st.provedores[id];
|
|
80
|
+
if (st.ativo === id) delete st.ativo;
|
|
81
|
+
_save(st);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Mostra só o suficiente pra o usuário reconhecer a chave, nunca a chave.
|
|
86
|
+
function mascarar(k) {
|
|
87
|
+
const s = String(k || '');
|
|
88
|
+
if (!s) return '(sem chave)';
|
|
89
|
+
if (s.length <= 10) return s.slice(0, 2) + '…';
|
|
90
|
+
return s.slice(0, 6) + '…' + s.slice(-4);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── resolução (o ponto que o resto do CLI usa) ──────────────────────────────
|
|
94
|
+
// Mesma forma de retorno da rota /api/ai/key + campos extras que só o BYOK usa.
|
|
95
|
+
// `api` é INJETADO pra manter este módulo testável sem rede.
|
|
96
|
+
async function resolve(token, opts = {}) {
|
|
97
|
+
const { feature = 'cli_agent', api = null, timeoutMs = 20000 } = opts;
|
|
98
|
+
const a = ativo();
|
|
99
|
+
if (a && (a.key || (providers.info(a.id) || {}).semChave)) {
|
|
100
|
+
return {
|
|
101
|
+
key: a.key || 'ollama', // servidor local ignora o header, mas o fetch exige algo
|
|
102
|
+
baseUrl: a.baseUrl,
|
|
103
|
+
modelo: a.modelo || null,
|
|
104
|
+
source: 'byok',
|
|
105
|
+
provedor: a.id,
|
|
106
|
+
toolsOk: a.toolsOk === true,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const _api = api || require('./api').api;
|
|
110
|
+
const k = await _api('/api/ai/key?feature=' + encodeURIComponent(feature), { token, timeoutMs });
|
|
111
|
+
return Object.assign({}, k, { source: 'cloud', provedor: 'ts-cloud' });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Qual modelo mandar para ESTA credencial.
|
|
115
|
+
// Vários pontos do CLI pedem um modelo específico do GATEWAY (ex: 'gemini-2.5-flash-lite'
|
|
116
|
+
// pro classificador barato). Esse id não existe no provedor do usuário e volta 404 — foi
|
|
117
|
+
// exatamente o que quebrou o primeiro teste real com a NVIDIA. Com BYOK, o modelo é o do
|
|
118
|
+
// provedor; sem BYOK, respeita o que o chamador pediu.
|
|
119
|
+
function modeloPara(k, preferidoDoGateway) {
|
|
120
|
+
if (k && k.source === 'byok') return k.modelo || null;
|
|
121
|
+
return preferidoDoGateway || null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── teste REAL da credencial ────────────────────────────────────────────────
|
|
125
|
+
// Duas perguntas, nesta ordem, porque falham por motivos diferentes:
|
|
126
|
+
// 1) a chave responde? → erro de auth/rede/modelo inexistente
|
|
127
|
+
// 2) o modelo CHAMA FERRAMENTA? → é o que decide se serve pro agente
|
|
128
|
+
// Modelo grátis que não faz tool-call vira um chat que NARRA sucesso sem executar nada —
|
|
129
|
+
// exatamente o `false_done` que o model_router já monitora. Melhor descobrir no cadastro.
|
|
130
|
+
const TOOL_TESTE = {
|
|
131
|
+
type: 'function',
|
|
132
|
+
function: {
|
|
133
|
+
name: 'somar',
|
|
134
|
+
description: 'Soma dois números inteiros e devolve o resultado.',
|
|
135
|
+
parameters: {
|
|
136
|
+
type: 'object',
|
|
137
|
+
properties: { a: { type: 'number' }, b: { type: 'number' } },
|
|
138
|
+
required: ['a', 'b'],
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// timeout generoso de propósito: provedores serverless (NVIDIA NIM à frente) sobem o
|
|
144
|
+
// modelo sob demanda, e um modelo grande FRIO passa de 2 min na primeira chamada.
|
|
145
|
+
// Medido: llama-3.3-70b > 120s frio; llama-3.1-8b 1s quente. Cortar cedo aqui faria o
|
|
146
|
+
// cadastro reprovar chave BOA — o erro mais caro que este comando pode cometer.
|
|
147
|
+
async function testar(id, { key = '', baseUrl = '', modelo = '', timeoutMs = 150000, fetchImpl = null } = {}) {
|
|
148
|
+
const p = providers.info(id);
|
|
149
|
+
if (!p) return { ok: false, erro: 'provedor desconhecido: ' + id };
|
|
150
|
+
const url = providers.baseUrlDe(id, baseUrl);
|
|
151
|
+
if (!url) return { ok: false, erro: 'baseUrl não definida' };
|
|
152
|
+
const mdl = modelo || p.modeloPadrao;
|
|
153
|
+
if (!mdl) return { ok: false, erro: 'modelo não definido' };
|
|
154
|
+
const _fetch = fetchImpl || fetch;
|
|
155
|
+
|
|
156
|
+
const chamar = async (body) => {
|
|
157
|
+
const ctrl = new AbortController();
|
|
158
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
159
|
+
try {
|
|
160
|
+
const res = await _fetch(url.replace(/\/+$/, '') + '/chat/completions', {
|
|
161
|
+
method: 'POST',
|
|
162
|
+
signal: ctrl.signal,
|
|
163
|
+
headers: {
|
|
164
|
+
'Content-Type': 'application/json',
|
|
165
|
+
'Authorization': 'Bearer ' + (key || 'local'),
|
|
166
|
+
'User-Agent': 'terminal-smart-cli',
|
|
167
|
+
},
|
|
168
|
+
body: JSON.stringify(body),
|
|
169
|
+
});
|
|
170
|
+
let j = null;
|
|
171
|
+
try { j = await res.json(); } catch (_) {}
|
|
172
|
+
return { status: res.status, json: j };
|
|
173
|
+
} finally { clearTimeout(t); }
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// 1) a chave responde?
|
|
177
|
+
let r1;
|
|
178
|
+
try {
|
|
179
|
+
r1 = await chamar({ model: mdl, max_tokens: 16, messages: [{ role: 'user', content: 'Responda apenas: ok' }] });
|
|
180
|
+
} catch (e) {
|
|
181
|
+
// Timeout aqui quase nunca é chave ruim: é modelo grande subindo do zero. Dizer
|
|
182
|
+
// "não conectou" mandaria o usuário caçar firewall em vez de trocar de modelo.
|
|
183
|
+
const msg = (e && e.message) ? e.message : String(e);
|
|
184
|
+
if (/abort/i.test(msg)) {
|
|
185
|
+
const alt = (p.sugestoes || []).filter(s => s[0] !== mdl).slice(0, 2).map(s => s[0]).join(' · ');
|
|
186
|
+
return { ok: false, timeout: true, erro: 'o modelo "' + mdl + '" não respondeu em ' + Math.round(timeoutMs / 1000) + 's. ' +
|
|
187
|
+
'Modelo grande costuma ter "cold start" (sobe sob demanda) — tente de novo, ou escolha um menor' +
|
|
188
|
+
(alt ? ': ' + alt : '') + '.' };
|
|
189
|
+
}
|
|
190
|
+
return { ok: false, erro: 'não conectou: ' + msg };
|
|
191
|
+
}
|
|
192
|
+
if (r1.status === 401 || r1.status === 403) return { ok: false, erro: 'chave recusada pelo provedor (HTTP ' + r1.status + ')' };
|
|
193
|
+
if (r1.status === 404) return { ok: false, erro: 'modelo "' + mdl + '" não existe nesse provedor (HTTP 404)' };
|
|
194
|
+
if (r1.status >= 400) {
|
|
195
|
+
const msg = (r1.json && (r1.json.error?.message || r1.json.message)) || ('HTTP ' + r1.status);
|
|
196
|
+
return { ok: false, erro: String(msg).slice(0, 200) };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 2) o modelo chama ferramenta?
|
|
200
|
+
let toolsOk = false, obsTools = '';
|
|
201
|
+
try {
|
|
202
|
+
const r2 = await chamar({
|
|
203
|
+
model: mdl,
|
|
204
|
+
max_tokens: 128,
|
|
205
|
+
tools: [TOOL_TESTE],
|
|
206
|
+
messages: [{ role: 'user', content: 'Quanto é 2 + 3? Use a ferramenta somar para calcular.' }],
|
|
207
|
+
});
|
|
208
|
+
if (r2.status >= 400) {
|
|
209
|
+
obsTools = 'o provedor recusou a requisição com ferramentas (HTTP ' + r2.status + ')';
|
|
210
|
+
} else {
|
|
211
|
+
const m = r2.json && r2.json.choices && r2.json.choices[0] && r2.json.choices[0].message;
|
|
212
|
+
const tc = m && (m.tool_calls || (m.function_call ? [m.function_call] : null));
|
|
213
|
+
toolsOk = Array.isArray(tc) && tc.length > 0;
|
|
214
|
+
if (!toolsOk) obsTools = 'respondeu em TEXTO em vez de chamar a ferramenta';
|
|
215
|
+
}
|
|
216
|
+
} catch (e) {
|
|
217
|
+
obsTools = 'falhou ao testar ferramentas: ' + (e && e.message ? e.message : String(e));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { ok: true, modelo: mdl, baseUrl: url, toolsOk, obsTools };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Recursos que EXIGEM o gateway do TS e por isso não funcionam com chave própria.
|
|
224
|
+
// Mostrado no `ts conectar` — degradação anunciada, nunca silenciosa.
|
|
225
|
+
const SO_NA_NUVEM = [
|
|
226
|
+
['visão / gate visual', 'o olho que aprova tela de jogo usa modelo de visão do gateway'],
|
|
227
|
+
['RAG e embeddings', 'a base recuperável indexa pelo gateway'],
|
|
228
|
+
['aprovação remota', 'o pedido de OK no Telegram passa pelo backend do TS'],
|
|
229
|
+
['ts run (nuvem)', 'orquestração remota roda no servidor, não na sua máquina'],
|
|
230
|
+
];
|
|
231
|
+
|
|
232
|
+
module.exports = {
|
|
233
|
+
FILE, listar, ativo, salvar, usar, remover, mascarar,
|
|
234
|
+
resolve, testar, modeloPara, SO_NA_NUVEM, _load, _save,
|
|
235
|
+
};
|
package/lib/meta.js
CHANGED
|
@@ -11,6 +11,7 @@ const path = require('path');
|
|
|
11
11
|
const cp = require('child_process');
|
|
12
12
|
const compactor = require('./compactor');
|
|
13
13
|
const { api, ApiError } = require('./api');
|
|
14
|
+
const keyring = require('./keyring');
|
|
14
15
|
// Teto de IA estourado (free/plano) no gateway → 402 (ou 429 com type cost_cap). Os helpers de
|
|
15
16
|
// IA da missão DEVEM lançar (não retornar vazio em silêncio), senão a missão desperdiça rodadas
|
|
16
17
|
// fingindo trabalhar. O run() pausa (estado salvo = resumível) e mostra CTA de upgrade.
|
|
@@ -589,7 +590,11 @@ function runApp(b, projRoot, onAlert) {
|
|
|
589
590
|
// default grok-4.5) critica o layout (nav torta, gráfico cortado, sobreposição).
|
|
590
591
|
// A crítica volta pro executor melhorar o visual. É o gate de APARÊNCIA.
|
|
591
592
|
async function _llmVision({ token, model, text, imageB64, images, maxTokens = 700 }) {
|
|
592
|
-
|
|
593
|
+
// VISÃO fica SEMPRE no gateway do TS, mesmo com BYOK: os provedores gratuitos ou não
|
|
594
|
+
// têm modelo de visão, ou usam outro formato de imagem. Cache PRÓPRIO (_keyNuvem) —
|
|
595
|
+
// compartilhar `_key` com as chamadas de texto misturaria as duas credenciais.
|
|
596
|
+
if (!_keyNuvem) _keyNuvem = await api('/api/ai/key?feature=cli_agent', { token, timeoutMs: 20000 });
|
|
597
|
+
const _key = _keyNuvem;
|
|
593
598
|
// aceita 1 imagem (imageB64) ou VÁRIAS (images[]) na MESMA chamada de visão — 1 crítica cobre N telas
|
|
594
599
|
const imgs = (images && images.length ? images : [imageB64]).filter(Boolean);
|
|
595
600
|
const content = [{ type: 'text', text }].concat(imgs.map(b64 => ({ type: 'image_url', image_url: { url: 'data:image/png;base64,' + b64 } })));
|
|
@@ -789,8 +794,9 @@ function save(st, dir) {
|
|
|
789
794
|
|
|
790
795
|
// Chamada JSON barata (flash-lite via CDC, chave sk-hub do usuário) + cobrança
|
|
791
796
|
let _key = null;
|
|
797
|
+
let _keyNuvem = null; // credencial do GATEWAY (visão) — separada da BYOK
|
|
792
798
|
async function _llmJson({ token, system, user, maxTokens = 900 }) {
|
|
793
|
-
if (!_key) _key = await
|
|
799
|
+
if (!_key) _key = await keyring.resolve(token, { feature: 'cli_agent' });
|
|
794
800
|
const ctrl = new AbortController();
|
|
795
801
|
const timer = setTimeout(() => ctrl.abort(), 60000);
|
|
796
802
|
let res;
|
|
@@ -798,7 +804,7 @@ async function _llmJson({ token, system, user, maxTokens = 900 }) {
|
|
|
798
804
|
res = await fetch(String(_key.baseUrl).replace(/\/+$/, '') + '/chat/completions', {
|
|
799
805
|
method: 'POST', signal: ctrl.signal,
|
|
800
806
|
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + _key.key },
|
|
801
|
-
body: JSON.stringify({ model: 'gemini-2.5-flash-lite', stream: false, max_completion_tokens: maxTokens,
|
|
807
|
+
body: JSON.stringify({ model: keyring.modeloPara(_key, 'gemini-2.5-flash-lite'), stream: false, max_completion_tokens: maxTokens,
|
|
802
808
|
messages: [{ role: 'system', content: system }, { role: 'user', content: user }] }),
|
|
803
809
|
});
|
|
804
810
|
} finally { clearTimeout(timer); }
|
|
@@ -814,7 +820,7 @@ async function _llmJson({ token, system, user, maxTokens = 900 }) {
|
|
|
814
820
|
|
|
815
821
|
// Chamada de TEXTO com modelo arbitrário — usada pelo PENSADOR (escalonamento).
|
|
816
822
|
async function _llmText({ token, model, system, user, maxTokens = 1200 }) {
|
|
817
|
-
if (!_key) _key = await
|
|
823
|
+
if (!_key) _key = await keyring.resolve(token, { feature: 'cli_agent' });
|
|
818
824
|
const ctrl = new AbortController();
|
|
819
825
|
const timer = setTimeout(() => ctrl.abort(), 120000);
|
|
820
826
|
let res;
|
|
@@ -822,7 +828,7 @@ async function _llmText({ token, model, system, user, maxTokens = 1200 }) {
|
|
|
822
828
|
res = await fetch(String(_key.baseUrl).replace(/\/+$/, '') + '/chat/completions', {
|
|
823
829
|
method: 'POST', signal: ctrl.signal,
|
|
824
830
|
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + _key.key },
|
|
825
|
-
body: JSON.stringify({ model, stream: false, max_completion_tokens: maxTokens, messages: [{ role: 'system', content: system }, { role: 'user', content: user }] }),
|
|
831
|
+
body: JSON.stringify({ model: keyring.modeloPara(_key, model), stream: false, max_completion_tokens: maxTokens, messages: [{ role: 'system', content: system }, { role: 'user', content: user }] }),
|
|
826
832
|
});
|
|
827
833
|
} finally { clearTimeout(timer); }
|
|
828
834
|
const j = await res.json().catch(() => null);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const JSZip = require('jszip');
|
|
6
|
+
|
|
7
|
+
function _entities(text) {
|
|
8
|
+
return String(text || '')
|
|
9
|
+
.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&')
|
|
10
|
+
.replace(/"/g, '"').replace(/'/g, "'");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function _xmlText(xml, tag) {
|
|
14
|
+
const re = new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, 'g');
|
|
15
|
+
const parts = [];
|
|
16
|
+
let match;
|
|
17
|
+
while ((match = re.exec(String(xml || '')))) parts.push(_entities(match[1]).replace(/<[^>]+>/g, ''));
|
|
18
|
+
return parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function _load(file, extension, maxMb = 80) {
|
|
22
|
+
const p = path.resolve(String(file || ''));
|
|
23
|
+
if (!p.toLowerCase().endsWith(extension)) throw new Error(`O arquivo precisa terminar em ${extension}.`);
|
|
24
|
+
if (!fs.existsSync(p) || !fs.statSync(p).isFile()) throw new Error('Arquivo não encontrado: ' + p);
|
|
25
|
+
const size = fs.statSync(p).size;
|
|
26
|
+
if (size > maxMb * 1048576) throw new Error(`Arquivo muito grande (${Math.round(size / 1048576)} MB, máximo ${maxMb} MB).`);
|
|
27
|
+
return { p, size, zip: await JSZip.loadAsync(fs.readFileSync(p)) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function _window(text, input) {
|
|
31
|
+
const total = text.length;
|
|
32
|
+
if (input.buscar) {
|
|
33
|
+
const q = String(input.buscar).trim().toLocaleLowerCase();
|
|
34
|
+
const lower = text.toLocaleLowerCase();
|
|
35
|
+
const hits = [];
|
|
36
|
+
let at = 0;
|
|
37
|
+
while (q && hits.length < 20 && (at = lower.indexOf(q, at)) >= 0) {
|
|
38
|
+
hits.push({
|
|
39
|
+
posicao_char: at,
|
|
40
|
+
trecho: text.slice(Math.max(0, at - 180), Math.min(total, at + q.length + 420)).trim(),
|
|
41
|
+
});
|
|
42
|
+
at += Math.max(1, q.length);
|
|
43
|
+
}
|
|
44
|
+
return { busca: input.buscar, encontrados: hits.length, trechos: hits };
|
|
45
|
+
}
|
|
46
|
+
const start = Math.max(0, Number(input.inicio_char) || 0);
|
|
47
|
+
const length = Math.min(30000, Math.max(500, Number(input.tamanho) || 12000));
|
|
48
|
+
return {
|
|
49
|
+
janela: `${start}–${Math.min(total, start + length)}`,
|
|
50
|
+
texto: text.slice(start, start + length),
|
|
51
|
+
proximo_inicio: start + length < total ? start + length : null,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function lerDocumento(input = {}) {
|
|
56
|
+
try {
|
|
57
|
+
const { p, size, zip } = await _load(input.caminho, '.docx');
|
|
58
|
+
const doc = zip.file('word/document.xml');
|
|
59
|
+
if (!doc) return { ok: false, erro: 'DOCX inválido: word/document.xml ausente.' };
|
|
60
|
+
const xml = await doc.async('string');
|
|
61
|
+
const paragraphs = (xml.match(/<w:p(?:\s|>)/g) || []).length;
|
|
62
|
+
const tables = (xml.match(/<w:tbl(?:\s|>)/g) || []).length;
|
|
63
|
+
const text = _xmlText(xml, 'w:t');
|
|
64
|
+
return {
|
|
65
|
+
ok: true,
|
|
66
|
+
arquivo: p,
|
|
67
|
+
tamanho_bytes: size,
|
|
68
|
+
paragrafos: paragraphs,
|
|
69
|
+
tabelas: tables,
|
|
70
|
+
imagens: Object.keys(zip.files).filter(f => /^word\/media\//.test(f)).length,
|
|
71
|
+
total_chars: text.length,
|
|
72
|
+
..._window(text, input),
|
|
73
|
+
};
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return { ok: false, erro: 'Falha ao ler DOCX: ' + error.message };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function lerApresentacao(input = {}) {
|
|
80
|
+
try {
|
|
81
|
+
const { p, size, zip } = await _load(input.caminho, '.pptx');
|
|
82
|
+
const names = Object.keys(zip.files)
|
|
83
|
+
.filter(f => /^ppt\/slides\/slide\d+\.xml$/.test(f))
|
|
84
|
+
.sort((a, b) => Number(a.match(/\d+/)?.[0]) - Number(b.match(/\d+/)?.[0]));
|
|
85
|
+
const start = Math.max(1, Number(input.inicio_slide) || 1);
|
|
86
|
+
const limit = Math.min(50, Math.max(1, Number(input.slides) || 20));
|
|
87
|
+
const slides = [];
|
|
88
|
+
for (let i = start - 1; i < Math.min(names.length, start - 1 + limit); i++) {
|
|
89
|
+
const xml = await zip.file(names[i]).async('string');
|
|
90
|
+
slides.push({ numero: i + 1, texto: _xmlText(xml, 'a:t').slice(0, 12000) });
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
ok: true,
|
|
94
|
+
arquivo: p,
|
|
95
|
+
tamanho_bytes: size,
|
|
96
|
+
total_slides: names.length,
|
|
97
|
+
imagens: Object.keys(zip.files).filter(f => /^ppt\/media\//.test(f)).length,
|
|
98
|
+
slides,
|
|
99
|
+
proximo_slide: start - 1 + limit < names.length ? start + limit : null,
|
|
100
|
+
};
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return { ok: false, erro: 'Falha ao ler PPTX: ' + error.message };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = { lerDocumento, lerApresentacao };
|
package/lib/policy.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// lib/policy.js — POLÍTICA DECLARATIVA de permissões do agente.
|
|
2
|
+
//
|
|
3
|
+
// Os gates do TS são fortes, mas fixos: `isDestructive` decide igual em todo projeto. Isso
|
|
4
|
+
// não cobre "neste repo `git push` pergunta; em staging pode; em produção nunca" — e o
|
|
5
|
+
// `--yolo` é tudo-ou-nada. A política resolve isso em arquivo, versionável junto do projeto.
|
|
6
|
+
//
|
|
7
|
+
// ⚠️ A REGRA DE SEGURANÇA CENTRAL DESTE MÓDULO:
|
|
8
|
+
// a política do PROJETO só pode RESTRINGIR, nunca AMPLIAR. O conteúdo do repositório é
|
|
9
|
+
// dado NÃO-CONFIÁVEL — é o mesmo motivo pelo qual AGENTS.md é lido como "dado, não comando".
|
|
10
|
+
// Se um `.ts-politica.json` pudesse liberar, um repo hostil escreveria `shell: allow` e
|
|
11
|
+
// desarmaria o produto inteiro só por ter sido clonado. Por isso `combinar()` sempre fica
|
|
12
|
+
// com o veredito MAIS restritivo entre global e projeto.
|
|
13
|
+
//
|
|
14
|
+
// E o que a política NUNCA alcança (garantido no agent.js, não aqui): a recusa instantânea
|
|
15
|
+
// de auto-destrutivos (`selfDestructiveReason`) e a proteção de `~/.ts` (`touchesTsConfig`).
|
|
16
|
+
// Um `allow` não ressuscita nenhum dos dois.
|
|
17
|
+
'use strict';
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
|
|
22
|
+
const ARQ_PROJETO = '.ts-politica.json';
|
|
23
|
+
const ARQ_GLOBAL = path.join(os.homedir(), '.ts', 'policy.json');
|
|
24
|
+
|
|
25
|
+
const VEREDITOS = ['allow', 'ask', 'deny'];
|
|
26
|
+
const NIVEL = { allow: 0, ask: 1, deny: 2 };
|
|
27
|
+
|
|
28
|
+
// Cada ferramenta do agente cai numa categoria. O que não está mapeado é tratado como
|
|
29
|
+
// 'outro' — e 'outro' sem regra explícita NÃO é decidido pela política (cai no gate normal).
|
|
30
|
+
const CATEGORIA = {
|
|
31
|
+
ler_arquivo: 'ler', listar_diretorio: 'ler', buscar_arquivos: 'ler', buscar_codigo: 'ler',
|
|
32
|
+
mapa_projeto: 'ler', info_sistema: 'ler', explorar: 'ler', ler_documento: 'ler', ler_apresentacao: 'ler',
|
|
33
|
+
escrever_arquivo: 'editar', editar_arquivo: 'editar', restaurar_arquivo: 'editar',
|
|
34
|
+
executar_comando: 'shell',
|
|
35
|
+
executar_remoto: 'ssh', conectar_vps: 'ssh',
|
|
36
|
+
buscar_web: 'rede', navegador: 'rede',
|
|
37
|
+
android_dispositivos: 'dispositivo', android_parear: 'dispositivo', android_conectar: 'dispositivo',
|
|
38
|
+
android_instalar: 'dispositivo', android_iniciar: 'dispositivo', android_logs: 'dispositivo',
|
|
39
|
+
android_capturar_tela: 'dispositivo',
|
|
40
|
+
lembrar: 'memoria', skill_gerenciar: 'memoria',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function categoriaDe(tool) {
|
|
44
|
+
const t = String(tool || '');
|
|
45
|
+
if (CATEGORIA[t]) return CATEGORIA[t];
|
|
46
|
+
if (t.startsWith('mcp_')) return 'mcp';
|
|
47
|
+
return 'outro';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// O "valor" que a regra casa: o comando no shell/ssh, o caminho no editar, a URL na rede.
|
|
51
|
+
function valorDe(tool, input) {
|
|
52
|
+
const i = input || {};
|
|
53
|
+
const cat = categoriaDe(tool);
|
|
54
|
+
if (cat === 'shell' || cat === 'ssh') return String(i.comando || i.host || '');
|
|
55
|
+
if (cat === 'editar' || cat === 'ler') return String(i.caminho || i.arquivo || i.padrao || '');
|
|
56
|
+
if (cat === 'rede') return String(i.url || i.consulta || i.query || '');
|
|
57
|
+
if (cat === 'dispositivo') return String(i.pacote || i.serial || i.host || '');
|
|
58
|
+
return String(i.comando || i.caminho || '');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── glob simples (sem dependência) ───────────────────────────────────────────
|
|
62
|
+
// `git push*` casa "git push origin main". `*producao*` casa no meio. Ancorado por padrão,
|
|
63
|
+
// que é o comportamento intuitivo pra comando ("rm *" não deve casar "npm run rm-cache").
|
|
64
|
+
function casaGlob(padrao, valor) {
|
|
65
|
+
const p = String(padrao || '');
|
|
66
|
+
if (p === '*') return true;
|
|
67
|
+
const re = new RegExp('^' + p.split('*').map(s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$', 'i');
|
|
68
|
+
return re.test(String(valor || '').trim());
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── leitura ──────────────────────────────────────────────────────────────────
|
|
72
|
+
function _lerJson(caminho, { ler = null } = {}) {
|
|
73
|
+
try {
|
|
74
|
+
const txt = (ler || ((p) => fs.readFileSync(p, 'utf8')))(caminho);
|
|
75
|
+
const j = JSON.parse(txt);
|
|
76
|
+
return (j && typeof j === 'object' && !Array.isArray(j)) ? j : null;
|
|
77
|
+
} catch (_) { return null; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function carregar({ dir = process.cwd(), lerGlobal = null, lerProjeto = null } = {}) {
|
|
81
|
+
const global = _lerJson(ARQ_GLOBAL, { ler: lerGlobal });
|
|
82
|
+
const projeto = _lerJson(path.join(dir, ARQ_PROJETO), { ler: lerProjeto });
|
|
83
|
+
return { global, projeto, temPolitica: !!(global || projeto) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── decisão ──────────────────────────────────────────────────────────────────
|
|
87
|
+
// Uma categoria pode ser string ('ask') ou objeto de globs ({'git push*':'ask','*':'allow'}).
|
|
88
|
+
// Devolve {veredito, regra} — `regra` serve pro `ts politica testar` explicar de ONDE veio.
|
|
89
|
+
function _decidirEm(politica, categoria, valor) {
|
|
90
|
+
if (!politica) return null;
|
|
91
|
+
const bruto = politica[categoria];
|
|
92
|
+
if (bruto === undefined || bruto === null) return null;
|
|
93
|
+
if (typeof bruto === 'string') {
|
|
94
|
+
return VEREDITOS.includes(bruto) ? { veredito: bruto, regra: categoria } : null;
|
|
95
|
+
}
|
|
96
|
+
if (typeof bruto !== 'object') return null;
|
|
97
|
+
// Regra mais ESPECÍFICA primeiro: '*' é sempre o último recurso; entre os demais,
|
|
98
|
+
// o padrão mais longo vence (mais específico).
|
|
99
|
+
const chaves = Object.keys(bruto).filter(k => VEREDITOS.includes(bruto[k]));
|
|
100
|
+
const especificas = chaves.filter(k => k !== '*').sort((a, b) => b.length - a.length);
|
|
101
|
+
for (const k of especificas) {
|
|
102
|
+
if (casaGlob(k, valor)) return { veredito: bruto[k], regra: categoria + ' → "' + k + '"' };
|
|
103
|
+
}
|
|
104
|
+
// o curinga também passa pela validação: um typo ('liberado') não pode virar decisão
|
|
105
|
+
if (VEREDITOS.includes(bruto['*'])) return { veredito: bruto['*'], regra: categoria + ' → "*"' };
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function maisRestritivo(a, b) {
|
|
110
|
+
if (!a) return b; if (!b) return a;
|
|
111
|
+
return NIVEL[a.veredito] >= NIVEL[b.veredito] ? a : b;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// A decisão final. `null` = a política não opina (o fluxo normal do agente decide).
|
|
115
|
+
function decidir({ global, projeto }, tool, input, { valor = null } = {}) {
|
|
116
|
+
const cat = categoriaDe(tool);
|
|
117
|
+
const val = valor !== null ? valor : valorDe(tool, input);
|
|
118
|
+
const g = _decidirEm(global, cat, val);
|
|
119
|
+
const p = _decidirEm(projeto, cat, val);
|
|
120
|
+
if (!g && !p) return null;
|
|
121
|
+
// Projeto SÓ RESTRINGE: se ele tenta afrouxar o global, o global prevalece — e dizemos
|
|
122
|
+
// isso na explicação, senão o usuário acha que a regra dele foi ignorada por bug.
|
|
123
|
+
if (g && p && NIVEL[p.veredito] < NIVEL[g.veredito]) {
|
|
124
|
+
return { veredito: g.veredito, regra: g.regra, origem: 'global',
|
|
125
|
+
nota: 'a política do projeto pediu "' + p.veredito + '" mas só pode RESTRINGIR — o global "' + g.veredito + '" prevalece' };
|
|
126
|
+
}
|
|
127
|
+
const venc = maisRestritivo(g, p);
|
|
128
|
+
return { veredito: venc.veredito, regra: venc.regra, origem: (venc === p ? 'projeto' : 'global') };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Valida um arquivo de política e devolve os problemas em linguagem humana (pro `ts politica`).
|
|
132
|
+
function validar(politica) {
|
|
133
|
+
const erros = [];
|
|
134
|
+
if (!politica || typeof politica !== 'object') return ['não é um objeto JSON'];
|
|
135
|
+
for (const [cat, val] of Object.entries(politica)) {
|
|
136
|
+
if (typeof val === 'string') {
|
|
137
|
+
if (!VEREDITOS.includes(val)) erros.push('"' + cat + '": "' + val + '" não é ' + VEREDITOS.join('/'));
|
|
138
|
+
} else if (val && typeof val === 'object' && !Array.isArray(val)) {
|
|
139
|
+
for (const [glob, v] of Object.entries(val)) {
|
|
140
|
+
if (!VEREDITOS.includes(v)) erros.push('"' + cat + '" → "' + glob + '": "' + v + '" não é ' + VEREDITOS.join('/'));
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
erros.push('"' + cat + '" precisa ser texto ou objeto de regras');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return erros;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const EXEMPLO = {
|
|
150
|
+
ler: 'allow',
|
|
151
|
+
editar: 'ask',
|
|
152
|
+
shell: { 'git status*': 'allow', 'git push*': 'ask', 'rm *': 'deny', '*': 'ask' },
|
|
153
|
+
ssh: { '*producao*': 'deny', '*': 'ask' },
|
|
154
|
+
rede: 'allow',
|
|
155
|
+
mcp: 'ask',
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
module.exports = {
|
|
159
|
+
ARQ_PROJETO, ARQ_GLOBAL, VEREDITOS, NIVEL, CATEGORIA, EXEMPLO,
|
|
160
|
+
categoriaDe, valorDe, casaGlob, carregar, decidir, validar, maisRestritivo,
|
|
161
|
+
_decidirEm,
|
|
162
|
+
};
|