brito-cli 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -93,6 +93,7 @@ The lint **rejects** secrets/tokens, absolute user paths, credentials in URLs (`
93
93
 
94
94
  - **Fallback when Claude runs out**: chain Claude → `qwen` profile → local SLM, with the full agent loop and tools (same permissions). It is temporary: after a window (the reset time reported by the error, else 10 min, or 60 min for quota/auth; `BRITO_FALLBACK_MINUTES` overrides) the next turn tries Claude again. `/fallback` shows when. A 4B model errs more with tools: use it to keep going, not for big tasks. `BRITO_SLM_FALLBACK=off` removes it from the chain; `BRITO_SLM_CTX` sets its context.
95
95
  - **Context compaction on the local SLM**: when the conversation is compacted, the middle is summarized by the local SLM (chunked map-reduce) instead of Claude, saving tokens; on any failure it falls back to Claude. `/context` shows compactions and estimated tokens saved. `BRITO_SLM_COMPACT=off` disables it.
96
+ - **Route decider (shadow mode)**: the local SLM classifies each request as `claude`, `slm` or `tool` after the turn and logs it next to what the turn really needed; it changes nothing yet. `/route` (or `/route all`) shows how much it would delegate and its expensive errors. Logs in `~/.brito/routing/`; `BRITO_ROUTE=off` disables it.
96
97
 
97
98
  `BRITO_SLM=off` disables it; `BRITO_SLM_MODEL` points to another GGUF (`hf:` URI or file path).
98
99
 
package/README.pt-BR.md CHANGED
@@ -88,6 +88,8 @@ O lint **rejeita** segredos/tokens, caminhos absolutos do usuário, credenciais
88
88
  - **Tool `digest`**: batedor e refinador de textos longos. O modelo local escolhe os blocos mais ligados ao seu objetivo e propõe trechos; o código confere cada citação contra a fonte e descarta o que não existe, e o modelo principal recebe um rascunho pequeno em markdown com citações verificadas e números de página.
89
89
 
90
90
  - **Fallback quando o Claude acaba**: cadeia Claude → perfil `qwen` → SLM local, com o loop completo do agente e as tools (mesmas permissões). É temporário: passada a janela (o horário de reset que o erro informa, senão 10 min, ou 60 min para cota/auth; `BRITO_FALLBACK_MINUTES` sobrepõe) o próximo turno tenta o Claude de novo. O `/fallback` mostra quando. Um modelo de 4B erra mais com tools: use para continuar, não para tarefas grandes. `BRITO_SLM_FALLBACK=off` tira o SLM da cadeia; `BRITO_SLM_CTX` define o contexto.
91
+ - **Compactação de contexto no SLM local**: ao compactar a conversa, o meio é resumido pelo SLM local (map-reduce em fatias) em vez do Claude, poupando tokens; em qualquer falha volta ao Claude. O `/context` mostra as compactações e os tokens poupados estimados. `BRITO_SLM_COMPACT=off` desliga.
92
+ - **Decisor de destino (modo sombra)**: o SLM local classifica cada pedido como `claude`, `slm` ou `tool` depois do turno e grava ao lado do que o turno realmente exigiu; ainda não muda nada. `/route` (ou `/route all`) mostra quanto ele delegaria e os erros caros. Registros em `~/.brito/routing/`; `BRITO_ROUTE=off` desliga.
91
93
 
92
94
  `BRITO_SLM=off` desliga; `BRITO_SLM_MODEL` aponta para outro GGUF (URI `hf:` ou caminho de arquivo).
93
95
 
package/dist/cli.js CHANGED
@@ -36,6 +36,9 @@ import { expireTrials, skillStats, skillsMode } from "./skills/trial.js";
36
36
  import { claudeCliComplete } from "./skills/learn-cli.js";
37
37
  import { pullModel, removeModel, slmStatus, withSlm } from "./local/slm.js";
38
38
  import { slmStatsLine } from "./local/stats.js";
39
+ import { shadowRoute, readRoutes } from "./route/log.js";
40
+ import { renderRouteStats } from "./route/stats.js";
41
+ import { routeEnabled } from "./route/decide.js";
39
42
  import { usageCommand } from "./usage/command.js";
40
43
  import { buildSystemPrompt, graphStatsLine, memoSystem, systemKey } from "./agent/prompt.js";
41
44
  import { allTools } from "./tools/registry.js";
@@ -133,7 +136,7 @@ program
133
136
  });
134
137
  program
135
138
  .command("slm [acao]")
136
- .description("SLM local embutido (llama.cpp): status | pull (baixa o modelo) | rm (apaga). Usado na reflexão de skills; desligue com BRITO_SLM=off")
139
+ .description("Modelo nativo embutido (llama.cpp, roda na sua máquina, sem tokens): status | pull (baixa) | rm (apaga). Faz reflexão de skills, compactação e digest; desligue com BRITO_SLM=off")
137
140
  .action(async (acao) => {
138
141
  const st = await slmStatus();
139
142
  if (acao === "pull") {
@@ -523,12 +526,24 @@ const skillsCommand = (root, io, llm) => ({
523
526
  ui.info(`${pending.length} proposta(s) pendente(s): /skills pending`);
524
527
  },
525
528
  });
529
+ /** `/route [all]`: o que o decisor de destino (claude/slm/tool) teria feito nos turnos já registrados, em modo sombra. */
530
+ const routeCommand = (root) => ({
531
+ desc: "decisor claude/slm/tool em modo sombra: estatísticas dos turnos (all = todos os repositórios)",
532
+ run: async (args) => {
533
+ const all = args[0] === "all";
534
+ for (const l of renderRouteStats(readRoutes(all ? undefined : root), all ? "todos os repositórios" : "este repositório", routeEnabled()))
535
+ ui.print(c.muted(` ${l}`));
536
+ },
537
+ });
526
538
  /** Reflexão pós-turno que propõe skills (gatilho automático ou `/learn`). Independe do backend: só muda a origem do turno e o modelo. */
527
539
  function makeLearner(getTurns, ctx, complete, getModel) {
528
540
  return async (force = false) => {
529
541
  const model = getModel();
530
542
  const turns = getTurns();
531
543
  const turn = turns[turns.length - 1] ?? null;
544
+ // Modo sombra do decisor de destino: registra o que ele escolheria e o que o turno exigiu. Não bloqueia o REPL.
545
+ if (!force)
546
+ void shadowRoute(ctx.root, turn);
532
547
  // O resultado do turno vira evidência para as skills usadas nele (promoção/arquivamento), antes de refletir.
533
548
  if (!force)
534
549
  for (const n of [...recordOutcomes(turn, ctx), ...(skillsMode() === "off" ? [] : expireTrials(ctx.root))])
@@ -572,7 +587,7 @@ function makeLearner(getTurns, ctx, complete, getModel) {
572
587
  function orchestratorCommand(main) {
573
588
  const usage = " uso: /orquestrar on|off [modelo <perfil>] [--global] · /orquestrar modelo <perfil> [--global]";
574
589
  return {
575
- desc: "modo orquestrador: o modelo principal planeja e revisa, um perfil (qwen) implementa",
590
+ desc: "modo orquestrador: o modelo principal planeja e revisa; um perfil de endpoint (qwen) escreve o código, poupando tokens do Claude",
576
591
  run: async (args) => {
577
592
  const global = args.includes("--global");
578
593
  const words = args.filter((a) => !a.startsWith("--"));
@@ -765,6 +780,7 @@ async function buildApiBackend(root, model, io, o = {}) {
765
780
  "/usage": usageCommand(() => ({ ...agent.usage, costUsd: backend.cost?.() })),
766
781
  "/index": { desc: "reconstrói o grafo do zero", run: async () => { ui.info("reconstruindo grafo…"); ui.print(c.muted(JSON.stringify(await indexProject(root, graph, { full: true })))); } },
767
782
  "/learn": { desc: "extrai skills do último turno agora", run: async () => { await learn(true); } },
783
+ "/route": routeCommand(root),
768
784
  "/skills": skillsCommand(root, io, { complete: withSlm(defaultComplete), model: learnModel }),
769
785
  "/permissions": permissionsCommand(root),
770
786
  "/todos": { desc: "lista de tarefas da sessão", run: async () => { ctx.todos?.length ? ui.printTodos(ctx.todos) : ui.print(c.muted(" sem lista de tarefas nesta sessão")); } },
@@ -934,6 +950,7 @@ async function buildClaudeBackend(root, io, sw, model, openGraph, resume) {
934
950
  "/context": unavailable,
935
951
  "/usage": usageCommand(() => ({ ...session.usage, costUsd: session.costUsd })),
936
952
  "/learn": { desc: "extrai skills do último turno agora", run: async () => { await learn(true); } },
953
+ "/route": routeCommand(root),
937
954
  "/skills": skillsCommand(root, io, { complete: withSlm(claudeCliComplete), model: () => process.env.BRITO_LEARN_MODEL ?? "" }),
938
955
  "/permissions": permissionsCommand(root),
939
956
  "/sessions": sessionsCommand(root, () => session.sessionId),
@@ -1,4 +1,5 @@
1
1
  import { fetchPlanUsage } from "../usage/plan.js";
2
+ import { slmUsage } from "../local/stats.js";
2
3
  import fs from "node:fs";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
@@ -26,12 +27,18 @@ import { buildSystemPrompt, graphStatsLine, memoSystem, systemKey } from "../age
26
27
  import { openGraphAfterClaude } from "../graph/store.js";
27
28
  import { indexProject } from "../graph/indexer.js";
28
29
  import { initialEffort, initialModel, isEffort, loadProfiles, MODEL_ALIASES } from "../model.js";
29
- import { NO_ANTHROPIC, fallbackStatus, openEngine } from "../engine.js";
30
+ import { NO_ANTHROPIC, fallbackStatus, learnerModel, openEngine } from "../engine.js";
30
31
  import { engineChip, activateFallback, chooseModel, cliShouldFallBack, fallbackWarning, newFallbackState, renderFallback, retryPrompt } from "../provider/fallback.js";
31
32
  import { contextWindowFor, resolveModel } from "../provider/resolve.js";
32
33
  import { PLAN_NOTE, perms } from "../permissions.js";
33
34
  import { ORCH_NOTE, implStatus, mainTools, orch, orchGuard, restoreOrch } from "../orchestrator.js";
34
- import { getSkill, listBuiltins, listSkills, skillsIndex } from "../skills/store.js";
35
+ import { getSkill, listBuiltins, listPending, listSkills, skillsIndex } from "../skills/store.js";
36
+ import { defaultComplete, recordOutcomes, reflect, shouldReflect } from "../skills/learn.js";
37
+ import { claudeCliComplete } from "../skills/learn-cli.js";
38
+ import { autoConsolidate } from "../skills/consolidate.js";
39
+ import { expireTrials, skillsMode } from "../skills/trial.js";
40
+ import { withSlm } from "../local/slm.js";
41
+ import { shadowRoute } from "../route/log.js";
35
42
  /**
36
43
  * Backend do app desktop, controlado por JSON em linhas.
37
44
  * stdin → {t:"send",text} {t:"abort"} {t:"approve",id,allow,scope?:"session"|"project"} {t:"config",model?,effort?,mode?,orch?} {t:"reset"} {t:"query"} {t:"action"} {t:"quit"}
@@ -53,6 +60,7 @@ const BUILTIN_COMMANDS = [
53
60
  { name: "clear", description: "limpa a conversa", kind: "builtin" },
54
61
  { name: "fallback", description: "estado do fallback Claude → modelo local", kind: "builtin" },
55
62
  { name: "help", description: "lista os comandos", kind: "builtin" },
63
+ { name: "learn", description: "extrai skills do último turno agora", kind: "builtin" },
56
64
  ];
57
65
  /** Arquivos do projeto para o `@`: git ls-files (respeita .gitignore); sem git, uma varredura rasa. */
58
66
  let fileCache = null;
@@ -114,6 +122,50 @@ const sink = {
114
122
  warn: (msg) => emit({ t: "warn", msg }),
115
123
  error: (msg) => emit({ t: "error", msg }),
116
124
  };
125
+ /**
126
+ * Reflexão pós-turno que propõe skills (gatilho automático ou `/learn`), igual à do terminal, mas avisando a janela
127
+ * com eventos `skill` (refletindo → resultado + nº de propostas pendentes) em vez de imprimir.
128
+ */
129
+ function makeLearner(getTurns, ctx, complete, getModel) {
130
+ let seen = null; // turno já analisado: um turno abortado/falho não reanalisa o anterior
131
+ const done = (notes) => emit({ t: "skill", phase: "done", notes, pending: listPending(ctx.root).length });
132
+ return async (force = false) => {
133
+ const turns = getTurns();
134
+ const turn = turns[turns.length - 1] ?? null;
135
+ if (!force && turn === seen)
136
+ return;
137
+ if (!force)
138
+ void shadowRoute(ctx.root, turn); // modo sombra do decisor de destino; não bloqueia
139
+ const model = getModel();
140
+ try {
141
+ const outcomes = force ? [] : [...recordOutcomes(turn, ctx), ...(skillsMode() === "off" ? [] : expireTrials(ctx.root))];
142
+ if (!force && !shouldReflect(turn, ctx)) {
143
+ seen = turn;
144
+ ctx.loadedSkills?.clear();
145
+ if (outcomes.length)
146
+ done(outcomes);
147
+ return;
148
+ }
149
+ if (!turn) {
150
+ emit({ t: "skill", phase: "done", notes: ["Nada para refletir ainda."], pending: listPending(ctx.root).length });
151
+ return;
152
+ }
153
+ seen = turn;
154
+ emit({ t: "skill", phase: "reflecting" });
155
+ const notes = await reflect(turn, ctx, model, complete, turns[turns.length - 2]);
156
+ if (skillsMode() === "auto") {
157
+ try {
158
+ notes.push(...await autoConsolidate(ctx.root, model, complete));
159
+ }
160
+ catch { /* manutenção é opcional */ }
161
+ }
162
+ done([...outcomes, ...(notes.length ? notes : ["nenhuma skill nova"])]);
163
+ }
164
+ catch (e) {
165
+ emit({ t: "skill", phase: "error", notes: [`reflexão falhou: ${e?.message ?? e}`], pending: listPending(ctx.root).length });
166
+ }
167
+ };
168
+ }
117
169
  const apiLabel = (m) => (resolveModel(m).kind === "openai" ? "endpoint" : "API Anthropic");
118
170
  export async function serveDesktop(root, resumeId) {
119
171
  const pending = new Map();
@@ -175,6 +227,7 @@ export async function serveDesktop(root, resumeId) {
175
227
  if (o.session?.todos)
176
228
  ctx.todos = o.session.todos;
177
229
  let ac = null;
230
+ const learn = makeLearner(() => agent.recentTurns, ctx, withSlm(defaultComplete), () => learnerModel(agent.model, process.env));
178
231
  const id = o.session?.id ?? randomUUID();
179
232
  // Depois de uma troca a partir do claude CLI quem guarda a conversa é ele (o id é o dele): nada a salvar aqui.
180
233
  const persist = () => {
@@ -188,7 +241,7 @@ export async function serveDesktop(root, resumeId) {
188
241
  }
189
242
  };
190
243
  return {
191
- kind: "api", effort: "", fallback,
244
+ kind: "api", effort: "", fallback, learn,
192
245
  // Depois da troca o id segue o do claude: a janela o guarda para retomar a conversa ao reabrir.
193
246
  get sessionId() { return o.back?.engine.sessionId ?? id; },
194
247
  get model() { return agent.model; }, set model(m) { agent.model = m; },
@@ -282,6 +335,9 @@ export async function serveDesktop(root, resumeId) {
282
335
  // Dentro do Electron o executável é o próprio app: este env o faz agir como Node nos filhos (claude → mcp-serve).
283
336
  env: process.versions.electron || process.env.ELECTRON_RUN_AS_NODE ? { ELECTRON_RUN_AS_NODE: "1" } : undefined,
284
337
  });
338
+ // O grafo fica com o servidor MCP; skill_save só usa o store de skills, então `graph` não é tocado.
339
+ const learnCtx = { root, graph: undefined, ask, sync: async () => { }, dirty: false, loadedSkills: session.loadedSkills };
340
+ const learn = makeLearner(() => session.recentTurns, learnCtx, withSlm(claudeCliComplete), () => process.env.BRITO_LEARN_MODEL ?? "");
285
341
  if (resumeId)
286
342
  session.resume(resumeId);
287
343
  let resumed = !!resumeId; // até o primeiro reset
@@ -290,7 +346,7 @@ export async function serveDesktop(root, resumeId) {
290
346
  /** Fechamento do grafo do modo API na volta para cá (ver `back.release`). */
291
347
  let graphReleased = Promise.resolve();
292
348
  const claude = {
293
- kind: "claude", fallback,
349
+ kind: "claude", fallback, learn,
294
350
  get sessionId() { return session.sessionId; },
295
351
  get model() { return session.model; },
296
352
  get effort() { return session.effort; }, set effort(e) { session.effort = e; },
@@ -380,11 +436,16 @@ export async function serveDesktop(root, resumeId) {
380
436
  }
381
437
  busy = true;
382
438
  try {
383
- await engine.send(text);
439
+ if (cmd === "/learn")
440
+ await engine.learn(true);
441
+ else {
442
+ await engine.send(text);
443
+ await engine.learn();
444
+ }
384
445
  }
385
446
  finally {
386
447
  busy = false;
387
- emit({ t: "usage", ...engine.stats(), model: engine.model, sessionId: engine.sessionId, fallback: engine.fallback.active, chip: chipOf(engine.model, engine.fallback) });
448
+ emit({ t: "usage", ...engine.stats(), slm: slmUsage(), model: engine.model, sessionId: engine.sessionId, fallback: engine.fallback.active, chip: chipOf(engine.model, engine.fallback) });
388
449
  }
389
450
  break;
390
451
  }
@@ -409,7 +470,7 @@ export async function serveDesktop(root, resumeId) {
409
470
  data = [...BUILTIN_COMMANDS, ...skills].filter((c) => !arg || c.name.includes(arg));
410
471
  }
411
472
  else if (m.q === "usage")
412
- data = { plan: await fetchPlanUsage({ force: arg === "force" }), session: engine.stats(), model: engine.model };
473
+ data = { plan: await fetchPlanUsage({ force: arg === "force" }), session: engine.stats(), slm: slmUsage(), model: engine.model };
413
474
  else if (m.q === "models")
414
475
  data = { aliases: Object.keys(MODEL_ALIASES), profiles: Object.keys(loadProfiles().profiles) };
415
476
  }
package/dist/local/slm.js CHANGED
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import { createRequire } from "node:module";
4
4
  import path from "node:path";
5
5
  import { globalHome } from "../skills/store.js";
6
+ import { recordSlmCall } from "./stats.js";
6
7
  /**
7
8
  * SLM embutido (llama.cpp via `node-llama-cpp`, dependência OPCIONAL): roda dentro do processo, sem servidor.
8
9
  * Hoje serve às tarefas auxiliares (reflexão que propõe skills). Só entra em ação depois de `brito slm pull`
@@ -104,12 +105,16 @@ export async function slmGenerate(o) {
104
105
  const lib = await loadLib();
105
106
  const { model, llama } = await ensureLoaded();
106
107
  const context = await model.createContext({ contextSize: { max: 8192 } });
108
+ const t0 = Date.now();
109
+ let out = "";
107
110
  try {
108
111
  const session = new lib.LlamaChatSession({ contextSequence: context.getSequence(), systemPrompt: o.system });
109
112
  const grammar = o.schema ? await llama.createGrammarForJsonSchema(o.schema) : undefined;
110
- return await session.prompt(o.user, { grammar, maxTokens: o.maxTokens ?? 1500, temperature: o.temperature ?? 0.2, signal: o.signal });
113
+ out = await session.prompt(o.user, { grammar, maxTokens: o.maxTokens ?? 1500, temperature: o.temperature ?? 0.2, signal: o.signal });
114
+ return out;
111
115
  }
112
116
  finally {
117
+ recordSlmCall(o.system.length + o.user.length, out.length, Date.now() - t0);
113
118
  await context.dispose();
114
119
  scheduleUnload();
115
120
  }
@@ -122,6 +127,8 @@ export async function slmChat(o) {
122
127
  const lib = await loadLib();
123
128
  const { model } = await ensureLoaded();
124
129
  const context = await model.createContext({ contextSize: { max: slmChatCtx() } });
130
+ const t0 = Date.now();
131
+ let outChars = 0;
125
132
  try {
126
133
  const chat = new lib.LlamaChat({ contextSequence: context.getSequence() });
127
134
  const functions = o.functions && Object.keys(o.functions).length
@@ -133,9 +140,11 @@ export async function slmChat(o) {
133
140
  o.onText?.(t); },
134
141
  });
135
142
  const calls = (res.functionCalls ?? []).map((c) => ({ name: String(c.functionName), input: c.params ?? {} }));
143
+ outChars = String(res.response ?? "").length + JSON.stringify(calls).length;
136
144
  return { text: String(res.response ?? ""), calls, truncated: res.metadata?.stopReason === "maxTokens" };
137
145
  }
138
146
  finally {
147
+ recordSlmCall(JSON.stringify(o.history).length + (o.functions ? JSON.stringify(o.functions).length : 0), outChars, Date.now() - t0);
139
148
  await context.dispose();
140
149
  scheduleUnload();
141
150
  }
@@ -6,8 +6,34 @@ export const slmStats = {
6
6
  compactFallbacks: 0,
7
7
  /** estimativa: tokens de entrada que o modelo principal deixou de processar (texto original − resumo) */
8
8
  tokensSaved: 0,
9
+ /** TODA geração feita no SLM local (compactação, digest, reflexão, roteador, fallback): é o "gasto" local */
10
+ calls: 0,
11
+ /** estimativa (~4 caracteres/token) do que o SLM leu e escreveu */
12
+ tokensIn: 0,
13
+ tokensOut: 0,
14
+ ms: 0,
9
15
  };
10
- export const resetSlmStats = () => { slmStats.compactCalls = 0; slmStats.compactFallbacks = 0; slmStats.tokensSaved = 0; };
16
+ export const resetSlmStats = () => {
17
+ slmStats.compactCalls = 0;
18
+ slmStats.compactFallbacks = 0;
19
+ slmStats.tokensSaved = 0;
20
+ slmStats.calls = 0;
21
+ slmStats.tokensIn = 0;
22
+ slmStats.tokensOut = 0;
23
+ slmStats.ms = 0;
24
+ };
25
+ const est = (chars) => Math.ceil(chars / 4);
26
+ /** Registra uma geração do SLM (chamada por `slmGenerate`/`slmChat`; mesmo se falhar, o custo local existiu). */
27
+ export function recordSlmCall(inChars, outChars, ms) {
28
+ slmStats.calls++;
29
+ slmStats.tokensIn += est(inChars);
30
+ slmStats.tokensOut += est(outChars);
31
+ slmStats.ms += ms;
32
+ }
33
+ /** Resumo do gasto local para a barra de status/painel; `null` enquanto o SLM não foi usado. */
34
+ export function slmUsage() {
35
+ return slmStats.calls ? { calls: slmStats.calls, tokensIn: slmStats.tokensIn, tokensOut: slmStats.tokensOut, ms: slmStats.ms, tokensSaved: slmStats.tokensSaved } : null;
36
+ }
11
37
  /** Linha para o `/context`; `null` quando o SLM não fez nada. */
12
38
  export function slmStatsLine() {
13
39
  const { compactCalls: c, compactFallbacks: f, tokensSaved: t } = slmStats;
@@ -176,7 +176,7 @@ const hhmm = (ms) => { const d = new Date(ms); return `${String(d.getHours()).pa
176
176
  */
177
177
  export function engineChip(model, fb) {
178
178
  const r = resolveModel(model);
179
- const where = r.kind === "anthropic" ? "☁ nuvem" : r.local ? "💻 local" : "🌐 endpoint";
179
+ const where = r.kind === "anthropic" ? "☁ Claude (nuvem, usa tokens)" : r.local ? "💻 nativo (na sua máquina, sem tokens)" : "🌐 endpoint (servidor próprio)";
180
180
  const text = `${where} · ${r.label}`;
181
181
  if (!fb?.active)
182
182
  return { text, fallback: false };
package/dist/repl.js CHANGED
@@ -6,6 +6,7 @@ import { randomBytes } from "node:crypto";
6
6
  import { Dock } from "./ui/dock.js";
7
7
  import os from "node:os";
8
8
  import { ui, c, fmtTokens } from "./ui/ui.js";
9
+ import { slmUsage } from "./local/stats.js";
9
10
  import { cols } from "./ui/theme.js";
10
11
  import { engineChip } from "./provider/fallback.js";
11
12
  /** Nome do usuário da máquina (fallback para variáveis de ambiente). */
@@ -103,7 +104,7 @@ export async function runRepl(b, o) {
103
104
  mode: modeChip(),
104
105
  engine: engineChip(b.model(), b.fallback?.()),
105
106
  model: b.model(), ctxTokens: b.contextTokens(), ctxWindow: b.ctxWindow,
106
- input: b.usage().input, output: b.usage().output, cacheRead: b.usage().cacheRead, startedAt: o.startedAt, cost: b.cost?.(),
107
+ input: b.usage().input, output: b.usage().output, cacheRead: b.usage().cacheRead, startedAt: o.startedAt, cost: b.cost?.(), slm: slmUsage(),
107
108
  extra: `${skillCount} ${skillCount === 1 ? "skill" : "skills"}`,
108
109
  });
109
110
  if (interactive && process.stdout.isTTY)
@@ -0,0 +1,42 @@
1
+ import { slmGenerate, slmStatus } from "../local/slm.js";
2
+ export const ROUTE_NAMES = ["slm", "claude", "tool"];
3
+ export const ROUTE_DESC = {
4
+ slm: "Condensar, resumir, extrair, traduzir, reformatar ou rotular um texto que já veio no pedido (log, diff, saída de comando), sem raciocinar sobre o código nem alterá-lo",
5
+ claude: "Raciocinar, depurar, projetar, implementar, refatorar ou revisar código; decisões de arquitetura; tarefas de várias etapas",
6
+ tool: "Consultar o projeto ou executar um comando direto: localizar símbolos, quem chama quem, listar ou abrir arquivos, rodar testes, build ou git",
7
+ };
8
+ /** `BRITO_ROUTE=off` desliga o registro em modo sombra. */
9
+ export const routeEnabled = () => process.env.BRITO_ROUTE !== "off";
10
+ const SCHEMA = { type: "object", properties: { route: { enum: [...ROUTE_NAMES] } }, required: ["route"] };
11
+ const SYSTEM = `Você é um roteador. Escolha o destino de um pedido feito a um agente de programação.\n${ROUTE_NAMES.map((r) => `- ${r}: ${ROUTE_DESC[r]}`).join("\n")}\nResponda só com o JSON {"route": "..."}.`;
12
+ /** O pedido é só classificado: o começo basta e protege a janela do SLM. */
13
+ const MAX_CHARS = 1500;
14
+ /** Lê a saída do SLM; qualquer coisa fora do enum vira `null`. */
15
+ export function parseRoute(out) {
16
+ try {
17
+ const r = JSON.parse(out)?.route;
18
+ return ROUTE_NAMES.includes(r) ? r : null;
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ /**
25
+ * Decide o destino. `ready` e `gen` são injetáveis para teste; por padrão usa o SLM local se ele estiver baixado e
26
+ * habilitado. Nunca lança: o pior caso é `{ route: "claude", source: "default" }`.
27
+ */
28
+ export async function decideRoute(text, opts = {}) {
29
+ const t0 = performance.now();
30
+ const dflt = () => ({ route: "claude", source: "default", ms: Math.round(performance.now() - t0) });
31
+ try {
32
+ const ready = await (opts.ready ?? (async () => (await slmStatus()).ready))();
33
+ if (!ready)
34
+ return dflt();
35
+ const out = await (opts.gen ?? slmGenerate)({ system: SYSTEM, user: text.slice(0, MAX_CHARS), schema: SCHEMA, maxTokens: 20, temperature: 0 });
36
+ const route = parseRoute(out);
37
+ return route ? { route, source: "qwen", ms: Math.round(performance.now() - t0) } : dflt();
38
+ }
39
+ catch {
40
+ return dflt();
41
+ }
42
+ }
@@ -0,0 +1,118 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { globalHome } from "../skills/store.js";
5
+ import { decideRoute, routeEnabled } from "./decide.js";
6
+ const TEXT_MAX = 400;
7
+ // ------------------------------------------------------------------ rótulo implícito
8
+ /** Tools que, se usadas, mostram que o pedido exigia o Claude. */
9
+ const CLAUDE_TOOLS = new Set(["write_file", "edit_file", "implement", "delegate", "skill_save", "skill_delete", "spec_save", "exit_plan", "todo_write"]);
10
+ /** Comandos de shell que equivalem a uma consulta/execução direta (ler estado, rodar testes/build). */
11
+ const DIRECT_BASH = /^\s*(git\s+(status|log|diff|branch|show|rev-parse|remote)|ls|cat|head|tail|pwd|wc|node\s+(-v|--version)|npm\s+(test|run\s+(test|build|typecheck|lint)|ls|view)|npx\s+(tsc|tsx\s+--test)|tsc|yarn\s+(test|build|typecheck)|pnpm\s+(test|build|typecheck))\b/;
12
+ /** Chamadas de tool do turno, na ordem. */
13
+ export function toolUses(turn) {
14
+ const out = [];
15
+ for (const m of turn.messages) {
16
+ if (m.role !== "assistant" || typeof m.content === "string")
17
+ continue;
18
+ for (const b of m.content)
19
+ if (b.type === "tool_use")
20
+ out.push({ name: String(b.name), input: b.input });
21
+ }
22
+ return out;
23
+ }
24
+ /** Texto do pedido do usuário (primeira mensagem do turno). */
25
+ export function turnText(turn) {
26
+ const m = turn.messages.find((x) => x.role === "user");
27
+ if (!m)
28
+ return "";
29
+ if (typeof m.content === "string")
30
+ return m.content;
31
+ return m.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join(" ");
32
+ }
33
+ export function implicitLabel(uses) {
34
+ if (!uses.length)
35
+ return "none";
36
+ for (const u of uses) {
37
+ if (CLAUDE_TOOLS.has(u.name))
38
+ return "claude";
39
+ if (u.name === "bash") {
40
+ const cmd = String(u.input?.command ?? "");
41
+ if (!DIRECT_BASH.test(cmd) || /[;&|><]/.test(cmd.replace(/2>&1/g, "")))
42
+ return "claude";
43
+ }
44
+ }
45
+ return "tool";
46
+ }
47
+ // ------------------------------------------------------------------ arquivo
48
+ export const routeDir = () => path.join(globalHome(), "routing");
49
+ /** Um arquivo por repositório: nome legível + hash do caminho (dois projetos com o mesmo nome não se misturam). */
50
+ export function routeFile(root) {
51
+ const slug = path.basename(root).replace(/[^\w.-]+/g, "_") || "repo";
52
+ return path.join(routeDir(), `${slug}-${crypto.createHash("sha1").update(path.resolve(root)).digest("hex").slice(0, 8)}.jsonl`);
53
+ }
54
+ export function appendRoute(root, e) {
55
+ fs.mkdirSync(routeDir(), { recursive: true });
56
+ fs.appendFileSync(routeFile(root), JSON.stringify(e) + "\n");
57
+ }
58
+ function parseLines(file) {
59
+ let raw;
60
+ try {
61
+ raw = fs.readFileSync(file, "utf8");
62
+ }
63
+ catch {
64
+ return [];
65
+ }
66
+ return raw.split("\n").flatMap((l) => {
67
+ if (!l.trim())
68
+ return [];
69
+ try {
70
+ return [JSON.parse(l)];
71
+ }
72
+ catch {
73
+ return [];
74
+ }
75
+ });
76
+ }
77
+ /** Entradas de um repositório, ou de todos (`root` indefinido). */
78
+ export function readRoutes(root) {
79
+ if (root)
80
+ return parseLines(routeFile(root));
81
+ let files;
82
+ try {
83
+ files = fs.readdirSync(routeDir()).filter((f) => f.endsWith(".jsonl"));
84
+ }
85
+ catch {
86
+ return [];
87
+ }
88
+ return files.flatMap((f) => parseLines(path.join(routeDir(), f)));
89
+ }
90
+ // ------------------------------------------------------------------ modo sombra
91
+ /**
92
+ * Registra a decisão sombra de um turno já concluído. Roda DEPOIS do turno (não atrasa a resposta) e nunca lança.
93
+ * Turno sem texto ou interrompido (não finalizado) é ignorado: não há rótulo confiável.
94
+ */
95
+ const logged = new WeakSet();
96
+ export async function shadowRoute(root, turn, decide = decideRoute) {
97
+ try {
98
+ if (!routeEnabled() || !turn?.finished || logged.has(turn))
99
+ return null;
100
+ logged.add(turn);
101
+ const text = turnText(turn).replace(/\s+/g, " ").trim();
102
+ // Comandos do próprio REPL e mensagens vazias não são pedidos ao agente.
103
+ if (!text || text.startsWith("/"))
104
+ return null;
105
+ const d = await decide(text);
106
+ const uses = toolUses(turn);
107
+ const entry = {
108
+ ts: new Date().toISOString(), repo: path.basename(path.resolve(root)), text: text.slice(0, TEXT_MAX),
109
+ decision: d.route, source: d.source, ms: d.ms, implicit: implicitLabel(uses),
110
+ tools: [...new Set(uses.map((u) => u.name))].slice(0, 12), errors: turn.errors,
111
+ };
112
+ appendRoute(root, entry);
113
+ return entry;
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ }
@@ -0,0 +1,53 @@
1
+ const zero = (keys) => Object.fromEntries(keys.map((k) => [k, 0]));
2
+ const R = ["slm", "claude", "tool"];
3
+ const I = ["slm", "claude", "tool", "none"];
4
+ export function summarize(entries) {
5
+ const dec = entries.filter((e) => e.source === "qwen");
6
+ const s = {
7
+ total: entries.length, decided: dec.length, byDecision: zero(R), byImplicit: zero(I),
8
+ matrix: Object.fromEntries(R.map((r) => [r, zero(I)])),
9
+ risky: 0, wasted: 0, labeled: 0, hits: 0, comparable: 0, delegable: 0, msMedian: 0,
10
+ };
11
+ for (const e of dec) {
12
+ s.byDecision[e.decision]++;
13
+ s.byImplicit[e.implicit]++;
14
+ s.matrix[e.decision][e.implicit]++;
15
+ if (e.decision !== "claude")
16
+ s.delegable++;
17
+ if (e.implicit !== "none")
18
+ s.labeled++;
19
+ if (e.decision !== "claude" && e.implicit === "claude")
20
+ s.risky++;
21
+ if (e.decision === "claude" && e.implicit === "tool")
22
+ s.wasted++;
23
+ // `slm` não tem rótulo implícito (resumir texto dado não deixa rastro nas tools): fica fora da taxa de acerto
24
+ if (e.decision !== "slm" && e.implicit !== "none") {
25
+ s.comparable++;
26
+ if (e.decision === e.implicit)
27
+ s.hits++;
28
+ }
29
+ }
30
+ const ms = dec.map((e) => e.ms).sort((a, b) => a - b);
31
+ s.msMedian = ms.length ? ms[Math.floor(ms.length / 2)] : 0;
32
+ return s;
33
+ }
34
+ const pct = (a, b) => (b ? `${Math.round((a / b) * 100)}%` : "—");
35
+ /** Linhas do `/route`. */
36
+ export function renderRouteStats(entries, scope, enabled) {
37
+ const s = summarize(entries);
38
+ const head = `modo sombra ${enabled ? "ligado" : "desligado (BRITO_ROUTE=off)"} · ${scope}: nada aqui muda o que o Brito faz`;
39
+ if (!s.total)
40
+ return [head, " sem turnos registrados ainda"];
41
+ const out = [head, ` turnos registrados ${s.total} (decididos pelo SLM: ${s.decided}${s.total - s.decided ? `; sem SLM pronto: ${s.total - s.decided}` : ""})`];
42
+ if (!s.decided)
43
+ return [...out, " rode `brito slm pull` para o decisor entrar em ação"];
44
+ out.push(` teria mandado claude ${s.byDecision.claude} · tool ${s.byDecision.tool} · slm ${s.byDecision.slm} (delegaria ${pct(s.delegable, s.decided)})`);
45
+ out.push(` o turno exigiu claude ${s.byImplicit.claude} · só consulta ${s.byImplicit.tool} · sem tools ${s.byImplicit.none}`);
46
+ out.push(` erro caro ${s.risky} turno(s) que o decisor delegaria mas escreveram/planejaram/delegaram${s.decided - s.byDecision.claude ? ` (${pct(s.risky, s.delegable)} do que ele delegaria)` : ""}`);
47
+ out.push(` custo sem ganho ${s.wasted} turno(s) que foram ao Claude mas só consultaram`);
48
+ out.push(` acerto estimado ${pct(s.hits, s.comparable)} em ${s.comparable} turnos comparáveis (slm não tem rótulo implícito)`);
49
+ out.push(` latência mediana ${s.msMedian} ms por decisão`);
50
+ const need = 30 - s.decided;
51
+ out.push(need > 0 ? ` amostra pequena: faltam ~${need} decisões para a taxa ser confiável` : " amostra suficiente para avaliar se o decisor pode passar a agir");
52
+ return out;
53
+ }
package/dist/ui/ui.js CHANGED
@@ -475,6 +475,7 @@ class UI {
475
475
  [0, label("contexto") + c.muted(`${fmtTokens(s.ctxTokens)}/${fmtTokens(s.ctxWindow)}`) + ` [${bar}] ${colour(`${pct}%`)}`],
476
476
  ...(hit !== undefined ? [[2, label("cache") + hitColour(`◎ ${Math.round(hit)}%`)]] : []),
477
477
  [3, label("tokens") + c.muted(`↑${fmtTokens(s.input)} ↓${fmtTokens(s.output)}`)],
478
+ ...(s.slm ? [[3, c.light("💻 ") + label("slm") + c.muted(`${s.slm.calls}× ↑${fmtTokens(s.slm.tokensIn)} ↓${fmtTokens(s.slm.tokensOut)}`)]] : []),
478
479
  ...(s.cost !== undefined ? [[1, label("custo") + c.muted(`$${s.cost.toFixed(2)}`)]] : []),
479
480
  ...(this.activeSkill ? [[1, c.light("🎓 ") + label("skill") + c.bold(c.soft(this.activeSkill))]] : []),
480
481
  [4, label("tempo") + c.muted(mins < 1 ? "<1m" : `${mins}m`) + (active >= 1000 ? c.dim(` (ativo ${fmtDur(active)})`) : "")],
@@ -496,8 +497,9 @@ class UI {
496
497
  ["contexto", "tokens que o modelo está lendo agora / tamanho máximo da janela. Perto de 100% a conversa precisa ser compactada."],
497
498
  ["cache", "% do prompt reaproveitado do cache (◎). Alto (verde) = respostas mais rápidas e baratas. Some enquanto não há dado."],
498
499
  ["tokens", "total da sessão: ↑ enviados ao modelo (inclui cache) · ↓ gerados por ele."],
499
- ["custo", "gasto estimado da sessão em dólares."],
500
- ["motor", "de onde vêm as respostas: ☁ nuvem (Claude), 🌐 endpoint (ex.: qwen) ou 💻 local (SLM). ⚠ FALLBACK = troca automática ativa, com a hora em que o Claude é tentado de novo."],
500
+ ["custo", "gasto estimado da sessão em dólares (só o Claude; o SLM local roda de graça no seu computador)."],
501
+ ["slm", "💻 trabalho feito pelo modelo local: nº de chamadas × tokens lidos ↑ e gerados ↓ (estimativa). Não gasta plano nem dinheiro. Só aparece depois do primeiro uso."],
502
+ ["motor", "quem está respondendo: 💻 nativo = modelo embutido que roda na sua máquina (sem tokens, sem custo); ☁ Claude = nuvem, consome tokens; 🌐 endpoint = servidor seu (ex.: Qwen). ⚠ FALLBACK = troca automática ativa, com a hora em que o Claude é tentado de novo."],
501
503
  ["tempo", "há quanto tempo a sessão está aberta · (ativo) soma do tempo gasto em todos os turnos."],
502
504
  ["📁", "projeto (pasta) em que o Brito está trabalhando — também aparece no título da janela do terminal."],
503
505
  ["skill", "skill carregada (skill_load) no turno em andamento."],
@@ -1,4 +1,5 @@
1
1
  import { ui, c, fmtTokens } from "../ui/ui.js";
2
+ import { slmUsage } from "../local/stats.js";
2
3
  import { fetchPlanUsage, renderPlanLines } from "./plan.js";
3
4
  /** `/usage` do terminal: limites do plano (como o do Claude Code) + tokens/custo desta sessão. */
4
5
  export function usageCommand(session) {
@@ -15,6 +16,8 @@ export function usageCommand(session) {
15
16
  ui.print(` ${c.muted(`Plano: ${r.reason}`)}`);
16
17
  const s = session();
17
18
  ui.print(` ${c.soft("Sessão")} ${c.muted(`↑${fmtTokens(s.input)} ↓${fmtTokens(s.output)} · cache ${fmtTokens(s.cacheRead)}${s.costUsd ? ` · US$ ${s.costUsd.toFixed(2)}` : ""}`)}`);
19
+ const l = slmUsage();
20
+ ui.print(` ${c.soft("SLM local")} ${c.muted(l ? `${l.calls} chamadas · ↑${fmtTokens(l.tokensIn)} ↓${fmtTokens(l.tokensOut)} (estimado) · ${(l.ms / 1000).toFixed(1)}s · sem custo` : "não usado nesta sessão")}`);
18
21
  },
19
22
  };
20
23
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brito-cli",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "Coding-agent CLI with a code knowledge graph, memory and reviewable skills / CLI de agente de código com grafo de conhecimento, memória e skills revisáveis",
5
5
  "type": "module",
6
6
  "bin": {