terminal-smart-cli 0.97.69 → 0.98.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/bin/ts.js +151 -0
- package/lib/agent.js +4 -3
- package/lib/catalog-service.js +235 -0
- package/lib/i18n.js +4 -0
- package/lib/intelligence-core.js +155 -12
- package/lib/model-catalog-sync.js +295 -0
- package/package.json +1 -1
package/bin/ts.js
CHANGED
|
@@ -2743,6 +2743,156 @@ function trilhaCmd() {
|
|
|
2743
2743
|
}
|
|
2744
2744
|
|
|
2745
2745
|
// ── ts tema — paleta do CLI (cor é SEMÂNTICA: ok/aprovação/bloqueado) ────────
|
|
2746
|
+
// ── ts modelos — o catálogo em vigor, e o que o seu plano permite ───────────
|
|
2747
|
+
//
|
|
2748
|
+
// O catálogo do produto é curado à mão e envelhece. A camada dinâmica resolve
|
|
2749
|
+
// isso buscando o catálogo público do OpenRouter; este comando é a porta de
|
|
2750
|
+
// entrada dela na linha de comando.
|
|
2751
|
+
//
|
|
2752
|
+
// Funciona OFFLINE: o piso é o catálogo embutido, e o cache em disco (buscado
|
|
2753
|
+
// antes) entra por cima. Só `--atualizar` toca a rede.
|
|
2754
|
+
async function modelosCmd(words) {
|
|
2755
|
+
const en = (cfg.lang === 'en');
|
|
2756
|
+
const _path = require('path');
|
|
2757
|
+
const _os = require('os');
|
|
2758
|
+
const core = require('../lib/intelligence-core');
|
|
2759
|
+
const servico = require('../lib/catalog-service').createCatalogService({
|
|
2760
|
+
// Ao lado do config.json, que é onde a CLI já guarda o estado dela.
|
|
2761
|
+
cacheFile: _path.join(_path.dirname(config.FILE), 'model-catalog.json'),
|
|
2762
|
+
core,
|
|
2763
|
+
});
|
|
2764
|
+
|
|
2765
|
+
const forcar = FLAGS.has('--atualizar') || FLAGS.has('--update') || FLAGS.has('-u');
|
|
2766
|
+
const soGratis = FLAGS.has('--gratis') || FLAGS.has('--grátis') || FLAGS.has('--free');
|
|
2767
|
+
const soFerramentas = FLAGS.has('--ferramentas') || FLAGS.has('--tools');
|
|
2768
|
+
const termo = words.filter((w) => !w.startsWith('-')).join(' ').trim().toLowerCase();
|
|
2769
|
+
|
|
2770
|
+
servico.boot();
|
|
2771
|
+
let aviso = '';
|
|
2772
|
+
if (forcar) {
|
|
2773
|
+
const r = await servico.refresh({ force: true });
|
|
2774
|
+
if (!r.ok) aviso = (en ? 'could not update: ' : 'não consegui atualizar: ') + r.motivo;
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
/* Quais modelos ESTE usuário pode pedir. Vem do servidor, que é quem aplica a
|
|
2778
|
+
regra — a CLI não a reimplementa. Sem login ou sem rede, `null`: aí o
|
|
2779
|
+
comando mostra o catálogo inteiro e diz que não sabe do plano, em vez de
|
|
2780
|
+
esconder tudo ou mentir que tudo está liberado. */
|
|
2781
|
+
let permitidos = null;
|
|
2782
|
+
let plano = '';
|
|
2783
|
+
if (cfg.token) {
|
|
2784
|
+
try {
|
|
2785
|
+
const r = await api('/api/intelligence/catalog', { token: cfg.token, timeoutMs: 8000 });
|
|
2786
|
+
if (r && Array.isArray(r.allowed)) { permitidos = r.allowed; plano = String(r.plan || ''); }
|
|
2787
|
+
} catch (_) { /* offline ou sessão expirada: segue sem a informação */ }
|
|
2788
|
+
}
|
|
2789
|
+
const liberado = (id) => !permitidos || permitidos.includes('*') || permitidos.includes(id);
|
|
2790
|
+
|
|
2791
|
+
const ativo = core.activeCatalog();
|
|
2792
|
+
const lista = Object.keys(ativo)
|
|
2793
|
+
.filter((id) => id !== 'smart')
|
|
2794
|
+
.map((id) => {
|
|
2795
|
+
const m = ativo[id] || {};
|
|
2796
|
+
return {
|
|
2797
|
+
id,
|
|
2798
|
+
nome: m.name || id,
|
|
2799
|
+
provedor: m.upstreamProvider || m.provider || '',
|
|
2800
|
+
contexto: m.contextWindow || 0,
|
|
2801
|
+
entrada: (m.price && m.price.input) || 0,
|
|
2802
|
+
saida: (m.price && m.price.output) || 0,
|
|
2803
|
+
capacidades: m.capabilities || [],
|
|
2804
|
+
gratis: !!m.free,
|
|
2805
|
+
varianteGratis: m.freeVariant || null,
|
|
2806
|
+
avaliado: m.rated !== false,
|
|
2807
|
+
noPlano: liberado(id),
|
|
2808
|
+
};
|
|
2809
|
+
})
|
|
2810
|
+
.filter((m) => !soGratis || m.gratis || m.varianteGratis)
|
|
2811
|
+
.filter((m) => !soFerramentas || m.capacidades.includes('tools'))
|
|
2812
|
+
.filter((m) => !termo || m.id.toLowerCase().includes(termo)
|
|
2813
|
+
|| m.nome.toLowerCase().includes(termo) || m.provedor.toLowerCase().includes(termo))
|
|
2814
|
+
.sort((a, b) => (a.noPlano === b.noPlano)
|
|
2815
|
+
? ((a.avaliado === b.avaliado) ? a.nome.localeCompare(b.nome) : (a.avaliado ? -1 : 1))
|
|
2816
|
+
: (a.noPlano ? -1 : 1));
|
|
2817
|
+
|
|
2818
|
+
const st = servico.status();
|
|
2819
|
+
|
|
2820
|
+
if (JSON_OUT) {
|
|
2821
|
+
console.log(JSON.stringify({
|
|
2822
|
+
ok: true, fonte: st.fonte, total: st.total, curados: st.curados,
|
|
2823
|
+
atualizadoEm: st.fetchedAt || null, divergencias: st.divergencias,
|
|
2824
|
+
plano: plano || null, permitidos, modelos: lista,
|
|
2825
|
+
}));
|
|
2826
|
+
return;
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
const fmtCtx = (n) => (!n ? '—'
|
|
2830
|
+
: n >= 1000000 ? (n / 1000000).toFixed(n % 1000000 ? 1 : 0) + 'M'
|
|
2831
|
+
: n >= 1000 ? Math.round(n / 1000) + 'k' : String(n));
|
|
2832
|
+
const fmtPreco = (m) => (!m.entrada && !m.saida) ? (en ? 'free' : 'grátis')
|
|
2833
|
+
: '$' + (m.entrada >= 1 ? m.entrada.toFixed(2) : m.entrada.toFixed(3)) +
|
|
2834
|
+
'/$' + (m.saida >= 1 ? m.saida.toFixed(2) : m.saida.toFixed(3));
|
|
2835
|
+
|
|
2836
|
+
const linhas = [];
|
|
2837
|
+
if (aviso) linhas.push(C.warn('▲ ') + aviso, '');
|
|
2838
|
+
if (!lista.length) {
|
|
2839
|
+
linhas.push(C.dim(en ? 'no model matches that.' : 'nenhum modelo com esse filtro.'));
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
/* DUAS linhas por modelo, de propósito. Numa linha só, nome + contexto + preço
|
|
2843
|
+
+ marcas estourava a largura da caixa e a borda direita ia embora. A segunda
|
|
2844
|
+
linha também é onde o id aparece — que é o que se copia para `--modelo`. */
|
|
2845
|
+
const largura = Math.min(32, Math.max(14, ...lista.map((m) => m.nome.length)));
|
|
2846
|
+
for (const m of lista.slice(0, 60)) {
|
|
2847
|
+
/* A cor É o estado, como no resto da CLI: normal = pode usar, apagado = está
|
|
2848
|
+
no catálogo mas fora do seu plano. Esconder o que está fora esconderia
|
|
2849
|
+
também o motivo de assinar. */
|
|
2850
|
+
const cor = m.noPlano ? C.cyan : C.dim;
|
|
2851
|
+
linhas.push(' ' + cor(m.nome.slice(0, largura).padEnd(largura)) + ' ' +
|
|
2852
|
+
C.dim(fmtCtx(m.contexto).padStart(5)) + ' ' + C.dim(fmtPreco(m)));
|
|
2853
|
+
|
|
2854
|
+
/* `medido` marca o que o produto AVALIOU, e não o contrário. Com a camada
|
|
2855
|
+
instalada são 31 de 436: marcar os 405 restantes como "não avaliado" seria
|
|
2856
|
+
escrever a mesma coisa em quase toda linha, que é o mesmo que não escrever
|
|
2857
|
+
nada. É também o que explica por que só alguns entram no Automático. */
|
|
2858
|
+
const marcas = [
|
|
2859
|
+
m.gratis ? C.ok(en ? 'free' : 'grátis') : '',
|
|
2860
|
+
(!m.gratis && m.varianteGratis) ? C.ok(en ? 'has free' : 'tem grátis') : '',
|
|
2861
|
+
m.capacidades.includes('vision') ? (en ? 'vision' : 'visão') : '',
|
|
2862
|
+
m.capacidades.includes('tools') ? '' : (en ? 'no tools' : 'sem ferramentas'),
|
|
2863
|
+
m.avaliado ? C.indigo(en ? 'measured' : 'medido') : '',
|
|
2864
|
+
m.noPlano ? '' : C.warn(en ? 'not in your plan' : 'fora do seu plano'),
|
|
2865
|
+
].filter(Boolean);
|
|
2866
|
+
linhas.push(' ' + C.dim(' '.repeat(largura) + ' ' + m.id) +
|
|
2867
|
+
(marcas.length ? C.dim(' · ') + marcas.join(C.dim(' · ')) : ''));
|
|
2868
|
+
}
|
|
2869
|
+
if (lista.length > 60) {
|
|
2870
|
+
linhas.push('', C.dim((en ? `+${lista.length - 60} more — narrow it down: ` : `+${lista.length - 60} — refine: `) + 'ts modelos <termo>'));
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
linhas.push('');
|
|
2874
|
+
linhas.push(C.dim((en ? 'source: ' : 'fonte: ') +
|
|
2875
|
+
(st.fonte === 'openrouter' ? 'OpenRouter' : (en ? 'built-in' : 'embutido')) +
|
|
2876
|
+
' · ' + st.total + (en ? ' models' : ' modelos') +
|
|
2877
|
+
(st.divergencias ? ' · ' + st.divergencias + (en ? ' price diffs' : ' preços divergentes') : '')));
|
|
2878
|
+
if (plano) {
|
|
2879
|
+
linhas.push(C.dim((en ? 'your plan: ' : 'seu plano: ') + plano +
|
|
2880
|
+
(permitidos && permitidos.includes('*')
|
|
2881
|
+
? (en ? ' (all models)' : ' (todos os modelos)')
|
|
2882
|
+
: (permitidos ? ` (${permitidos.length - 1}${en ? ' models' : ' modelos'})` : ''))));
|
|
2883
|
+
} else if (!permitidos) {
|
|
2884
|
+
linhas.push(C.dim(en
|
|
2885
|
+
? 'plan unknown (offline or not signed in): showing the whole catalog.'
|
|
2886
|
+
: 'plano desconhecido (offline ou sem login): mostrando o catálogo inteiro.'));
|
|
2887
|
+
}
|
|
2888
|
+
if (st.vencido && !forcar) {
|
|
2889
|
+
linhas.push(C.dim((en ? 'catalog may be stale — ' : 'catálogo pode estar velho — ') + 'ts modelos --atualizar'));
|
|
2890
|
+
}
|
|
2891
|
+
linhas.push(C.dim((en ? 'use one: ' : 'usar um: ') + C.cyan('ts --modelo <id> "sua pergunta"')));
|
|
2892
|
+
|
|
2893
|
+
console.log('\n' + ui.box(linhas, { title: 'ts modelos' }));
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2746
2896
|
function temaCmd(words) {
|
|
2747
2897
|
const en = (cfg.lang === 'en');
|
|
2748
2898
|
const temas = require('../lib/temas');
|
|
@@ -3481,6 +3631,7 @@ function recallCmd(args) {
|
|
|
3481
3631
|
case 'hooks': case 'ganchos': return hooksCmd(POS.slice(1));
|
|
3482
3632
|
case 'worktrees': case 'worktree': case 'wt': return worktreesCmd(POS.slice(1));
|
|
3483
3633
|
case 'eval': case 'avaliar': case 'evals': return evalCmd(POS.slice(1));
|
|
3634
|
+
case 'modelos': case 'modelo': case 'models': case 'model': return modelosCmd(POS.slice(1));
|
|
3484
3635
|
case 'tema': case 'theme': return temaCmd(POS.slice(1));
|
|
3485
3636
|
case 'politica': case 'política': case 'policy': return politicaCmd(POS.slice(1));
|
|
3486
3637
|
case 'checkpoints': case 'checkpoint': return checkpointsCmd(POS.slice(1));
|
package/lib/agent.js
CHANGED
|
@@ -965,7 +965,7 @@ async function _subAgent({ task, k, model, cwd, lang, onStep }) {
|
|
|
965
965
|
let inp = {}; try { inp = JSON.parse((tc.function && tc.function.arguments) || '{}'); } catch (_) {}
|
|
966
966
|
if (onStep) onStep({ name: ' ↳ ' + nm, detail: argsShort(nm, inp) });
|
|
967
967
|
const res = READONLY.has(nm) ? await tools.execute(nm, inp, { baseDir: cwd, token: k }) : { erro: 'ferramenta não permitida no sub-agente (só leitura).' };
|
|
968
|
-
msgs.push({ role: 'tool', tool_call_id: tc.id, content:
|
|
968
|
+
msgs.push({ role: 'tool', tool_call_id: tc.id, content: intelligence.compactToolResult(res, TOOL_RESULT_CAP) });
|
|
969
969
|
}
|
|
970
970
|
}
|
|
971
971
|
// Esgotou os passos ainda chamando ferramentas? Fecha com UM resumo (sem ferramentas) —
|
|
@@ -2368,7 +2368,7 @@ async function run(task, opts = {}) {
|
|
|
2368
2368
|
const _cacheClass = core.classifyToolResult(result);
|
|
2369
2369
|
if (_cacheClass.ok && READONLY.has(name)) {
|
|
2370
2370
|
const _resultCap = name === 'ler_arquivos' ? 24000 : TOOL_RESULT_CAP;
|
|
2371
|
-
_missionCache.remember(name, input, { toolCallId: tc.id, content:
|
|
2371
|
+
_missionCache.remember(name, input, { toolCallId: tc.id, content: intelligence.compactToolResult(result, _resultCap) });
|
|
2372
2372
|
} else if (_cacheClass.ok && !READONLY.has(name)) {
|
|
2373
2373
|
_missionCache.invalidate();
|
|
2374
2374
|
}
|
|
@@ -2395,7 +2395,8 @@ async function run(task, opts = {}) {
|
|
|
2395
2395
|
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 }, orchestration: { plan: _planDecision, roles: _roleTrace, inspections: _inspectionTrace } };
|
|
2396
2396
|
}
|
|
2397
2397
|
const _resultCap = name === 'ler_arquivos' ? 24000 : TOOL_RESULT_CAP;
|
|
2398
|
-
|
|
2398
|
+
// Era .slice() no JSON: cortava o FIM (onde está o erro) e entregava JSON quebrado.
|
|
2399
|
+
const _rawToolContent = _cachedContent || intelligence.compactToolResult(result, _resultCap);
|
|
2399
2400
|
messages.push({ role: 'tool', tool_call_id: tc.id, content: isUntrustedToolOutput(name)
|
|
2400
2401
|
? untrustedToolEnvelope(name, _rawToolContent, lang)
|
|
2401
2402
|
: _rawToolContent });
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/* ═══ Serviço do catálogo — onde a camada encosta no mundo ═══════════════════
|
|
4
|
+
|
|
5
|
+
Divisão de responsabilidades, de propósito:
|
|
6
|
+
|
|
7
|
+
model-catalog-sync.js PURO. Sabe o formato do OpenRouter e a política de
|
|
8
|
+
validade do cache. Não lê disco, não abre socket.
|
|
9
|
+
intelligence-core.js Guarda o piso congelado e aceita uma camada.
|
|
10
|
+
este arquivo Tem os EFEITOS: rede e disco. E só ele.
|
|
11
|
+
|
|
12
|
+
Rede e disco entram por injeção (`fetchJson`, `fs`, `now`), então o teste
|
|
13
|
+
exercita todos os caminhos — inclusive falha de rede, JSON corrompido e disco
|
|
14
|
+
somente-leitura — sem tocar em nenhum dos dois.
|
|
15
|
+
|
|
16
|
+
REGRA DESTE ARQUIVO: nada aqui lança. O catálogo é uma MELHORIA sobre um piso
|
|
17
|
+
que já funciona; se a rede cair, o disco encher ou o upstream mudar de
|
|
18
|
+
formato, o app tem que continuar com o catálogo congelado. Toda função
|
|
19
|
+
devolve um objeto com `ok` e, quando falha, `motivo` legível.
|
|
20
|
+
|
|
21
|
+
Byte-idêntico nas três superfícies: os dois módulos de que ele depende são
|
|
22
|
+
irmãos dele em cli/lib, app-electron/ e backend/services. */
|
|
23
|
+
|
|
24
|
+
const sync = require('./model-catalog-sync');
|
|
25
|
+
|
|
26
|
+
/* Teto do corpo baixado. O catálogo real mede 0,70 MB para 445 modelos — 8 MB
|
|
27
|
+
comporta cerca de cinco mil. O teto existe para que uma resposta errada (um
|
|
28
|
+
portal de captura, um proxy devolvendo HTML infinito) não vire memória sem
|
|
29
|
+
limite: o pedido é abortado ao cruzar a marca. */
|
|
30
|
+
const MAX_BODY_BYTES = 8 * 1024 * 1024;
|
|
31
|
+
const FETCH_TIMEOUT_MS = 15000;
|
|
32
|
+
|
|
33
|
+
/* Busca padrão, em Node puro. Sem dependência e sem `fetch` global de propósito:
|
|
34
|
+
este mesmo arquivo roda no processo principal do Electron, na CLI e no backend,
|
|
35
|
+
e nem todos garantem a mesma versão de runtime. */
|
|
36
|
+
function _fetchHttpsJson(url, timeoutMs) {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
let alvo;
|
|
39
|
+
try { alvo = new URL(url); } catch (_) { return resolve({ ok: false, motivo: 'URL inválida' }); }
|
|
40
|
+
const mod = alvo.protocol === 'http:' ? require('http') : require('https');
|
|
41
|
+
|
|
42
|
+
let req;
|
|
43
|
+
const falhar = (motivo) => {
|
|
44
|
+
try { req && req.destroy(); } catch (_) {}
|
|
45
|
+
resolve({ ok: false, motivo });
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
req = mod.get({
|
|
50
|
+
hostname: alvo.hostname,
|
|
51
|
+
port: alvo.port || (alvo.protocol === 'http:' ? 80 : 443),
|
|
52
|
+
path: alvo.pathname + alvo.search,
|
|
53
|
+
headers: { 'Accept': 'application/json', 'User-Agent': 'terminal-smart' },
|
|
54
|
+
timeout: timeoutMs || FETCH_TIMEOUT_MS,
|
|
55
|
+
}, (res) => {
|
|
56
|
+
/* O catálogo do OpenRouter é público: responde 200 sem chave. Qualquer
|
|
57
|
+
outra coisa é tratada como falha e não escreve nada — não há redirect
|
|
58
|
+
a seguir aqui, e seguir um às cegas seria buscar noutro lugar. */
|
|
59
|
+
if (res.statusCode !== 200) {
|
|
60
|
+
res.resume();
|
|
61
|
+
return falhar('HTTP ' + res.statusCode);
|
|
62
|
+
}
|
|
63
|
+
let bytes = 0;
|
|
64
|
+
const partes = [];
|
|
65
|
+
res.on('data', (c) => {
|
|
66
|
+
bytes += c.length;
|
|
67
|
+
if (bytes > MAX_BODY_BYTES) return falhar('resposta acima do teto de ' + MAX_BODY_BYTES + ' bytes');
|
|
68
|
+
partes.push(c);
|
|
69
|
+
});
|
|
70
|
+
res.on('end', () => {
|
|
71
|
+
if (bytes > MAX_BODY_BYTES) return;
|
|
72
|
+
try {
|
|
73
|
+
resolve({ ok: true, corpo: JSON.parse(Buffer.concat(partes).toString('utf8')) });
|
|
74
|
+
} catch (e) {
|
|
75
|
+
resolve({ ok: false, motivo: 'resposta não é JSON: ' + e.message });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
req.on('timeout', () => falhar('tempo esgotado em ' + (timeoutMs || FETCH_TIMEOUT_MS) + 'ms'));
|
|
80
|
+
req.on('error', (e) => falhar(e.message));
|
|
81
|
+
} catch (e) {
|
|
82
|
+
resolve({ ok: false, motivo: e.message });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function createCatalogService(opcoes) {
|
|
88
|
+
const o = opcoes || {};
|
|
89
|
+
const fs = o.fs || require('fs');
|
|
90
|
+
const core = o.core || require('./intelligence-core');
|
|
91
|
+
const fetchJson = o.fetchJson || _fetchHttpsJson;
|
|
92
|
+
const agora = o.now || (() => Date.now());
|
|
93
|
+
const cacheFile = o.cacheFile || '';
|
|
94
|
+
const url = o.url || sync.OPENROUTER_MODELS_URL;
|
|
95
|
+
const aviso = o.log || (() => {});
|
|
96
|
+
|
|
97
|
+
/* Quando foi a última TENTATIVA — não o último sucesso. É o que segura o piso
|
|
98
|
+
de MIN_REFRESH_MS quando o upstream está fora do ar: sem isso, uma falha
|
|
99
|
+
rápida viraria laço apertado. Fica só em memória de propósito: reiniciar o
|
|
100
|
+
app é ação do usuário e merece uma tentativa nova. */
|
|
101
|
+
let ultimaTentativa = 0;
|
|
102
|
+
let instalado = null;
|
|
103
|
+
|
|
104
|
+
function readCache() {
|
|
105
|
+
if (!cacheFile) return null;
|
|
106
|
+
try {
|
|
107
|
+
const bruto = fs.readFileSync(cacheFile, 'utf8');
|
|
108
|
+
const payload = JSON.parse(bruto);
|
|
109
|
+
return sync.isCacheValid(payload) ? payload : null;
|
|
110
|
+
} catch (_) {
|
|
111
|
+
/* Arquivo ausente no primeiro uso é o caso NORMAL, não um erro. Corrompido
|
|
112
|
+
também cai aqui e o tratamento é o mesmo: ignora e busca de novo. */
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function writeCache(payload) {
|
|
118
|
+
if (!cacheFile) return { ok: false, motivo: 'sem caminho de cache' };
|
|
119
|
+
try {
|
|
120
|
+
/* Escreve em temporário e renomeia: sem isso, um desligamento no meio da
|
|
121
|
+
escrita deixaria um JSON pela metade, que o próximo boot leria como
|
|
122
|
+
cache corrompido e jogaria fora — perdendo o catálogo por um corte de
|
|
123
|
+
energia. O rename é atômico no mesmo volume. */
|
|
124
|
+
const tmp = cacheFile + '.tmp';
|
|
125
|
+
fs.writeFileSync(tmp, JSON.stringify(payload), 'utf8');
|
|
126
|
+
try { fs.renameSync(tmp, cacheFile); }
|
|
127
|
+
catch (_) { fs.writeFileSync(cacheFile, JSON.stringify(payload), 'utf8'); }
|
|
128
|
+
return { ok: true };
|
|
129
|
+
} catch (e) {
|
|
130
|
+
return { ok: false, motivo: e.message };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* Instala a camada a partir de um payload de cache. Devolve o relatório de
|
|
135
|
+
divergência junto, porque é a única hora em que ele está calculado. */
|
|
136
|
+
function install(payload) {
|
|
137
|
+
if (!sync.isCacheValid(payload)) return { ok: false, motivo: 'cache inválido' };
|
|
138
|
+
let fusao;
|
|
139
|
+
try {
|
|
140
|
+
fusao = sync.mergeWithCurated(core.MODEL_CATALOG, payload.models);
|
|
141
|
+
} catch (e) {
|
|
142
|
+
return { ok: false, motivo: 'fusão falhou: ' + e.message };
|
|
143
|
+
}
|
|
144
|
+
const r = core.installCatalogOverlay(fusao.models);
|
|
145
|
+
if (!r.ok) return { ok: false, motivo: r.motivo };
|
|
146
|
+
|
|
147
|
+
instalado = {
|
|
148
|
+
total: r.total,
|
|
149
|
+
curados: r.curados,
|
|
150
|
+
novos: fusao.added.length,
|
|
151
|
+
precoAtualizado: fusao.priceUpdated.length,
|
|
152
|
+
divergencias: fusao.drift.length,
|
|
153
|
+
sumiramDoUpstream: fusao.upstreamMissing,
|
|
154
|
+
fetchedAt: payload.fetchedAt || '',
|
|
155
|
+
};
|
|
156
|
+
return Object.assign({ ok: true }, instalado);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/* Boot é SÍNCRONO e não toca a rede: o app precisa de um catálogo antes de
|
|
160
|
+
desenhar a primeira tela. Sem cache em disco, fica no piso congelado — que
|
|
161
|
+
já é um catálogo utilizável — e quem chama decide se dispara o refresh. */
|
|
162
|
+
function boot() {
|
|
163
|
+
const payload = readCache();
|
|
164
|
+
if (!payload) return { ok: false, motivo: 'sem cache utilizável', usandoPiso: true };
|
|
165
|
+
const r = install(payload);
|
|
166
|
+
if (!r.ok) aviso('catálogo: cache em disco recusado — ' + r.motivo);
|
|
167
|
+
return r;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function refresh(op) {
|
|
171
|
+
const forcar = !!(op && op.force);
|
|
172
|
+
const t = agora();
|
|
173
|
+
const payloadAtual = readCache();
|
|
174
|
+
|
|
175
|
+
if (!forcar && !sync.shouldRefresh(payloadAtual, t, ultimaTentativa)) {
|
|
176
|
+
return { ok: true, pulou: true, motivo: 'cache ainda válido', fetchedAt: payloadAtual && payloadAtual.fetchedAt };
|
|
177
|
+
}
|
|
178
|
+
ultimaTentativa = t;
|
|
179
|
+
|
|
180
|
+
const resp = await fetchJson(url, o.timeoutMs);
|
|
181
|
+
if (!resp || !resp.ok) {
|
|
182
|
+
const motivo = (resp && resp.motivo) || 'falha desconhecida';
|
|
183
|
+
aviso('catálogo: busca falhou — ' + motivo);
|
|
184
|
+
/* Falhou a rede: se havia cache, ele CONTINUA instalado. Não desinstala
|
|
185
|
+
nada — um catálogo de ontem é melhor que nenhum. */
|
|
186
|
+
return { ok: false, motivo, usandoCache: !!payloadAtual };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let parsed;
|
|
190
|
+
try { parsed = sync.parseOpenRouterCatalog(resp.corpo); }
|
|
191
|
+
catch (e) { return { ok: false, motivo: 'formato inesperado: ' + e.message }; }
|
|
192
|
+
|
|
193
|
+
if (!parsed || !parsed.models || !parsed.models.length) {
|
|
194
|
+
/* Zero modelos é resposta bem-formada e inútil. Se gravasse, o cache de
|
|
195
|
+
amanhã seria um catálogo vazio e o app ficaria sem modelo nenhum. */
|
|
196
|
+
return { ok: false, motivo: 'upstream devolveu catálogo vazio', usandoCache: !!payloadAtual };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const payload = sync.buildCachePayload(parsed.models, new Date(t).toISOString());
|
|
200
|
+
const r = install(payload);
|
|
201
|
+
if (!r.ok) return { ok: false, motivo: r.motivo, usandoCache: !!payloadAtual };
|
|
202
|
+
|
|
203
|
+
/* Grava só DEPOIS de instalar: um catálogo que o núcleo recusou não merece
|
|
204
|
+
ficar no disco esperando o próximo boot para ser recusado de novo. */
|
|
205
|
+
const gravou = writeCache(payload);
|
|
206
|
+
return Object.assign({}, r, {
|
|
207
|
+
gravou: gravou.ok,
|
|
208
|
+
motivoGravacao: gravou.ok ? undefined : gravou.motivo,
|
|
209
|
+
ignorados: parsed.skipped || 0,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function status() {
|
|
214
|
+
const payload = readCache();
|
|
215
|
+
const t = agora();
|
|
216
|
+
const buscadoEm = payload ? Date.parse(payload.fetchedAt || '') : NaN;
|
|
217
|
+
return {
|
|
218
|
+
instalado: !!instalado,
|
|
219
|
+
total: instalado ? instalado.total : Object.keys(core.MODEL_CATALOG).length,
|
|
220
|
+
curados: Object.keys(core.MODEL_CATALOG).length,
|
|
221
|
+
novos: instalado ? instalado.novos : 0,
|
|
222
|
+
divergencias: instalado ? instalado.divergencias : 0,
|
|
223
|
+
fetchedAt: payload ? payload.fetchedAt : '',
|
|
224
|
+
idadeMs: Number.isFinite(buscadoEm) ? t - buscadoEm : null,
|
|
225
|
+
vencido: sync.shouldRefresh(payload, t, 0),
|
|
226
|
+
fonte: instalado ? 'openrouter' : 'embutido',
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function reset() { instalado = null; core.resetCatalogOverlay(); }
|
|
231
|
+
|
|
232
|
+
return { boot, refresh, status, install, readCache, writeCache, reset };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
module.exports = { createCatalogService, MAX_BODY_BYTES, FETCH_TIMEOUT_MS, _fetchHttpsJson };
|
package/lib/i18n.js
CHANGED
|
@@ -81,6 +81,8 @@ const STR = {
|
|
|
81
81
|
['ts diagnosticos status', 'controla telemetria sanitizada de erros (ativar/desativar)'],
|
|
82
82
|
['ts doctor', 'diagnóstico do ambiente (Node, login, gateway, versão, deps opcionais)'],
|
|
83
83
|
['ts privacidade', 'mostra o endereço da Política de Privacidade'],
|
|
84
|
+
['ts modelos', 'lista os modelos disponíveis, o preço e o que o seu plano libera'],
|
|
85
|
+
['ts modelos --atualizar', 'busca o catálogo mais novo no OpenRouter'],
|
|
84
86
|
['ts tema', 'paleta do terminal (7 temas; a cor indica o ESTADO)'],
|
|
85
87
|
['ts idioma pt|en', 'idioma (padrão pt-BR)'],
|
|
86
88
|
] },
|
|
@@ -250,6 +252,8 @@ const STR = {
|
|
|
250
252
|
['ts account open', 'open Account for privacy controls and safe deletion'],
|
|
251
253
|
['ts diagnostics status', 'controls sanitized error telemetry (on/off)'],
|
|
252
254
|
['ts privacy', 'shows the Privacy Policy address'],
|
|
255
|
+
['ts modelos', 'list available models, prices and what your plan unlocks'],
|
|
256
|
+
['ts modelos --atualizar', 'fetch the latest catalog from OpenRouter'],
|
|
253
257
|
['ts tema', 'terminal palette (7 themes; color means STATE)'],
|
|
254
258
|
['ts idioma pt|en', 'language (default pt-BR)'],
|
|
255
259
|
] },
|
package/lib/intelligence-core.js
CHANGED
|
@@ -176,6 +176,41 @@ const MODEL_CATALOG = Object.freeze({
|
|
|
176
176
|
},
|
|
177
177
|
});
|
|
178
178
|
|
|
179
|
+
/* ═══ Camada dinâmica sobre o catálogo ═══
|
|
180
|
+
|
|
181
|
+
MODEL_CATALOG continua congelado e é o PISO: o que vale offline, no primeiro
|
|
182
|
+
uso e sempre que a camada não tiver sido instalada. As leituras internas
|
|
183
|
+
passam por ACTIVE_CATALOG, que começa sendo exatamente ele.
|
|
184
|
+
|
|
185
|
+
Sem overlay instalado o comportamento é idêntico ao de antes — é isso que
|
|
186
|
+
permite que os testes antigos continuem valendo sem alterar uma linha.
|
|
187
|
+
|
|
188
|
+
Quem monta a camada é model-catalog-sync.js, a partir do catálogo público do
|
|
189
|
+
OpenRouter. Aqui só existe o ponto de encaixe: este arquivo não busca nada,
|
|
190
|
+
não lê disco e não conhece o formato do provedor. */
|
|
191
|
+
let ACTIVE_CATALOG = MODEL_CATALOG;
|
|
192
|
+
|
|
193
|
+
function installCatalogOverlay(models) {
|
|
194
|
+
if (!models || typeof models !== 'object') return { ok: false, motivo: 'camada inválida' };
|
|
195
|
+
const ids = Object.keys(models);
|
|
196
|
+
if (!ids.length) return { ok: false, motivo: 'camada vazia' };
|
|
197
|
+
// Toda entrada curada precisa sobreviver. Uma camada que perdesse modelo do
|
|
198
|
+
// piso deixaria o app sem o que ele garante funcionar offline.
|
|
199
|
+
for (const id of Object.keys(ACTIVE_CATALOG)) {
|
|
200
|
+
if (!models[id]) return { ok: false, motivo: 'a camada perderia o modelo curado ' + id };
|
|
201
|
+
}
|
|
202
|
+
ACTIVE_CATALOG = models;
|
|
203
|
+
return { ok: true, total: ids.length, curados: Object.keys(MODEL_CATALOG).length };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Volta ao piso. Existe para o teste poder isolar casos sem carregar o módulo
|
|
207
|
+
// de novo, e para o app cair no embutido se a camada se mostrar ruim.
|
|
208
|
+
function resetCatalogOverlay() { ACTIVE_CATALOG = MODEL_CATALOG; }
|
|
209
|
+
|
|
210
|
+
// O catálogo em vigor, para quem precisa LISTAR (o seletor de modelos) em vez
|
|
211
|
+
// de consultar um id.
|
|
212
|
+
function activeCatalog() { return ACTIVE_CATALOG; }
|
|
213
|
+
|
|
179
214
|
const MODEL_ALIASES = Object.freeze({
|
|
180
215
|
deepseek: 'deepseek-v4.1-flash',
|
|
181
216
|
'deepseek-v4.1': 'deepseek-v4.1-flash',
|
|
@@ -270,11 +305,11 @@ function normalizeModelId(model) {
|
|
|
270
305
|
// sobrecobrado e Opus subcobrado, com risco direto de margem negativa.
|
|
271
306
|
const transportFree = raw.replace(/^openrouter:/i, '');
|
|
272
307
|
const lower = transportFree.toLowerCase();
|
|
273
|
-
if (
|
|
274
|
-
if (
|
|
275
|
-
if (
|
|
308
|
+
if (ACTIVE_CATALOG[raw]) return raw;
|
|
309
|
+
if (ACTIVE_CATALOG[transportFree]) return transportFree;
|
|
310
|
+
if (ACTIVE_CATALOG[lower]) return lower;
|
|
276
311
|
if (MODEL_ALIASES[lower]) return MODEL_ALIASES[lower];
|
|
277
|
-
const upstream = Object.entries(
|
|
312
|
+
const upstream = Object.entries(ACTIVE_CATALOG)
|
|
278
313
|
.find(([, info]) => String(info.upstreamId || '').toLowerCase() === lower);
|
|
279
314
|
return upstream ? upstream[0] : transportFree;
|
|
280
315
|
}
|
|
@@ -289,11 +324,11 @@ function supportedModelId(model) {
|
|
|
289
324
|
|
|
290
325
|
function modelInfo(model, now) {
|
|
291
326
|
const id = normalizeModelId(model);
|
|
292
|
-
const info =
|
|
327
|
+
const info = ACTIVE_CATALOG[id] || {
|
|
293
328
|
provider: 'unknown', contextWindow: 100000, price: DEFAULT_PRICE,
|
|
294
329
|
capabilities: [], toolReliability: 0.50, quality: 0.50,
|
|
295
330
|
};
|
|
296
|
-
const result = Object.assign({ id, known: !!
|
|
331
|
+
const result = Object.assign({ id, known: !!ACTIVE_CATALOG[id] }, info);
|
|
297
332
|
const at = now ? new Date(now) : new Date();
|
|
298
333
|
if (id === 'claude-sonnet-5' && at.getTime() >= Date.parse('2026-09-01T00:00:00Z')) {
|
|
299
334
|
result.price = { input: 3.00, output: 15.00, cachedInput: 0.30 };
|
|
@@ -389,6 +424,107 @@ function cleanOperationalText(value, max) {
|
|
|
389
424
|
.replace(/\s+/g, ' ').trim().slice(0, max || 300);
|
|
390
425
|
}
|
|
391
426
|
|
|
427
|
+
// ── Compactação de saída de ferramenta ──────────────────────────────────────
|
|
428
|
+
// Saída de ferramenta é a maior fatia do contexto de um agente — bem maior que o que o
|
|
429
|
+
// usuário escreve ou o que o modelo responde. E boa parte dela é ruído para o modelo:
|
|
430
|
+
// código de cor, barra de progresso, a mesma linha de log repetida mil vezes.
|
|
431
|
+
//
|
|
432
|
+
// O jeito ingênuo de conter isso é cortar com slice(). Três coisas dão errado:
|
|
433
|
+
// 1. corta o FIM, que é justamente onde fica o erro — a parte que importa;
|
|
434
|
+
// 2. em JSON, rasga a estrutura no meio de uma string e entrega sintaxe inválida;
|
|
435
|
+
// 3. mantém o ANSI e as repetições, que continuam ocupando espaço pago.
|
|
436
|
+
// Head/tail resolve as três: preserva o começo (o que foi pedido) e o fim (como acabou).
|
|
437
|
+
const TOOL_OUTPUT_MAX = 6000; // ~1.500 tokens por resultado
|
|
438
|
+
const _TOOL_FIELD_FLOOR = 240; // nenhum campo encolhe abaixo disto: sobra virava enigma
|
|
439
|
+
|
|
440
|
+
function _stripAnsi(value) {
|
|
441
|
+
return String(value == null ? '' : value)
|
|
442
|
+
.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '')
|
|
443
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, '');
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Linhas consecutivas idênticas viram uma com contador. O modelo não aprende nada na
|
|
447
|
+
// milésima repetição que não soubesse na segunda — mas paga por todas.
|
|
448
|
+
function _dedupeLines(value) {
|
|
449
|
+
const lines = String(value).split('\n');
|
|
450
|
+
const out = [];
|
|
451
|
+
for (let i = 0; i < lines.length; i++) {
|
|
452
|
+
let j = i;
|
|
453
|
+
while (j + 1 < lines.length && lines[j + 1] === lines[i] && lines[i].trim()) j++;
|
|
454
|
+
const reps = j - i + 1;
|
|
455
|
+
out.push(reps > 2 ? lines[i] + ' [repetida ' + reps + 'x]' : lines[i]);
|
|
456
|
+
if (reps === 2) out.push(lines[i]);
|
|
457
|
+
i = j;
|
|
458
|
+
}
|
|
459
|
+
return out.join('\n');
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// 60% do orçamento para o começo, o resto para o fim. O começo diz o que a ferramenta
|
|
463
|
+
// estava fazendo; o fim diz como terminou. O miolo de uma saída grande é quase sempre
|
|
464
|
+
// a parte que se repete.
|
|
465
|
+
function _headTail(value, max) {
|
|
466
|
+
const s = String(value);
|
|
467
|
+
if (s.length <= max) return s;
|
|
468
|
+
const marca = (n) => '\n… [' + n + ' chars omitidos] …\n';
|
|
469
|
+
const head = Math.floor(max * 0.6);
|
|
470
|
+
const tail = Math.max(0, max - head - marca(s.length).length);
|
|
471
|
+
return s.slice(0, head) + marca(s.length - head - tail) + s.slice(s.length - tail);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function _limparStrings(node, depth, orcamento) {
|
|
475
|
+
if (depth > 6 || orcamento.n <= 0) return node;
|
|
476
|
+
if (typeof node === 'string') { orcamento.n--; return _dedupeLines(_stripAnsi(node)); }
|
|
477
|
+
if (Array.isArray(node)) return node.map(v => _limparStrings(v, depth + 1, orcamento));
|
|
478
|
+
if (node && typeof node === 'object') {
|
|
479
|
+
const out = {};
|
|
480
|
+
for (const k of Object.keys(node)) out[k] = _limparStrings(node[k], depth + 1, orcamento);
|
|
481
|
+
return out;
|
|
482
|
+
}
|
|
483
|
+
return node;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function _maiorCampo(node, depth, achado) {
|
|
487
|
+
if (depth > 6) return achado;
|
|
488
|
+
if (Array.isArray(node)) { node.forEach((v, i) => _maiorCampo(v, depth + 1, _alvo(node, i, v, achado))); return achado; }
|
|
489
|
+
if (node && typeof node === 'object') {
|
|
490
|
+
for (const k of Object.keys(node)) { _alvo(node, k, node[k], achado); _maiorCampo(node[k], depth + 1, achado); }
|
|
491
|
+
}
|
|
492
|
+
return achado;
|
|
493
|
+
}
|
|
494
|
+
function _alvo(dono, chave, valor, achado) {
|
|
495
|
+
if (typeof valor === 'string' && valor.length > achado.len && valor.length > _TOOL_FIELD_FLOOR) {
|
|
496
|
+
achado.dono = dono; achado.chave = chave; achado.len = valor.length;
|
|
497
|
+
}
|
|
498
|
+
return achado;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Recebe o que a ferramenta devolveu (texto ou objeto) e entrega o que vai para o
|
|
502
|
+
// contexto. Objeto sai como JSON VÁLIDO: encolhe os campos de texto grandes, um de cada
|
|
503
|
+
// vez, começando pelo maior — em vez de cortar o JSON inteiro e entregar sintaxe quebrada.
|
|
504
|
+
function compactToolResult(value, max) {
|
|
505
|
+
const limite = Math.max(400, Number(max) || TOOL_OUTPUT_MAX);
|
|
506
|
+
if (value == null) return '';
|
|
507
|
+
if (typeof value !== 'object') return _headTail(_dedupeLines(_stripAnsi(value)), limite);
|
|
508
|
+
|
|
509
|
+
let obj = _limparStrings(value, 0, { n: 5000 });
|
|
510
|
+
let json = JSON.stringify(obj);
|
|
511
|
+
if (json == null) return '';
|
|
512
|
+
// Encolhe o maior campo de texto por vez. Cada volta tem que reduzir de verdade,
|
|
513
|
+
// senão isto viraria laço infinito num objeto que não dá mais para espremer.
|
|
514
|
+
for (let volta = 0; volta < 40 && json.length > limite; volta++) {
|
|
515
|
+
const alvo = _maiorCampo(obj, 0, { dono: null, chave: null, len: 0 });
|
|
516
|
+
if (!alvo.dono) break;
|
|
517
|
+
const sobra = json.length - limite;
|
|
518
|
+
const novo = Math.max(_TOOL_FIELD_FLOOR, alvo.len - sobra - 40);
|
|
519
|
+
if (novo >= alvo.len) break;
|
|
520
|
+
alvo.dono[alvo.chave] = _headTail(alvo.dono[alvo.chave], novo);
|
|
521
|
+
json = JSON.stringify(obj);
|
|
522
|
+
}
|
|
523
|
+
// Último recurso: objeto grande por QUANTIDADE de campos pequenos, não por tamanho de
|
|
524
|
+
// um campo. Aí não há o que espremer e o corte é assumido — mas ainda com o fim preservado.
|
|
525
|
+
return json.length > limite ? _headTail(json, limite) : json;
|
|
526
|
+
}
|
|
527
|
+
|
|
392
528
|
function _safeToolName(value) {
|
|
393
529
|
const name = String(value || 'unknown').replace(/[^a-z0-9_-]/gi, '_').slice(0, 80);
|
|
394
530
|
return name || 'unknown';
|
|
@@ -396,7 +532,7 @@ function _safeToolName(value) {
|
|
|
396
532
|
|
|
397
533
|
function _safeModelId(value) {
|
|
398
534
|
const normalized = normalizeModelId(value);
|
|
399
|
-
return
|
|
535
|
+
return ACTIVE_CATALOG[normalized] || normalized === 'smart' ? normalized : _safeToolName(value);
|
|
400
536
|
}
|
|
401
537
|
|
|
402
538
|
function buildOperationalSummary(input) {
|
|
@@ -527,12 +663,12 @@ function agentRoleContract(role, plan, options) {
|
|
|
527
663
|
const allowedRaw = options && Array.isArray(options.allowedModels) ? options.allowedModels : null;
|
|
528
664
|
const allowAll = !allowedRaw || allowedRaw.includes('*');
|
|
529
665
|
const allowed = allowAll ? null : new Set(allowedRaw.map(normalizeModelId));
|
|
530
|
-
const candidates = requested.map(normalizeModelId).filter(model => model === 'smart' ||
|
|
666
|
+
const candidates = requested.map(normalizeModelId).filter(model => model === 'smart' || ACTIVE_CATALOG[model])
|
|
531
667
|
.filter(model => !allowed || allowed.has(model) || model === 'smart');
|
|
532
668
|
// O fallback nunca atravessa a fronteira comercial. Se a configuração vier
|
|
533
669
|
// inconsistente, usa o primeiro modelo explicitamente permitido, ou smart.
|
|
534
670
|
if (!candidates.length && allowed) {
|
|
535
|
-
const safe = [...allowed].find(model =>
|
|
671
|
+
const safe = [...allowed].find(model => ACTIVE_CATALOG[model] || model === 'smart');
|
|
536
672
|
if (safe) candidates.push(safe);
|
|
537
673
|
}
|
|
538
674
|
if (!candidates.length) candidates.push('smart');
|
|
@@ -548,15 +684,20 @@ function routeModel(task, reliability) {
|
|
|
548
684
|
if (t.kind === 'vision') required.add('vision');
|
|
549
685
|
// Era um Set escrito a mao aqui dentro. Agora le a MESMA tabela que o CLI e
|
|
550
686
|
// o App leem, para os tres nao divergirem de novo.
|
|
551
|
-
const candidates = (t.candidates || Object.keys(
|
|
552
|
-
.filter(id =>
|
|
687
|
+
const candidates = (t.candidates || Object.keys(ACTIVE_CATALOG)).map(supportedModelId)
|
|
688
|
+
.filter(id => ACTIVE_CATALOG[id] && id !== 'smart')
|
|
689
|
+
// rated:false é o modelo que veio do provedor sem ninguém medir como ele se
|
|
690
|
+
// comporta usando ferramenta. Ele aparece no seletor manual e no cálculo de
|
|
691
|
+
// custo, mas NÃO entra no roteamento automático: o score abaixo lê quality e
|
|
692
|
+
// toolReliability, que ele não tem — daria NaN e ordenaria por acidente.
|
|
693
|
+
.filter(id => ACTIVE_CATALOG[id].rated !== false)
|
|
553
694
|
.filter((id, i, list) => list.indexOf(id) === i);
|
|
554
695
|
const rel = reliability || {};
|
|
555
696
|
const plan = String(t.plan || '').toLowerCase();
|
|
556
697
|
const isPaid = plan && plan !== 'free' && plan !== 'gratuito';
|
|
557
698
|
|
|
558
699
|
const scored = candidates.map(id => {
|
|
559
|
-
const m =
|
|
700
|
+
const m = ACTIVE_CATALOG[id];
|
|
560
701
|
const missing = [...required].filter(c => !m.capabilities.includes(c)).length;
|
|
561
702
|
const observed = rel[id] && Number.isFinite(rel[id].successRate) ? rel[id].successRate : m.toolReliability;
|
|
562
703
|
const price = m.price.input + m.price.output;
|
|
@@ -572,7 +713,9 @@ module.exports = {
|
|
|
572
713
|
INTELLIGENCE_CONTRACT, PRICE_REVISION, MODEL_CATALOG, MODEL_ALIASES, DEFAULT_PRICE,
|
|
573
714
|
MEMORY_TYPES, MEMORY_TRUST, CONTEXT_SOURCES, OFFICIAL_AUTOMATIC_TEAM, AGENT_ROLES,
|
|
574
715
|
RETIRED_MODELS, supportedModelId,
|
|
716
|
+
installCatalogOverlay, resetCatalogOverlay, activeCatalog,
|
|
575
717
|
normalizeModelId, modelInfo, legacyPriceMap, normalizeUsage, estimateCostUsd, catalogFreshness,
|
|
718
|
+
TOOL_OUTPUT_MAX, compactToolResult,
|
|
576
719
|
createContextLedger, createMemoryRecord, scoreMemoryRecord, createHandoff, cleanOperationalText, buildOperationalSummary,
|
|
577
720
|
normalizeAgentPlan, agentRoleContract, routeModel,
|
|
578
721
|
};
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/* ═══════════════════════════════════════════════════════════════════════════
|
|
4
|
+
CATÁLOGO DE MODELOS — camada dinâmica sobre o catálogo curado
|
|
5
|
+
|
|
6
|
+
O MODEL_CATALOG do intelligence-core é mantido à mão e envelhece: há uma
|
|
7
|
+
constante PRICE_REVISION e uma função catalogFreshness() que marca `stale`
|
|
8
|
+
depois de 30 dias. Ou seja, o código já sabia do problema — só avisava.
|
|
9
|
+
|
|
10
|
+
A medição que originou este módulo: dos 27 upstreamIds do catálogo que ainda
|
|
11
|
+
existem no OpenRouter, 18 estavam com preço divergente e 3 com contexto
|
|
12
|
+
divergente; e 2 ids marcados como provider 'openrouter' (qwen3.8-max,
|
|
13
|
+
ling-2.6-flash) não existem mais lá.
|
|
14
|
+
|
|
15
|
+
O DESENHO, em uma frase: o catálogo congelado continua sendo o piso — o que
|
|
16
|
+
vale offline e no primeiro uso — e o que vem do OpenRouter entra como CAMADA
|
|
17
|
+
por cima, nunca substituindo o objeto congelado.
|
|
18
|
+
|
|
19
|
+
O QUE É DERIVÁVEL, e só isso é derivado:
|
|
20
|
+
upstreamId, contextWindow, price, e as capacidades `tools` (de
|
|
21
|
+
supported_parameters), `vision` (de architecture.input_modalities) e
|
|
22
|
+
`long-context` (do próprio contextWindow).
|
|
23
|
+
|
|
24
|
+
O QUE NÃO É, e por isso continua curado: toolReliability e quality são
|
|
25
|
+
JULGAMENTOS sobre como o modelo se comporta como agente, não fatos que uma
|
|
26
|
+
API publique. Um modelo novo entra com `rated: false` — aparece no seletor
|
|
27
|
+
manual e no cálculo de custo, mas fica FORA do roteamento automático até
|
|
28
|
+
alguém curá-lo. Deixar o roteador escolher sozinho um modelo cuja
|
|
29
|
+
confiabilidade ninguém mediu seria trocar "catálogo velho" por "catálogo
|
|
30
|
+
irresponsável".
|
|
31
|
+
|
|
32
|
+
SEM REDE E SEM DISCO AQUI. Este arquivo é função pura: recebe texto JSON e
|
|
33
|
+
devolve objeto. Quem busca e quem grava são os arranjos de cada superfície,
|
|
34
|
+
que passam um `transport` e um `store`. É isso que torna o módulo testável
|
|
35
|
+
sem tocar na rede — regra do projeto.
|
|
36
|
+
|
|
37
|
+
BYTE-IDÊNTICO nos três repos (cli/lib/, app-electron/, backend/services/),
|
|
38
|
+
como o intelligence-core. Há teste de paridade.
|
|
39
|
+
═══════════════════════════════════════════════════════════════════════════ */
|
|
40
|
+
|
|
41
|
+
const CATALOG_SCHEMA = 1;
|
|
42
|
+
const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models';
|
|
43
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // um dia
|
|
44
|
+
const MIN_REFRESH_MS = 5 * 60 * 1000; // piso entre duas tentativas
|
|
45
|
+
const LONG_CONTEXT_MIN = 200000;
|
|
46
|
+
|
|
47
|
+
// O preço do OpenRouter vem POR TOKEN, em string ("0.000003"). O catálogo do
|
|
48
|
+
// produto trabalha por 1M de tokens. Converter no lugar errado produz erro de
|
|
49
|
+
// seis ordens de grandeza no custo — daí a constante ter nome.
|
|
50
|
+
const TOKENS_POR_UNIDADE_DE_PRECO = 1e6;
|
|
51
|
+
|
|
52
|
+
function _num(valor) {
|
|
53
|
+
const n = Number(valor);
|
|
54
|
+
return Number.isFinite(n) ? n : 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Arredonda para 6 casas. Sem isso, 0.000003 * 1e6 vira 3.0000000000000004 e o
|
|
58
|
+
// teste de paridade entre superfícies passa a comparar ruído de ponto flutuante.
|
|
59
|
+
function _preco1M(porToken) {
|
|
60
|
+
return Math.round(_num(porToken) * TOKENS_POR_UNIDADE_DE_PRECO * 1e6) / 1e6;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* Converte UMA entrada do OpenRouter para o formato do catálogo. Devolve null
|
|
64
|
+
para entrada inutilizável — id ausente ou sem barra (o formato é sempre
|
|
65
|
+
`autor/modelo`), que é o que separa um modelo de um registro quebrado. */
|
|
66
|
+
function parseModel(bruto) {
|
|
67
|
+
if (!bruto || typeof bruto !== 'object') return null;
|
|
68
|
+
const upstreamId = String(bruto.id || '').trim();
|
|
69
|
+
if (!upstreamId || upstreamId.indexOf('/') < 0) return null;
|
|
70
|
+
|
|
71
|
+
const params = Array.isArray(bruto.supported_parameters) ? bruto.supported_parameters : [];
|
|
72
|
+
const modalidades = (bruto.architecture && Array.isArray(bruto.architecture.input_modalities))
|
|
73
|
+
? bruto.architecture.input_modalities : [];
|
|
74
|
+
const preco = bruto.pricing || {};
|
|
75
|
+
|
|
76
|
+
const contextWindow = Math.max(0, Math.trunc(_num(bruto.context_length)));
|
|
77
|
+
const input = _preco1M(preco.prompt);
|
|
78
|
+
const output = _preco1M(preco.completion);
|
|
79
|
+
|
|
80
|
+
const capabilities = [];
|
|
81
|
+
if (params.includes('tools')) capabilities.push('tools');
|
|
82
|
+
if (modalidades.includes('image')) capabilities.push('vision');
|
|
83
|
+
if (contextWindow >= LONG_CONTEXT_MIN) capabilities.push('long-context');
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
// O id local é o upstreamId sem o sufixo de variante `:free`, para que o
|
|
87
|
+
// mesmo modelo pago e gratuito não vire duas entradas concorrendo no
|
|
88
|
+
// seletor. A variante fica registrada em `free`.
|
|
89
|
+
id: upstreamId.replace(/:free$/, ''),
|
|
90
|
+
upstreamId,
|
|
91
|
+
provider: 'openrouter',
|
|
92
|
+
upstreamProvider: upstreamId.split('/')[0],
|
|
93
|
+
name: String(bruto.name || upstreamId),
|
|
94
|
+
contextWindow,
|
|
95
|
+
price: {
|
|
96
|
+
input,
|
|
97
|
+
output,
|
|
98
|
+
cachedInput: _preco1M(preco.input_cache_read),
|
|
99
|
+
},
|
|
100
|
+
capabilities,
|
|
101
|
+
free: input === 0 && output === 0,
|
|
102
|
+
rated: false, // sem toolReliability/quality: fora do roteamento automático
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* Converte o corpo inteiro. Aceita texto ou objeto já parseado. Nunca lança por
|
|
107
|
+
causa de conteúdo: devolve {ok:false, erro} — uma resposta estranha do
|
|
108
|
+
provedor não pode derrubar a inicialização do app. */
|
|
109
|
+
function parseOpenRouterCatalog(corpo) {
|
|
110
|
+
let dados = corpo;
|
|
111
|
+
if (typeof corpo === 'string') {
|
|
112
|
+
try { dados = JSON.parse(corpo); }
|
|
113
|
+
catch (e) { return { ok: false, erro: 'json inválido: ' + (e && e.message), models: [] }; }
|
|
114
|
+
}
|
|
115
|
+
const lista = dados && Array.isArray(dados.data) ? dados.data : null;
|
|
116
|
+
if (!lista) return { ok: false, erro: 'resposta sem campo data[]', models: [] };
|
|
117
|
+
|
|
118
|
+
const models = [];
|
|
119
|
+
const porId = new Map();
|
|
120
|
+
let ignorados = 0;
|
|
121
|
+
for (const bruto of lista) {
|
|
122
|
+
const m = parseModel(bruto);
|
|
123
|
+
if (!m) { ignorados++; continue; }
|
|
124
|
+
|
|
125
|
+
const anterior = porId.get(m.id);
|
|
126
|
+
if (!anterior) { porId.set(m.id, m); models.push(m); continue; }
|
|
127
|
+
|
|
128
|
+
/* Mesmo id local duas vezes — é o par `modelo` e `modelo:free`. O registro
|
|
129
|
+
principal é sempre o PAGO, porque é dele que sai o preço real e é ele que
|
|
130
|
+
costuma ter a cota maior; a existência da variante gratuita fica em
|
|
131
|
+
`freeVariant`.
|
|
132
|
+
|
|
133
|
+
Uma versão anterior fazia `anterior.free = true` aqui e marcava o registro
|
|
134
|
+
PAGO como gratuito — o preço continuava lá, e o teste pegou a contradição
|
|
135
|
+
(`marcado free com preço de entrada`). Marcar errado teria feito o seletor
|
|
136
|
+
anunciar como grátis um modelo que cobra. */
|
|
137
|
+
if (m.free && !anterior.free) { anterior.freeVariant = m.upstreamId; continue; }
|
|
138
|
+
if (anterior.free && !m.free) {
|
|
139
|
+
// O gratuito chegou primeiro: o pago assume como principal, e a gratuita
|
|
140
|
+
// vira a variante. Sem isto a ordem da resposta decidiria o preço.
|
|
141
|
+
const idx = models.indexOf(anterior);
|
|
142
|
+
m.freeVariant = anterior.upstreamId;
|
|
143
|
+
models[idx] = m;
|
|
144
|
+
porId.set(m.id, m);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
// Duas variantes do mesmo tipo: fica a primeira, sem inventar nada.
|
|
148
|
+
}
|
|
149
|
+
return { ok: true, models, ignorados, total: lista.length };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* Funde o catálogo curado com a camada dinâmica. NÃO muta o curado — ele é
|
|
153
|
+
Object.freeze e é o piso offline. Devolve um objeto novo mais o relatório do
|
|
154
|
+
que mudou, que é o que o dono precisa ver para decidir o que curar. */
|
|
155
|
+
function mergeWithCurated(curated, overlayModels) {
|
|
156
|
+
const base = curated || {};
|
|
157
|
+
const lista = Array.isArray(overlayModels) ? overlayModels : [];
|
|
158
|
+
const porUpstream = new Map();
|
|
159
|
+
for (const [id, info] of Object.entries(base)) {
|
|
160
|
+
if (info && info.upstreamId) porUpstream.set(info.upstreamId, id);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const models = {};
|
|
164
|
+
for (const [id, info] of Object.entries(base)) models[id] = info;
|
|
165
|
+
|
|
166
|
+
const added = [];
|
|
167
|
+
const priceUpdated = [];
|
|
168
|
+
const drift = [];
|
|
169
|
+
|
|
170
|
+
for (const m of lista) {
|
|
171
|
+
const idLocal = porUpstream.get(m.upstreamId) || (base[m.id] ? m.id : null);
|
|
172
|
+
|
|
173
|
+
if (idLocal) {
|
|
174
|
+
// Entrada JÁ CURADA: os campos de julgamento ficam como estão; só preço e
|
|
175
|
+
// contexto acompanham o upstream, porque esses são fatos e mudam sozinhos.
|
|
176
|
+
const atual = base[idLocal];
|
|
177
|
+
const precoAntigo = atual.price || {};
|
|
178
|
+
const mudouPreco = _preco1M(precoAntigo.input) !== m.price.input
|
|
179
|
+
|| _preco1M(precoAntigo.output) !== m.price.output;
|
|
180
|
+
const mudouContexto = Number(atual.contextWindow) !== m.contextWindow && m.contextWindow > 0;
|
|
181
|
+
|
|
182
|
+
if (mudouPreco || mudouContexto) {
|
|
183
|
+
drift.push({
|
|
184
|
+
id: idLocal,
|
|
185
|
+
upstreamId: m.upstreamId,
|
|
186
|
+
nossoPreco: { input: precoAntigo.input, output: precoAntigo.output },
|
|
187
|
+
precoUpstream: { input: m.price.input, output: m.price.output },
|
|
188
|
+
nossoContexto: atual.contextWindow,
|
|
189
|
+
contextoUpstream: m.contextWindow,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
/* Fatos do upstream que a entrada curada não tem como saber sozinha, e
|
|
193
|
+
que por isso acompanham mesmo quando o preço não mexeu:
|
|
194
|
+
|
|
195
|
+
freeVariant o mesmo modelo publicado também como `:free`
|
|
196
|
+
name o nome legível ("Anthropic: Claude Opus 5")
|
|
197
|
+
|
|
198
|
+
Nenhuma das entradas curadas declara `name` — o campo não existe nelas —
|
|
199
|
+
então isto não sobrescreve escolha de ninguém. É a diferença entre o
|
|
200
|
+
seletor mostrar o nome do modelo ou mostrar o id cru para o usuário. */
|
|
201
|
+
const mudancas = {};
|
|
202
|
+
if (mudouPreco || mudouContexto) {
|
|
203
|
+
mudancas.price = Object.assign({}, precoAntigo, m.price);
|
|
204
|
+
mudancas.contextWindow = m.contextWindow > 0 ? m.contextWindow : atual.contextWindow;
|
|
205
|
+
priceUpdated.push(idLocal);
|
|
206
|
+
}
|
|
207
|
+
if (m.freeVariant) mudancas.freeVariant = m.freeVariant;
|
|
208
|
+
if (m.name && !atual.name) mudancas.name = m.name;
|
|
209
|
+
models[idLocal] = Object.keys(mudancas).length ? Object.assign({}, atual, mudancas) : atual;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Entrada NOVA: entra com rated:false e sem toolReliability/quality. Quem
|
|
214
|
+
// lê esses campos precisa tratar ausência — o roteador filtra por rated.
|
|
215
|
+
models[m.id] = {
|
|
216
|
+
upstreamId: m.upstreamId,
|
|
217
|
+
provider: 'openrouter',
|
|
218
|
+
upstreamProvider: m.upstreamProvider,
|
|
219
|
+
name: m.name,
|
|
220
|
+
contextWindow: m.contextWindow,
|
|
221
|
+
price: m.price,
|
|
222
|
+
capabilities: m.capabilities,
|
|
223
|
+
free: m.free,
|
|
224
|
+
rated: false,
|
|
225
|
+
};
|
|
226
|
+
/* Só quando existe: declarar a chave com undefined mudaria a forma de
|
|
227
|
+
centenas de registros para não dizer nada. */
|
|
228
|
+
if (m.freeVariant) models[m.id].freeVariant = m.freeVariant;
|
|
229
|
+
added.push(m.id);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Curado que aponta para upstream que não existe mais. Não remove sozinho:
|
|
233
|
+
// sumir com um modelo que alguém está usando é pior do que avisar. O relatório
|
|
234
|
+
// é que diz ao dono o que aposentar.
|
|
235
|
+
const idsUpstream = new Set(lista.map((m) => m.upstreamId));
|
|
236
|
+
const upstreamMissing = [];
|
|
237
|
+
for (const [id, info] of Object.entries(base)) {
|
|
238
|
+
if (!info || !info.upstreamId) continue;
|
|
239
|
+
if (info.provider !== 'openrouter') continue;
|
|
240
|
+
if (!idsUpstream.has(info.upstreamId)) upstreamMissing.push({ id, upstreamId: info.upstreamId });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { models, added, priceUpdated, drift, upstreamMissing };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/* Relatório legível do que divergiu. Existe porque "18 preços divergentes" só
|
|
247
|
+
vira ação quando alguém consegue ver QUAIS. */
|
|
248
|
+
function priceDriftReport(curated, overlayModels) {
|
|
249
|
+
const r = mergeWithCurated(curated, overlayModels);
|
|
250
|
+
return {
|
|
251
|
+
total: Object.keys(curated || {}).length,
|
|
252
|
+
novos: r.added.length,
|
|
253
|
+
precoAtualizado: r.priceUpdated.length,
|
|
254
|
+
divergencias: r.drift,
|
|
255
|
+
sumiramDoUpstream: r.upstreamMissing,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/* Envelope do cache em disco. `schema` existe para que uma versão futura com
|
|
260
|
+
outro formato não seja lida como se fosse desta — quem lê rejeita o que não
|
|
261
|
+
reconhece em vez de adivinhar. */
|
|
262
|
+
function buildCachePayload(models, fetchedAt) {
|
|
263
|
+
return {
|
|
264
|
+
schema: CATALOG_SCHEMA,
|
|
265
|
+
source: OPENROUTER_MODELS_URL,
|
|
266
|
+
fetchedAt: String(fetchedAt || ''),
|
|
267
|
+
count: Array.isArray(models) ? models.length : 0,
|
|
268
|
+
models: Array.isArray(models) ? models : [],
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function isCacheValid(payload) {
|
|
273
|
+
return !!payload
|
|
274
|
+
&& payload.schema === CATALOG_SCHEMA
|
|
275
|
+
&& Array.isArray(payload.models)
|
|
276
|
+
&& payload.models.length > 0;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/* Decide se vale buscar de novo. Separado da busca de propósito: assim o teste
|
|
280
|
+
exercita a política sem precisar de relógio nem de rede. */
|
|
281
|
+
function shouldRefresh(payload, agoraMs, ultimaTentativaMs) {
|
|
282
|
+
if (Number.isFinite(ultimaTentativaMs) && ultimaTentativaMs > 0) {
|
|
283
|
+
if (agoraMs - ultimaTentativaMs < MIN_REFRESH_MS) return false;
|
|
284
|
+
}
|
|
285
|
+
if (!isCacheValid(payload)) return true;
|
|
286
|
+
const buscadoEm = Date.parse(payload.fetchedAt || '');
|
|
287
|
+
if (!Number.isFinite(buscadoEm)) return true;
|
|
288
|
+
return (agoraMs - buscadoEm) >= CACHE_TTL_MS;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
module.exports = {
|
|
292
|
+
CATALOG_SCHEMA, OPENROUTER_MODELS_URL, CACHE_TTL_MS, MIN_REFRESH_MS, LONG_CONTEXT_MIN,
|
|
293
|
+
parseModel, parseOpenRouterCatalog, mergeWithCurated, priceDriftReport,
|
|
294
|
+
buildCachePayload, isCacheValid, shouldRefresh,
|
|
295
|
+
};
|