brito-cli 0.11.0 → 0.13.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
@@ -278,6 +278,8 @@ With Claude as the main model (API or `claude` CLI), the `qwen` profile is the a
278
278
 
279
279
  `/orquestrar on` keeps Claude as the main model but hands the implementation to an endpoint profile (`qwen` by default). Claude writes the spec and the task list to `spec/<feature>/spec.md` and `spec/<feature>/tasks.md` (`spec_save` tool), waits for your approval, and only then sends one task at a time to the `implement` tool, checking off in `tasks.md` the ones that pass its review. It loses its file-editing tools (`write_file`/`edit_file`, or `Edit`/`Write`/`MultiEdit`/`NotebookEdit` in `claude` CLI mode): its only write is `spec_save`, which can only write those two files. It keeps reading, the graph and `bash` to check the work.
280
280
 
281
+ **Desktop app.** The selector next to the model chooses who writes the code: **Claude**, or the **local Qwen3-4B** (`brito slm pull` first; no tokens, but a 4B model is weaker on multi-step tasks, so Claude still reviews and the `verify` command runs). To use your own Qwen server, open **⚙ Configure → QWEN COM CHAVE**, fill in name, address, model and key, and click *Testar e salvar*: Brito tests the connection before saving, stores the key encrypted by the OS vault (never in `config.json`) and creates the `qwen` profile. Only `https://` addresses are accepted (`http://` for localhost and private networks). A `qwen` profile you wrote by hand is never overwritten.
282
+
281
283
  - The implementer runs in an isolated context (it does not see the conversation), with the graph of the symbols passed in `focus`. Its edits and commands follow the permission mode, and in `claude` CLI mode they are approved in the terminal just like Claude's.
282
284
  - When it finishes, the tool itself appends `git status --short`, `git diff --stat` and the output of the task's `verify` command, so Claude judges by what changed on disk, not by the implementer's report.
283
285
  - `/orquestrar` shows the state; `/orquestrar off` turns it off; `/orquestrar modelo <profile>` picks another implementer profile (also `BRITO_IMPL_MODEL` or `implModel` in the config). They combine: `/orquestrar on modelo qwen`. With `--global`, what you asked for is saved in the config: `/orquestrar on --global` turns the mode on every time Brito starts (`orchestrator: true`), and `/orquestrar off --global` undoes it. The status bar shows `⚙ →qwen` while it is on. In the desktop app, use the "Implementa" picker.
@@ -7,6 +7,7 @@ import { skillsIndex } from "../skills/store.js";
7
7
  import { MCP_NAME, MCP_PREFIX, APPROVE_TOOL } from "../mcp/server.js";
8
8
  import { ui as terminalUi } from "../ui/ui.js";
9
9
  import { MODE_ENV, perms } from "../permissions.js";
10
+ import { scrubbedEnv } from "../endpoint.js";
10
11
  import { CLAUDE_EDIT_TOOLS, ORCH_MCP_TIMEOUT, ORCH_NOTE, ORCH_NOTE_CLAUDE, orch, orchEnv } from "../orchestrator.js";
11
12
  import { DEFAULT_MODEL, wireEffort } from "../model.js";
12
13
  import { subagentModel } from "../engine.js";
