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 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: JSON.stringify(res).slice(0, TOOL_RESULT_CAP) });
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: JSON.stringify(result).slice(0, _resultCap) });
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
- const _rawToolContent = _cachedContent || JSON.stringify(result).slice(0, _resultCap);
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
  ] },
@@ -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 (MODEL_CATALOG[raw]) return raw;
274
- if (MODEL_CATALOG[transportFree]) return transportFree;
275
- if (MODEL_CATALOG[lower]) return lower;
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(MODEL_CATALOG)
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 = MODEL_CATALOG[id] || {
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: !!MODEL_CATALOG[id] }, info);
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 MODEL_CATALOG[normalized] || normalized === 'smart' ? normalized : _safeToolName(value);
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' || MODEL_CATALOG[model])
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 => MODEL_CATALOG[model] || model === 'smart');
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(MODEL_CATALOG)).map(supportedModelId)
552
- .filter(id => MODEL_CATALOG[id] && id !== 'smart')
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 = MODEL_CATALOG[id];
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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminal-smart-cli",
3
- "version": "0.97.69",
3
+ "version": "0.98.1",
4
4
  "description": "Terminal Smart no seu terminal — pergunte, analise logs por pipe e orquestre agentes de IA. Comando: ts",
5
5
  "bin": {
6
6
  "ts": "bin/ts.js"