brito-cli 0.7.5 → 0.8.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.
@@ -3,11 +3,22 @@ import net from "node:net";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { describeClaudeTool, perms } from "../permissions.js";
6
+ import { decide, loadRules } from "../rules.js";
6
7
  /**
7
8
  * Aprovações do claude CLI: o claude chama a tool MCP `approve` (--permission-prompt-tool), que roda no
8
9
  * processo do servidor MCP e consulta ESTE processo por um socket unix, onde está o terminal (ou a janela do app).
9
10
  */
10
- export function startApprovalServer(io) {
11
+ /** Traduz uma tool do claude CLI para o nome e o input das tools do Brito e decide pelas regras de permissão. */
12
+ export function claudeDecision(root, tool, input) {
13
+ const file = typeof input?.file_path === "string" ? input.file_path : undefined;
14
+ const mapped = tool === "Bash" ? { name: "bash", input: { command: input?.command } } :
15
+ (tool === "Edit" || tool === "MultiEdit") && file ? { name: "edit_file", input: { path: file } } :
16
+ tool === "Write" && file ? { name: "write_file", input: { path: file } } :
17
+ { name: tool, input };
18
+ return decide(loadRules(root), mapped.name, mapped.input, root);
19
+ }
20
+ /** `root` liga as regras de permissão do projeto; sem ele, toda chamada é perguntada como antes. */
21
+ export function startApprovalServer(io, root) {
11
22
  // No Windows um socket unix em %TEMP% não é aceito por net.listen: o endereço tem que ser um named pipe.
12
23
  const sock = process.platform === "win32" ? `\\\\.\\pipe\\brito-approve-${process.pid}` : path.join(os.tmpdir(), `brito-approve-${process.pid}.sock`);
13
24
  try {
@@ -25,7 +36,13 @@ export function startApprovalServer(io) {
25
36
  try {
26
37
  const req = JSON.parse(buf.slice(0, nl));
27
38
  const plan = req.tool_name === "ExitPlanMode";
28
- const ok = await io.ask(describeClaudeTool(req.tool_name, req.input), { force: plan });
39
+ // As regras do Brito valem também para as tools do claude: deny bloqueia, allow dispensa a pergunta.
40
+ const decision = root && !plan ? claudeDecision(root, req.tool_name, req.input) : "ask";
41
+ if (decision === "deny") {
42
+ conn.end(JSON.stringify({ allow: false, message: "Bloqueado por uma regra deny das permissões do Brito (.brito/settings.json). Não tente contornar." }) + "\n");
43
+ return;
44
+ }
45
+ const ok = decision === "allow" || await io.ask(describeClaudeTool(req.tool_name, req.input), { force: plan });
29
46
  if (ok && plan)
30
47
  perms.mode = "manual";
31
48
  reply = ok ? { allow: true } : { allow: false, message: plan ? "Plano não aprovado pelo usuário. Pergunte o que ajustar." : "O usuário negou a execução." };
@@ -212,7 +212,7 @@ export class ClaudeSession {
212
212
  args(text, imageDirs = []) {
213
213
  const { root, approvalSock, stats, mcpCommand } = this.o;
214
214
  const mcp = mcpConfig(mcpCommand, approvalSock, perms.mode, this.o.env);
215
- const readOnlyMcp = ["graph_search", "graph_callers", "graph_callees", "graph_impact", "graph_context", "graph_query", "graph_index", "remember", "recall", "skill_load"];
215
+ const readOnlyMcp = ["graph_search", "graph_callers", "graph_callees", "graph_impact", "graph_context", "graph_query", "graph_index", "remember", "recall", "skill_load", "digest"];
216
216
  const system = buildSystemPrompt(root, stats, skillsIndex(root)) +
217
217
  `\n\n# Nesta sessão\nAs tools do Brito chegam com o prefixo \`${MCP_PREFIX}\` (ex.: ${MCP_PREFIX}graph_context). Use-as como descrito acima. ` +
218
218
  `read_file/grep/glob/edit_file/bash correspondem a Read/Grep/Glob/Edit/Bash, e \`delegate\` corresponde à tool Agent/Task com os subagentes configurados (explore, plan, general e os do projeto).`;
@@ -5,7 +5,7 @@ import { initialEffort, initialModel } from "../model.js";
5
5
  import { providerFor } from "../provider/index.js";
6
6
  import { compactAtFor, resolveModel } from "../provider/resolve.js";
7
7
  import { fallbackStatus } from "../engine.js";
8
- import { activateFallback, classifyAnthropicError, fallbackWarning, shortError, triggersFallback } from "../provider/fallback.js";
8
+ import { activateFallback, classifyAnthropicError, fallbackExpired, fallbackUntil, fallbackWarning, returnToPrimary, shortError, triggersFallback } from "../provider/fallback.js";
9
9
  import { fmtBytes, findImages, imageBlock, splitOversize } from "../util/images.js";
10
10
  export const MODEL = initialModel();
11
11
  export class Agent {
@@ -80,6 +80,7 @@ export class Agent {
80
80
  }
81
81
  else
82
82
  push({ role: "user", content: userText });
83
+ this.retryPrimaryIfExpired();
83
84
  await this.compactIfNeeded();
84
85
  let finalText = "";
85
86
  for (let step = 0; step < this.maxSteps; step++) {
@@ -147,31 +148,53 @@ export class Agent {
147
148
  try {
148
149
  return await providerFor(this.model).send({
149
150
  model: this.model,
150
- maxTokens: target.kind === "openai" && thinking ? 16_384 : 8192,
151
+ // Na Anthropic a resposta vem por streaming, então o teto pode ser alto sem risco de timeout: um
152
+ // write_file grande não é mais cortado no meio.
153
+ maxTokens: target.kind === "openai" ? (thinking ? 16_384 : 8192) : 32_000,
151
154
  system: typeof this.system === "function" ? this.system() : this.system,
152
155
  tools: toApiTools(this.opts.tools),
153
156
  messages: this.messages,
154
157
  thinking,
158
+ effort: this.effort,
155
159
  }, hooks);
156
160
  }
157
161
  catch (e) {
158
162
  const fb = this.opts.fallback;
159
- if (!fb || fb.active || hooks.signal?.aborted || target.kind !== "anthropic")
163
+ if (!fb || hooks.signal?.aborted)
160
164
  throw e;
161
- const kind = classifyAnthropicError(e);
165
+ // Claude → qwen → SLM: com o fallback ativo só desce um degrau (qwen que falha cai no SLM), nunca o SLM.
166
+ const onQwen = fb.active && target.kind === "openai" && !target.local && target.name === "qwen";
167
+ if (fb.active ? !onQwen : target.kind !== "anthropic" || target.local)
168
+ throw e;
169
+ if (onQwen && target.kind !== "openai")
170
+ throw e;
171
+ const kind = onQwen ? "network" : classifyAnthropicError(e);
162
172
  if (!triggersFallback(kind))
163
173
  throw e;
164
- const status = fallbackStatus(this.model, process.env);
174
+ const status = fallbackStatus(fb.primary, process.env, onQwen ? ["qwen"] : []);
165
175
  if (!status.ready)
166
176
  throw e;
167
177
  const why = shortError(e);
168
- this.ui.warn(fallbackWarning(kind, why, status.label));
169
- activateFallback(fb, `${kind} (${why})`);
178
+ const until = onQwen ? fb.until : fallbackUntil(kind, why);
179
+ this.ui.warn(fallbackWarning(kind, why, status.label, until));
180
+ activateFallback(fb, `${kind} (${why})`, Date.now(), until, status.name);
170
181
  this.model = status.name;
171
182
  await this.compactIfNeeded();
172
183
  return this.callModel(hooks);
173
184
  }
174
185
  }
186
+ /**
187
+ * Fallback temporário: passada a janela, o próximo turno tenta o Claude de novo (se falhar, `callModel` reativa o
188
+ * fallback com uma janela nova). Só com ANTHROPIC_API_KEY: sem ela o Claude não roda neste loop (volta via /model).
189
+ */
190
+ retryPrimaryIfExpired() {
191
+ const fb = this.opts.fallback;
192
+ if (!fb || !fallbackExpired(fb) || !process.env.ANTHROPIC_API_KEY?.trim() || resolveModel(fb.primary).kind !== "anthropic")
193
+ return;
194
+ returnToPrimary(fb);
195
+ this.model = fb.primary;
196
+ this.ui.info(`voltando ao Claude (${fb.primary}) para testar se o limite acabou`);
197
+ }
175
198
  async compactIfNeeded() {
176
199
  if (estimateTokens(this.messages) < compactAtFor(this.model))
177
200
  return;
@@ -23,33 +23,59 @@ export function buildSystemPrompt(root, graphStats, skills = "") {
23
23
  break;
24
24
  }
25
25
  }
26
- return `Você é Brito, um agente de engenharia de software que trabalha em CLI. Responda em português do Brasil, de forma direta.
27
-
28
- # Como trabalhar
29
- - Você tem um GRAFO DE CONHECIMENTO do projeto (símbolos, chamadas, imports) e uma MEMÓRIA em grafo (notas). ${graphStats}
30
- - Para entender código: comece por graph_search / graph_context / graph_callers / graph_callees / graph_impact. Só leia arquivos (read_file) nas linhas que o grafo apontou. Use grep apenas para texto que não é símbolo.
31
- - Antes de alterar uma função ou arquivo, rode graph_callers / graph_impact e considere o que pode quebrar.
32
- - Após editar muitos arquivos, rode graph_index para atualizar o grafo.
33
- - Ao tomar uma decisão não óbvia, descobrir um bug ou uma convenção, grave com remember (com refs para arquivos/símbolos). Consulte recall no início de tarefas relevantes.
34
- - Edições e comandos shell pedem aprovação do usuário. Prefira edit_file a write_file. Verifique seu trabalho (typecheck/testes) via bash quando existir.
35
- - Para investigações amplas ou sub-tarefas grandes, use delegate: o subagente trabalha em contexto isolado e devolve só o resultado. Vários delegate somente-leitura (explore/plan) no mesmo turno rodam em paralelo. Passe \`focus\` com os símbolos relevantes. Não delegue o que resolve com 1–2 chamadas de graph_*.
36
- - SKILLS: procedimentos reutilizáveis aprendidos neste projeto. Se alguma se aplica à tarefa, chame skill_load ANTES de começar e siga-a. Se ela estiver errada ou incompleta, corrija com skill_save. Ao terminar tarefas complexas, o sistema pode propor novas skills; não crie skill para fatos de uma tarefa única.
37
- - Seja conciso. Não repita conteúdo de arquivos na resposta.
38
-
39
- # Ambiente
40
- Diretório do projeto: ${root}
26
+ return `Você é Brito, um agente de engenharia de software que trabalha em CLI. Responda em português do Brasil, de forma direta.
27
+
28
+ # Como trabalhar
29
+ - Você tem um GRAFO DE CONHECIMENTO do projeto (símbolos, chamadas, imports) e uma MEMÓRIA em grafo (notas). ${graphStats}
30
+ - Para entender código: comece por graph_search / graph_context / graph_callers / graph_callees / graph_impact. Só leia arquivos (read_file) nas linhas que o grafo apontou. Use grep apenas para texto que não é símbolo.
31
+ - Antes de alterar uma função ou arquivo, rode graph_callers / graph_impact e considere o que pode quebrar.
32
+ - Após editar muitos arquivos, rode graph_index para atualizar o grafo.
33
+ - Ao tomar uma decisão não óbvia, descobrir um bug ou uma convenção, grave com remember (com refs para arquivos/símbolos). Consulte recall no início de tarefas relevantes.
34
+ - Edições e comandos shell pedem aprovação do usuário. Prefira edit_file a write_file. Verifique seu trabalho (typecheck/testes) via bash quando existir.
35
+ - Para investigações amplas ou sub-tarefas grandes, use delegate: o subagente trabalha em contexto isolado e devolve só o resultado. Vários delegate somente-leitura (explore/plan) no mesmo turno rodam em paralelo. Passe \`focus\` com os símbolos relevantes. Não delegue o que resolve com 1–2 chamadas de graph_*.
36
+ - SKILLS: procedimentos reutilizáveis aprendidos neste projeto. Se alguma se aplica à tarefa, chame skill_load ANTES de começar e siga-a. Se ela estiver errada ou incompleta, corrija com skill_save. Ao terminar tarefas complexas, o sistema pode propor novas skills; não crie skill para fatos de uma tarefa única.
37
+ - Em tarefas com 3+ passos, mantenha a lista com todo_write: um item in_progress por vez, marcado completed assim que terminar. Leia um arquivo (read_file) antes de editá-lo; se ele mudou desde a leitura, leia de novo.
38
+ - Seja conciso. Não repita conteúdo de arquivos na resposta.
39
+
40
+ # Ambiente
41
+ Diretório do projeto: ${root}
41
42
  Data: ${new Date().toISOString().slice(0, 10)}${shellNote()}${skills ? `\n\n# Skills disponíveis (use skill_load)\n${skills}` : ""}${projectDoc}`;
42
43
  }
43
- import { skillsIndex } from "../skills/store.js";
44
+ import { listSkills, skillsIndex } from "../skills/store.js";
45
+ /**
46
+ * Repete o system prompt anterior enquanto `key()` não muda. O cache de prompt da API é um prefixo: qualquer byte
47
+ * diferente no system (a contagem de símbolos após um sync, a ordem das skills por uso) invalidaria o cache da
48
+ * conversa inteira a cada passo. A chave lista só o que muda o que o modelo deve fazer.
49
+ */
50
+ export function memoSystem(key, build) {
51
+ let last;
52
+ let value = "";
53
+ return () => {
54
+ const k = key();
55
+ if (k !== last) {
56
+ last = k;
57
+ value = build();
58
+ }
59
+ return value;
60
+ };
61
+ }
62
+ /**
63
+ * Chave do `memoSystem` da sessão principal: modo plano, grafo vazio ou não (o aviso de "grafo vazio" muda a
64
+ * orientação) e o conjunto de skills com o status. Contagens e ordem por uso ficam de fora de propósito.
65
+ */
66
+ export function systemKey(root, planMode, graphEmpty) {
67
+ const skills = listSkills(root).map((s) => `${s.name}:${s.scope}:${s.status}`).sort().join(",");
68
+ return `${planMode}|${graphEmpty}|${skills}`;
69
+ }
44
70
  export function buildSubagentPrompt(root, def) {
45
- return `${def.prompt}
46
-
47
- # Regras de subagente
48
- - Você foi chamado por outro agente e NÃO vê a conversa dele: tudo que precisa está na tarefa.
49
- - Use o grafo antes de ler arquivos: graph_search, graph_context, graph_callers, graph_callees, graph_impact. Leia só as linhas que ele apontar.
50
- - Sua última mensagem é a única coisa que o chamador recebe. Seja objetivo e use caminhos \`arquivo:linha\`; sem preâmbulo.
51
-
52
- # Ambiente
53
- Diretório do projeto: ${root}
71
+ return `${def.prompt}
72
+
73
+ # Regras de subagente
74
+ - Você foi chamado por outro agente e NÃO vê a conversa dele: tudo que precisa está na tarefa.
75
+ - Use o grafo antes de ler arquivos: graph_search, graph_context, graph_callers, graph_callees, graph_impact. Leia só as linhas que ele apontar.
76
+ - Sua última mensagem é a única coisa que o chamador recebe. Seja objetivo e use caminhos \`arquivo:linha\`; sem preâmbulo.
77
+
78
+ # Ambiente
79
+ Diretório do projeto: ${root}
54
80
  Data: ${new Date().toISOString().slice(0, 10)}${skillsIndex(root) ? `\n\n# Skills disponíveis (use skill_load)\n${skillsIndex(root)}` : ""}`;
55
81
  }
@@ -0,0 +1,98 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ const MAX_SESSIONS = 50;
4
+ const ID = /^[\w-]{1,80}$/;
5
+ export const sessionsDir = (root) => path.join(root, ".brito", "sessions");
6
+ const fileOf = (root, id) => {
7
+ if (!ID.test(id))
8
+ throw new Error(`id de sessão inválido: ${id}`);
9
+ return path.join(sessionsDir(root), `${id}.json`);
10
+ };
11
+ const stripImages = (msgs) => msgs.map((m) => typeof m.content === "string" ? m : {
12
+ ...m,
13
+ content: m.content.map((b) => (b.type === "image" || b.type === "document" ? { type: "text", text: "[anexo omitido ao salvar a sessão]" } : b)),
14
+ });
15
+ export function titleOf(msgs, fallback = "") {
16
+ const first = msgs?.find((m) => m.role === "user");
17
+ const text = typeof first?.content === "string" ? first.content : first?.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join(" ") ?? fallback;
18
+ return (text || fallback).replace(/\s+/g, " ").trim().slice(0, 80);
19
+ }
20
+ /** Grava (ou atualiza) a sessão. Nunca derruba o turno: falha de disco vira aviso de quem chamou. */
21
+ export function saveSession(root, s, now = new Date()) {
22
+ const dir = sessionsDir(root);
23
+ fs.mkdirSync(dir, { recursive: true });
24
+ // Conversas são pessoais e podem citar dados sensíveis: nunca vão para o git por acidente.
25
+ try {
26
+ fs.writeFileSync(path.join(dir, ".gitignore"), "*\n", { flag: "wx" });
27
+ }
28
+ catch (e) {
29
+ if (e.code !== "EEXIST")
30
+ throw e;
31
+ }
32
+ const prev = loadSession(root, s.id);
33
+ const out = {
34
+ v: 1, ...s,
35
+ ...(s.messages ? { messages: stripImages(s.messages) } : {}),
36
+ createdAt: s.createdAt ?? prev?.createdAt ?? now.toISOString(),
37
+ updatedAt: now.toISOString(),
38
+ };
39
+ const file = fileOf(root, s.id);
40
+ const tmp = `${file}.tmp`;
41
+ fs.writeFileSync(tmp, JSON.stringify(out));
42
+ fs.renameSync(tmp, file); // atômico: um Ctrl+C no meio não deixa JSON cortado
43
+ prune(root);
44
+ }
45
+ export function loadSession(root, id) {
46
+ try {
47
+ const s = JSON.parse(fs.readFileSync(fileOf(root, id), "utf8"));
48
+ return s?.v === 1 && typeof s.id === "string" ? s : null;
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ /** Mais recentes primeiro. */
55
+ export function listSessions(root) {
56
+ let names;
57
+ try {
58
+ names = fs.readdirSync(sessionsDir(root)).filter((n) => n.endsWith(".json"));
59
+ }
60
+ catch {
61
+ return [];
62
+ }
63
+ return names
64
+ .flatMap((n) => { const s = loadSession(root, n.slice(0, -5)); return s ? [s] : []; })
65
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
66
+ }
67
+ export const latestSession = (root, engine) => listSessions(root).find((s) => !engine || s.engine === engine) ?? null;
68
+ /** Mantém as `MAX_SESSIONS` mais recentes, pela data do arquivo (sem abrir históricos que podem ter megabytes). */
69
+ function prune(root) {
70
+ const dir = sessionsDir(root);
71
+ const files = fs.readdirSync(dir).filter((n) => n.endsWith(".json")).map((n) => {
72
+ try {
73
+ return { n, t: fs.statSync(path.join(dir, n)).mtimeMs };
74
+ }
75
+ catch {
76
+ return { n, t: 0 };
77
+ }
78
+ }).sort((a, b) => b.t - a.t);
79
+ for (const f of files.slice(MAX_SESSIONS)) {
80
+ try {
81
+ fs.unlinkSync(path.join(dir, f.n));
82
+ }
83
+ catch { /* já removida */ }
84
+ }
85
+ }
86
+ /**
87
+ * Histórico salvo pronto para reenviar: se o processo caiu no meio de um turno, o último `tool_use` pode ter ficado sem
88
+ * `tool_result`, e a API recusaria a conversa. Fecha cada um com erro, como no Ctrl+C.
89
+ */
90
+ export function repairHistory(msgs) {
91
+ const last = msgs[msgs.length - 1];
92
+ if (last?.role !== "assistant" || typeof last.content === "string")
93
+ return msgs;
94
+ const pending = last.content.filter((b) => b.type === "tool_use");
95
+ if (!pending.length)
96
+ return msgs;
97
+ return [...msgs, { role: "user", content: pending.map((u) => ({ type: "tool_result", tool_use_id: u.id, content: "Sessão interrompida antes desta tool terminar.", is_error: true })) }];
98
+ }