@@ -260,7 +261,7 @@ export class ClaudeSession {
260
261
  const child = (this.child = spawn(cmd.command, cmd.args, {
261
262
  cwd: this.o.root, stdio: ["ignore", "pipe", "pipe"],
262
263
  // `/reasoning none`: o claude desliga o raciocínio com MAX_THINKING_TOKENS=0 (não há `--effort none`)
263
- env: { ...process.env, ...this.o.env, BRITO_DEPTH: String(Number(process.env.BRITO_DEPTH ?? 0) + 1),
264
+ env: { ...scrubbedEnv(), ...this.o.env, BRITO_DEPTH: String(Number(process.env.BRITO_DEPTH ?? 0) + 1),
264
265
  ...(orch.on && !process.env.MCP_TOOL_TIMEOUT ? { MCP_TOOL_TIMEOUT: ORCH_MCP_TIMEOUT } : {}), ...(wireEffort(this.effort).thinking ? {} : { MAX_THINKING_TOKENS: "0" }) },
265
266
  }));
266
267
  let buf = "", stderr = "", ok = true, interrupted = false;
@@ -29,9 +29,10 @@ import { indexProject } from "../graph/indexer.js";
29
29
  import { initialEffort, initialModel, isEffort, loadProfiles, MODEL_ALIASES } from "../model.js";
30
30
  import { NO_ANTHROPIC, fallbackStatus, learnerModel, openEngine } from "../engine.js";
31
31
  import { engineChip, activateFallback, chooseModel, cliShouldFallBack, fallbackWarning, newFallbackState, renderFallback, retryPrompt } from "../provider/fallback.js";
32
- import { contextWindowFor, resolveModel } from "../provider/resolve.js";
32
+ import { ENDPOINT_KEY_ENV, endpointInfo, removeEndpoint, saveEndpoint } from "../endpoint.js";
33
+ import { SLM_MODEL, contextWindowFor, resolveModel } from "../provider/resolve.js";
33
34
  import { PLAN_NOTE, perms } from "../permissions.js";
34
- import { ORCH_NOTE, implStatus, mainTools, orch, orchGuard, restoreOrch } from "../orchestrator.js";
35
+ import { ORCH_NOTE, implStatus, initialImplModel, mainTools, orch, orchGuard, restoreOrch } from "../orchestrator.js";
35
36
  import { getSkill, listBuiltins, listPending, listSkills, skillsIndex } from "../skills/store.js";
36
37
  import { defaultComplete, recordOutcomes, reflect, shouldReflect } from "../skills/learn.js";
37
38
  import { claudeCliComplete } from "../skills/learn-cli.js";
@@ -395,7 +396,10 @@ export async function serveDesktop(root, resumeId) {
395
396
  let busy = false;
396
397
  restoreOrch(engine.model, (m) => emit({ t: "warn", msg: m }), (m) => emit({ t: "info", msg: m }));
397
398
  const panels = new Panels(root, graphRootFor(root), isHome);
398
- const ready = () => emit({ t: "ready", engine: engine.kind, sessionId: engine.sessionId, model: engine.model, effort: engine.effort, mode: perms.mode, orch: orch.on, symbols: syms, notes, root, fallback: engine.fallback.active, chip: chipOf(engine.model, engine.fallback) });
399
+ // Perfil de endpoint (Qwen com chave): só aparece no seletor depois de configurado.
400
+ const endpointName = () => { const m = initialImplModel(); return m === SLM_MODEL ? "qwen" : m; };
401
+ const orchMode = () => (!orch.on ? "" : orch.model === SLM_MODEL ? "slm" : "endpoint");
402
+ const ready = () => emit({ t: "ready", engine: engine.kind, sessionId: engine.sessionId, model: engine.model, effort: engine.effort, mode: perms.mode, orch: orch.on, orchMode: orchMode(), endpointReady: implStatus(engine.model, endpointName(), process.env).ready, endpoint: endpointInfo(), symbols: syms, notes, root, fallback: engine.fallback.active, chip: chipOf(engine.model, engine.fallback) });
399
403
  ready();
400
404
  const rl = readline.createInterface({ input: process.stdin });
401
405
  const quit = async () => { closeApproval(); try {
@@ -517,16 +521,36 @@ export async function serveDesktop(root, resumeId) {
517
521
  engine.effort = isEffort(m.effort) ? m.effort : "";
518
522
  if (["manual", "auto", "plan", "bypass"].includes(m.mode))
519
523
  perms.mode = m.mode;
520
- // "on" liga o modo orquestrador (se o implementador estiver pronto); "" desliga.
524
+ // "" desliga; "slm" = Qwen3-4B nativo escreve o código; "endpoint" (ou "on", antigo) = perfil com chave.
521
525
  if (m.orch === "")
522
526
  orch.on = false;
523
- else if (m.orch === "on") {
524
- const s = implStatus(engine.model, orch.model, process.env);
525
- if (s.ready)
527
+ else if (m.orch === "slm" || m.orch === "endpoint" || m.orch === "on") {
528
+ const impl = m.orch === "slm" ? SLM_MODEL : endpointName();
529
+ const s = implStatus(engine.model, impl, process.env);
530
+ if (s.ready) {
531
+ orch.model = impl;
526
532
  orch.on = true;
527
- else
528
- sink.warn(`modo orquestrador não ligou: ${s.reason}`);
533
+ }
534
+ else {
535
+ orch.on = false;
536
+ sink.warn(`Não deu para usar ${m.orch === "slm" ? "o Qwen3-4B nativo" : "o Qwen com chave"} para escrever código: ${s.reason}. Quem escreve o código continua sendo o Claude (usa tokens).`);
537
+ }
538
+ }
539
+ ready();
540
+ break;
541
+ }
542
+ // Endpoint próprio (Qwen com chave). A chave chega pelo stdin, vem do processo principal do app e nunca é emitida de volta.
543
+ case "endpoint": {
544
+ if (m.op === "key") {
545
+ if (typeof m.key === "string" && m.key.trim())
546
+ process.env[ENDPOINT_KEY_ENV] = m.key.trim();
547
+ ready();
548
+ break;
529
549
  }
550
+ const r = m.op === "remove" ? removeEndpoint() : m.op === "save" ? await saveEndpoint({ baseURL: String(m.baseURL ?? ""), model: String(m.model ?? ""), label: String(m.label ?? ""), key: String(m.key ?? "") }) : { ok: false, msg: "operação desconhecida" };
551
+ if (m.op === "remove")
552
+ orchGuard(engine.model, (w) => sink.warn(w));
553
+ emit({ t: "endpointResult", ok: r.ok, msg: r.msg });
530
554
  ready();
531
555
  break;
532
556
  }
@@ -0,0 +1,113 @@
1
+ import { loadProfiles, updateConfig } from "./model.js";
2
+ // Configuração do endpoint próprio (Qwen com chave) feita pelo app. O perfil vai para `providers` no config SEM a
3
+ // chave; a chave mora só na memória do backend (variável abaixo) e, no app, cifrada pelo cofre do sistema.
4
+ /** Nome do perfil criado pelo app: é o que o modo orquestrador usa por padrão como implementador. */
5
+ export const ENDPOINT_NAME = "qwen";
6
+ /** Variável com a chave. Fica só na memória do backend e é removida do ambiente dos comandos do agente. */
7
+ export const ENDPOINT_KEY_ENV = "BRITO_ENDPOINT_KEY";
8
+ /** Ambiente para comandos que o agente roda (bash): sem a chave do endpoint, que o modelo não precisa ver. */
9
+ export function scrubbedEnv(env = process.env) {
10
+ const { [ENDPOINT_KEY_ENV]: _drop, ...rest } = env;
11
+ return rest;
12
+ }
13
+ /** localhost, loopback e redes privadas (10.x, 172.16–31.x, 192.168.x): onde `http://` não expõe a chave à internet. */
14
+ export function isPrivateHost(host) {
15
+ const h = host.toLowerCase().replace(/^\[|\]$/g, "");
16
+ if (h === "localhost" || h === "::1" || h.endsWith(".localhost"))
17
+ return true;
18
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h);
19
+ if (!m)
20
+ return false;
21
+ const [a, b] = [Number(m[1]), Number(m[2])];
22
+ if (m.slice(1).some((x) => Number(x) > 255))
23
+ return false;
24
+ return a === 127 || a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
25
+ }
26
+ /** Só `https://`; `http://` apenas em máquina local ou rede privada. Sem usuário/senha na URL. */
27
+ export function checkBaseURL(raw) {
28
+ let u;
29
+ try {
30
+ u = new URL(raw.trim());
31
+ }
32
+ catch {
33
+ return { ok: false, error: "endereço inválido: use algo como https://servidor/v1" };
34
+ }
35
+ if (u.protocol !== "https:" && u.protocol !== "http:")
36
+ return { ok: false, error: "o endereço precisa começar com https://" };
37
+ if (u.username || u.password)
38
+ return { ok: false, error: "não coloque usuário/senha no endereço: a chave vai no campo próprio" };
39
+ if (u.protocol === "http:" && !isPrivateHost(u.hostname))
40
+ return { ok: false, error: "http:// só é aceito em localhost ou rede privada; use https:// para endereços públicos" };
41
+ const url = (u.origin + u.pathname).replace(/\/+$/, "");
42
+ return { ok: true, url, host: u.host };
43
+ }
44
+ /** Uma chamada mínima (1 token) para provar endereço, modelo e chave antes de gravar qualquer coisa. Nunca devolve a chave. */
45
+ export async function testEndpoint(baseURL, model, key, fetchImpl = fetch) {
46
+ try {
47
+ const res = await fetchImpl(`${baseURL}/chat/completions`, {
48
+ method: "POST",
49
+ // sem seguir redirecionamento: a chave só vai para o endereço que você digitou
50
+ redirect: "manual",
51
+ headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
52
+ body: JSON.stringify({ model, messages: [{ role: "user", content: "ok" }], max_tokens: 1 }),
53
+ signal: AbortSignal.timeout(20_000),
54
+ });
55
+ await res.body?.cancel().catch(() => { });
56
+ if (res.ok)
57
+ return { ok: true, msg: "conexão ok" };
58
+ if (res.status === 401 || res.status === 403)
59
+ return { ok: false, msg: "o servidor recusou a chave (HTTP " + res.status + ")" };
60
+ if (res.status === 404)
61
+ return { ok: false, msg: "não achei o endpoint: confira o endereço (geralmente termina em /v1) e o nome do modelo" };
62
+ if (res.status >= 300 && res.status < 400)
63
+ return { ok: false, msg: "o endereço redireciona para outro lugar; use o endereço final" };
64
+ return { ok: false, msg: `o servidor respondeu HTTP ${res.status}` };
65
+ }
66
+ catch (e) {
67
+ return { ok: false, msg: `não consegui falar com o servidor: ${e?.cause?.code ?? e?.message ?? e}` };
68
+ }
69
+ }
70
+ /** Valida, testa e só então grava o perfil (sem a chave). A chave fica em `process.env` desta sessão. */
71
+ export async function saveEndpoint(f, opts = {}) {
72
+ const env = opts.env ?? process.env;
73
+ const model = f.model.trim(), key = f.key.trim(), label = f.label.trim() || "Qwen (endpoint)";
74
+ if (!model || !key || !f.baseURL.trim())
75
+ return { ok: false, msg: "preencha endereço, modelo e chave" };
76
+ const u = checkBaseURL(f.baseURL);
77
+ if (!u.ok)
78
+ return { ok: false, msg: u.error };
79
+ // não pisa num perfil `qwen` que o usuário escreveu à mão (com outra variável de chave)
80
+ const existing = loadProfiles().profiles[ENDPOINT_NAME];
81
+ if (existing && existing.apiKeyEnv !== ENDPOINT_KEY_ENV)
82
+ return { ok: false, msg: `já existe um perfil "${ENDPOINT_NAME}" no ~/.brito/config.json configurado à mão; edite-o por lá` };
83
+ const t = await testEndpoint(u.url, model, key, opts.fetchImpl);
84
+ if (!t.ok)
85
+ return t;
86
+ updateConfig((cfg) => {
87
+ cfg.providers = { ...(cfg.providers ?? {}), [ENDPOINT_NAME]: { type: "openai", baseURL: u.url, model, apiKeyEnv: ENDPOINT_KEY_ENV, label } };
88
+ });
89
+ env[ENDPOINT_KEY_ENV] = key;
90
+ return { ok: true, msg: `${label} · ${u.host} pronto` };
91
+ }
92
+ /** Remove o perfil criado pelo app (nunca um escrito à mão) e a chave da memória. */
93
+ export function removeEndpoint(env = process.env) {
94
+ const existing = loadProfiles().profiles[ENDPOINT_NAME];
95
+ if (existing && existing.apiKeyEnv === ENDPOINT_KEY_ENV) {
96
+ updateConfig((cfg) => { if (cfg.providers)
97
+ delete cfg.providers[ENDPOINT_NAME]; });
98
+ }
99
+ delete env[ENDPOINT_KEY_ENV];
100
+ return { ok: true, msg: "endpoint removido" };
101
+ }
102
+ /** Rótulo e host do endpoint configurado pelo app, para a tela mostrar (sem chave). */
103
+ export function endpointInfo() {
104
+ const p = loadProfiles().profiles[ENDPOINT_NAME];
105
+ if (!p || p.apiKeyEnv !== ENDPOINT_KEY_ENV)
106
+ return null;
107
+ try {
108
+ return { label: p.label, host: new URL(p.baseURL).host };
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ }
package/dist/model.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  /** Atalhos para os modelos atuais (o Hermes resolve aliases antes de validar o nome). */
@@ -19,16 +19,24 @@ export function loadSavedModel() {
19
19
  return undefined;
20
20
  }
21
21
  }
22
- /** Grava `patch` no config global preservando as demais chaves. */
23
- function saveConfig(patch) {
22
+ /**
23
+ * Altera o config global preservando as demais chaves. Grava num arquivo temporário e renomeia: uma queda no meio da
24
+ * escrita não deixa o config pela metade (ele guarda também modelo, esforço e perfis).
25
+ */
26
+ export function updateConfig(mutate) {
24
27
  let cfg = {};
25
28
  try {
26
29
  cfg = JSON.parse(readFileSync(configPath(), "utf8")) ?? {};
27
30
  }
28
31
  catch { /* config novo ou ilegível */ }
32
+ mutate(cfg);
29
33
  mkdirSync(dirname(configPath()), { recursive: true });
30
- writeFileSync(configPath(), JSON.stringify({ ...cfg, ...patch }, null, 2) + "\n");
34
+ const tmp = `${configPath()}.${process.pid}.tmp`;
35
+ writeFileSync(tmp, JSON.stringify(cfg, null, 2) + "\n");
36
+ renameSync(tmp, configPath());
31
37
  }
38
+ /** Grava `patch` no config global preservando as demais chaves. */
39
+ const saveConfig = (patch) => updateConfig((cfg) => Object.assign(cfg, patch));
32
40
  export const saveModel = (model) => saveConfig({ model });
33
41
  /** Perfil que implementa as tasks no modo orquestrador (`implModel` no config). */
34
42
  export function loadSavedImplModel() {
@@ -1,7 +1,9 @@
1
1
  import { allTools } from "./tools/registry.js";
2
2
  import { loadProfiles, loadSavedImplModel, loadSavedOrch } from "./model.js";
3
3
  import { missingKeyMessage } from "./provider/openai-compat.js";
4
- import { resolveModel } from "./provider/resolve.js";
4
+ import { resolveModel, SLM_MODEL } from "./provider/resolve.js";
5
+ import { slmReadySync } from "./local/slm.js";
6
+ import { ENDPOINT_KEY_ENV } from "./endpoint.js";
5
7
  // Modo orquestrador: o modelo principal (Claude) planeja e revisa; um perfil de endpoint (o qwen) implementa cada task
6
8
  // pela tool `implement`. Estado global da sessão, como o `perms`.
7
9
  /** Prioridade: BRITO_IMPL_MODEL > config global (`implModel`) > qwen. */
@@ -34,6 +36,9 @@ export function implStatus(main, impl, env) {
34
36
  return { ready: false, reason: `o perfil "${name}" em ~/.brito/config.json está inválido (${why})` };
35
37
  }
36
38
  const p = profiles[name];
39
+ // SLM nativo do Brito (Qwen3-4B): roda na máquina, não tem chave; basta estar baixado.
40
+ if (!p && name === SLM_MODEL)
41
+ return slmReadySync() ? { ready: true, label: "Qwen3-4B (nativo)" } : { ready: false, reason: "o SLM nativo não está pronto: rode `brito slm pull`" };
37
42
  if (!p || resolveModel(name).kind !== "openai")
38
43
  return { ready: false, reason: `sem perfil "${name}" em providers no ~/.brito/config.json` };
39
44
  if (!env[p.apiKeyEnv]?.trim())
@@ -71,7 +76,13 @@ export function restoreOrch(main, warn, info) {
71
76
  /** O servidor MCP roda em outro processo (subido pelo claude CLI a cada turno): o modo e o perfil chegam pelo env. */
72
77
  export const ORCH_ENV = "BRITO_ORCH";
73
78
  /** Env do servidor MCP: liga a tool `implement` lá e diz qual perfil implementa. */
74
- export const orchEnv = () => (orch.on ? { [ORCH_ENV]: "1", BRITO_IMPL_MODEL: orch.model } : {});
79
+ export const orchEnv = () => {
80
+ if (!orch.on)
81
+ return {};
82
+ // a chave do endpoint vai só ao servidor MCP (que roda o implementador), não ao ambiente do claude e dos comandos dele
83
+ const key = process.env[ENDPOINT_KEY_ENV];
84
+ return { [ORCH_ENV]: "1", BRITO_IMPL_MODEL: orch.model, ...(key ? { [ENDPOINT_KEY_ENV]: key } : {}) };
85
+ };
75
86
  /** Uma task do implementador leva minutos: o timeout padrão do claude CLI para tools MCP cortaria no meio (30 min). */
76
87
  export const ORCH_MCP_TIMEOUT = "1800000";
77
88
  /** Tools nativas do claude CLI que editam arquivos: bloqueadas com o modo ligado. */
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { exec } from "node:child_process";
3
3
  import { defineTool } from "./registry.js";
4
4
  import { shellFor } from "../util/platform.js";
5
+ import { scrubbedEnv } from "../endpoint.js";
5
6
  defineTool({
6
7
  name: "bash",
7
8
  description: "Executa um comando shell na raiz do projeto (timeout padrão 120s).",
@@ -10,7 +11,7 @@ defineTool({
10
11
  mutates: true,
11
12
  describe: (i) => i.command,
12
13
  run: ({ command, timeout_ms = 120_000 }, { root }) => new Promise((resolve) => {
13
- exec(command, { cwd: root, shell: shellFor(), timeout: timeout_ms, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
14
+ exec(command, { cwd: root, shell: shellFor(), env: scrubbedEnv(), timeout: timeout_ms, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
14
15
  const out = (stdout + (stderr ? `\n[stderr]\n${stderr}` : "")).trim();
15
16
  resolve(`${err ? `[exit ${err.code ?? "?"}] ` : ""}${out.slice(-30_000) || "(sem saída)"}`);
16
17
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brito-cli",
3
- "version": "0.11.0",
3
+ "version": "0.13.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": {