brito-cli 0.7.4 → 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.
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { parseFrontmatter, stringifyFrontmatter } from "../util/frontmatter.js";
5
+ import { BUILTIN_SKILLS } from "./builtin.js";
5
6
  export const NAME_RE = /^[a-z0-9][a-z0-9-]{1,48}$/;
6
7
  const today = () => new Date().toISOString().slice(0, 10);
7
8
  /** Skills globais valem para todos os projetos. `BRITO_HOME` troca o local (útil em testes). */
@@ -28,12 +29,15 @@ function parse(text, scope) {
28
29
  losses: Number(meta.losses) || 0,
29
30
  body: body.trim(),
30
31
  scope,
32
+ invoke: meta.invoke === "manual" ? "manual" : "auto",
33
+ model: meta.model ?? "",
34
+ lastLoss: meta.lastLoss ?? "",
31
35
  };
32
36
  }
33
37
  export function writeSkill(root, s) {
34
38
  const file = fileOf(root, s.scope, s.name);
35
39
  fs.mkdirSync(path.dirname(file), { recursive: true });
36
- fs.writeFileSync(file, stringifyFrontmatter({ name: s.name, description: s.description, version: s.version, uses: s.uses, created: s.created, updated: s.updated, lastUsed: s.lastUsed, refs: s.refs.join(", "), status: s.status, origin: s.origin, wins: s.wins, losses: s.losses }, s.body));
40
+ fs.writeFileSync(file, stringifyFrontmatter({ name: s.name, description: s.description, version: s.version, uses: s.uses, created: s.created, updated: s.updated, lastUsed: s.lastUsed, refs: s.refs.join(", "), status: s.status, origin: s.origin, wins: s.wins, losses: s.losses, ...(s.invoke === "manual" ? { invoke: "manual" } : {}), ...(s.model ? { model: s.model } : {}), ...(s.lastLoss ? { lastLoss: s.lastLoss } : {}) }, s.body));
37
41
  }
38
42
  /** Lê uma skill de um escopo específico. */
39
43
  export function getSkillIn(root, name, scope) {
@@ -48,7 +52,19 @@ export function getSkillIn(root, name, scope) {
48
52
  }
49
53
  /** Resolve por nome: a skill do projeto sobrepõe a global de mesmo nome. */
50
54
  export function getSkill(root, name) {
51
- return getSkillIn(root, name, "project") ?? getSkillIn(root, name, "global");
55
+ return getSkillIn(root, name, "project") ?? getSkillIn(root, name, "global") ?? getBuiltin(name);
56
+ }
57
+ /** Skills embutidas: disponíveis em qualquer projeto sem gravar nada em disco. */
58
+ export function getBuiltin(name) {
59
+ const b = BUILTIN_SKILLS.find((x) => x.name === name);
60
+ if (!b)
61
+ return null;
62
+ const d = "2026-11-07";
63
+ return { name: b.name, description: b.description, version: 1, uses: 0, created: d, updated: d, lastUsed: d, refs: [], status: "active", origin: "manual", wins: 0, losses: 0, body: b.body, scope: "global", invoke: "manual", model: b.model ?? "", lastLoss: "", builtin: true };
64
+ }
65
+ /** Embutidas ainda não sobrepostas por uma skill do usuário. */
66
+ export function listBuiltins(root) {
67
+ return BUILTIN_SKILLS.flatMap((b) => (getSkillIn(root, b.name, "project") ?? getSkillIn(root, b.name, "global") ? [] : [getBuiltin(b.name)]));
52
68
  }
53
69
  function listScope(root, scope) {
54
70
  const dir = dirOf(root, scope);
@@ -118,6 +134,17 @@ export function lintSkill(input) {
118
134
  const destructive = DESTRUCTIVE_RULES.filter((r) => r.re.test(text)).map((r) => `comando destrutivo (${r.msg}): só mantenha se houver guarda (backup, cópia/ambiente de teste ou confirmação)`);
119
135
  return { errors, warnings: [...urls, ...destructive] };
120
136
  }
137
+ /** Limite suave: skills longas pioram a qualidade do agente ("dumb zone"); acima disso, sugerimos dividir. */
138
+ export const SOFT_BODY_LIMIT = 3000;
139
+ /** Dicas de qualidade (não bloqueiam nem entram em `lintSkill`): tamanho e critério de término/verificação. */
140
+ export function adviseSkill(input) {
141
+ const tips = [];
142
+ if (input.body.length > SOFT_BODY_LIMIT)
143
+ tips.push(`body com ${input.body.length} chars (> ${SOFT_BODY_LIMIT}): considere dividir em skills menores`);
144
+ if (!/verifica|crit[ée]rio de (t[ée]rmino|sa[ií]da|pronto)|done when|pronto quando/i.test(input.body))
145
+ tips.push("sem seção '## Verificação' ou critério de término: diga como saber que acabou");
146
+ return tips;
147
+ }
121
148
  export function validate(input) {
122
149
  if (!NAME_RE.test(input.name))
123
150
  throw new Error("name deve ser kebab-case (a-z, 0-9, hífen), 2–49 chars.");
@@ -164,6 +191,9 @@ export function saveSkill(root, input, scope, opts = {}) {
164
191
  losses: opts.auto && prev?.status === "trial" ? 0 : prev?.losses ?? 0,
165
192
  body: input.body.trim(),
166
193
  scope: target,
194
+ invoke: input.invoke ?? prev?.invoke ?? "auto",
195
+ model: input.model ?? prev?.model ?? "",
196
+ lastLoss: prev?.lastLoss ?? "",
167
197
  };
168
198
  writeSkill(root, skill);
169
199
  return { created: !prev, version: skill.version, scope: target };
@@ -215,13 +245,13 @@ export function rejectPending(root, name) {
215
245
  }
216
246
  export function bumpUses(root, name) {
217
247
  const s = getSkill(root, name);
218
- if (s)
248
+ if (s && !s.builtin)
219
249
  writeSkill(root, { ...s, uses: s.uses + 1, lastUsed: today() });
220
250
  }
221
251
  /** Move para `.trash` do próprio escopo em vez de apagar. */
222
252
  export function deleteSkill(root, name, scope) {
223
253
  const s = scope ? getSkillIn(root, name, scope) : getSkill(root, name);
224
- if (!s)
254
+ if (!s || s.builtin)
225
255
  return false;
226
256
  const base = dirOf(root, s.scope);
227
257
  fs.mkdirSync(path.join(base, ".trash"), { recursive: true });
@@ -239,8 +269,34 @@ export function promoteSkill(root, name) {
239
269
  fs.mkdirSync(path.dirname(dest), { recursive: true });
240
270
  fs.renameSync(path.join(dirOf(root, "project"), name), dest);
241
271
  }
242
- /** Índice compacto para o system prompt (divulgação progressiva: o corpo só entra via skill_load). */
243
- export function skillsIndex(root, max = 40, now = Date.now()) {
244
- const all = listSkills(root, now).slice(0, max);
245
- return all.length ? all.map((s) => `- ${s.name}${s.scope === "global" ? " (global)" : ""}${s.status === "trial" ? " (em teste)" : ""}${staleRefs(root, s).length ? " [refs obsoletas]" : ""}: ${s.description}`).join("\n") : "";
272
+ /** Quantas skills vão para o índice do system prompt; o resto é achado por `skill_load` (sugestões) ou `/skills suggest`. */
273
+ export const INDEX_MAX = 25;
274
+ /** Índice compacto para o system prompt (divulgação progressiva: o corpo só entra via skill_load). Skills `manual` ficam de fora. */
275
+ export function skillsIndex(root, max = INDEX_MAX, now = Date.now()) {
276
+ const eligible = listSkills(root, now).filter((s) => s.invoke !== "manual");
277
+ const all = eligible.slice(0, max);
278
+ if (!all.length)
279
+ return "";
280
+ const rest = eligible.length - all.length;
281
+ return all.map((s) => `- ${s.name}${s.scope === "global" ? " (global)" : ""}${s.status === "trial" ? " (em teste)" : ""}${staleRefs(root, s).length ? " [refs obsoletas]" : ""}: ${s.description}`).join("\n")
282
+ + (rest > 0 ? `\n(+${rest} skills não listadas: chame skill_load com uma palavra da tarefa para receber sugestões)` : "");
283
+ }
284
+ const STOP = new Set(["para", "como", "com", "uma", "que", "the", "and", "uso", "usar", "skill", "fazer", "esta", "isso", "nos", "nas", "dos", "das", "por"]);
285
+ export const words = (t) => (t.toLowerCase().normalize("NFD").replace(/\p{M}/gu, "").match(/[a-z0-9]{3,}/g) ?? []).filter((w) => !STOP.has(w));
286
+ /** Skills (incluindo manuais e embutidas) ordenadas por afinidade lexical com o texto. Nome pesa mais que descrição. */
287
+ export function suggestSkills(root, text, limit = 5) {
288
+ const q = new Set(words(text));
289
+ if (!q.size)
290
+ return [];
291
+ return [...listSkills(root), ...listBuiltins(root)]
292
+ .map((skill) => {
293
+ const nm = new Set(words(skill.name)), ds = new Set(words(skill.description)), bd = new Set(words(skill.body));
294
+ let score = 0;
295
+ for (const w of q)
296
+ score += (nm.has(w) ? 3 : 0) + (ds.has(w) ? 2 : 0) + (bd.has(w) ? 0.5 : 0);
297
+ return { skill, score };
298
+ })
299
+ .filter((x) => x.score > 0)
300
+ .sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name))
301
+ .slice(0, limit);
246
302
  }
@@ -9,11 +9,11 @@ export const TRIAL_DAYS = 21;
9
9
  /** Teto de skills novas criadas sozinhas por dia (contido: cada uma vira instrução para o agente). */
10
10
  export const DAILY_LIMIT = 3;
11
11
  /** Registra o resultado de um turno que usou a skill. Devolve um aviso se ela foi promovida ou arquivada. */
12
- export function recordOutcome(root, name, win) {
12
+ export function recordOutcome(root, name, win, loss) {
13
13
  const s = getSkill(root, name);
14
- if (!s)
14
+ if (!s || s.builtin)
15
15
  return null;
16
- const next = { ...s, wins: s.wins + (win ? 1 : 0), losses: s.losses + (win ? 0 : 1) };
16
+ const next = { ...s, wins: s.wins + (win ? 1 : 0), losses: s.losses + (win ? 0 : 1), lastLoss: win ? s.lastLoss : (loss ?? s.lastLoss).replace(/\s+/g, " ").slice(0, 200) };
17
17
  if (s.origin === "auto") {
18
18
  if (s.status === "trial") {
19
19
  if (next.wins >= PROMOTE_WINS && next.wins >= 3 * next.losses) {
@@ -11,7 +11,8 @@ let seq = 0;
11
11
  function toolSet(def) {
12
12
  const names = def.tools === "all" ? allTools().map((t) => t.name) : def.tools;
13
13
  // Sem recursão e sem aprender: criar/alterar skills é papel do agente principal.
14
- return new Set(names.filter((n) => !["delegate", "skill_save", "skill_delete", "exit_plan"].includes(n)));
14
+ // A lista de tarefas é do usuário com o agente principal: um subagente não a reescreve.
15
+ return new Set(names.filter((n) => !["delegate", "skill_save", "skill_delete", "exit_plan", "todo_write"].includes(n)));
15
16
  }
16
17
  /** Agente somente-leitura: nenhuma tool dele escreve arquivos ou exige aprovação. */
17
18
  function isReadOnly(def) {
@@ -0,0 +1,29 @@
1
+ import { z } from "zod";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { defineTool } from "./registry.js";
5
+ import { digest } from "../local/digest.js";
6
+ import { slmDisabled, slmStatus } from "../local/slm.js";
7
+ defineTool({
8
+ name: "digest",
9
+ description: "USE ESTA TOOL PRIMEIRO ao pesquisar um assunto dentro de um texto LONGO (livro, PDF extraído, ata; arquivo acima de ~100 KB), em vez de Grep + vários Read: um modelo local pequeno percorre o arquivo e devolve um plano em markdown com só TRECHOS relevantes ao objetivo, com página, conferidos no texto-fonte por código. A seleção pode ser incompleta: use as páginas indicadas para aprofundar com Read e peça mais blocos (max_chunks) se faltar algo. Exige `brito slm pull`. Não use em arquivos curtos.",
10
+ schema: z.object({
11
+ path: z.string().describe("arquivo de texto dentro do projeto"),
12
+ objective: z.string().describe("o que você precisa achar/montar (ex.: 'estrutura em cinco livros do saltério')"),
13
+ max_chunks: z.number().int().min(1).max(30).optional().describe("blocos a analisar (padrão 8; cada um leva alguns segundos)"),
14
+ }),
15
+ async run({ path: p, objective, max_chunks }, ctx) {
16
+ if (slmDisabled())
17
+ throw new Error("SLM desligado (BRITO_SLM=off): leia o arquivo com read_file.");
18
+ const st = await slmStatus();
19
+ if (!st.ready)
20
+ throw new Error(`SLM local indisponível${st.lib ? " (modelo não baixado: rode `brito slm pull`)" : " (instale node-llama-cpp)"}: leia o arquivo com read_file.`);
21
+ const full = path.resolve(ctx.root, p);
22
+ const rel = path.relative(path.resolve(ctx.root), full);
23
+ if (rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel))
24
+ throw new Error("Caminho fora do projeto.");
25
+ const text = fs.readFileSync(full, "utf8");
26
+ const r = await digest({ text, source: rel.split(path.sep).join("/"), objective, maxChunks: max_chunks });
27
+ return r.markdown;
28
+ },
29
+ });
package/dist/tools/fs.js CHANGED
@@ -12,41 +12,83 @@ const abs = (root, p) => {
12
12
  throw new Error("Caminho fora do projeto.");
13
13
  return full;
14
14
  };
15
+ /** Estado atual do arquivo, ou `undefined` se ele não existe. */
16
+ const stampOf = (full) => {
17
+ try {
18
+ const s = fs.statSync(full);
19
+ return { mtimeMs: s.mtimeMs, size: s.size };
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ };
25
+ const remember = (ctx, full) => {
26
+ const s = stampOf(full);
27
+ if (s)
28
+ (ctx.readFiles ??= new Map()).set(full, s);
29
+ };
30
+ /**
31
+ * Editar exige ter lido o arquivo nesta sessão e que ele não tenha mudado desde então (por bash, pelo usuário ou por
32
+ * outro processo). Sem isso o modelo edita de memória e pode desfazer o que mudou. Arquivo novo não precisa de leitura.
33
+ */
34
+ function staleReason(ctx, p) {
35
+ let full;
36
+ try {
37
+ full = abs(ctx.root, p);
38
+ }
39
+ catch {
40
+ return undefined;
41
+ } // o run devolve o erro de caminho
42
+ const now = stampOf(full);
43
+ if (!now)
44
+ return undefined;
45
+ const seen = ctx.readFiles?.get(full);
46
+ if (!seen)
47
+ return `${p} ainda não foi lido nesta sessão. Leia com read_file antes de alterar.`;
48
+ if (seen.mtimeMs !== now.mtimeMs || seen.size !== now.size)
49
+ return `${p} mudou desde a última leitura (por um comando, pelo usuário ou por outro processo). Leia de novo com read_file antes de alterar.`;
50
+ return undefined;
51
+ }
15
52
  defineTool({
16
53
  name: "read_file",
17
54
  concurrent: true,
18
55
  description: "Lê um arquivo do projeto, com números de linha. Use offset/limit para arquivos grandes.",
19
56
  schema: z.object({ path: z.string(), offset: z.number().int().min(1).optional(), limit: z.number().int().min(1).optional() }),
20
- async run({ path: p, offset = 1, limit = 2000 }, { root }) {
21
- const lines = fs.readFileSync(abs(root, p), "utf8").split("\n");
57
+ async run({ path: p, offset = 1, limit = 2000 }, ctx) {
58
+ const full = abs(ctx.root, p);
59
+ const lines = fs.readFileSync(full, "utf8").split("\n");
60
+ remember(ctx, full);
22
61
  return lines.slice(offset - 1, offset - 1 + limit).map((l, i) => `${offset + i}\t${l}`).join("\n");
23
62
  },
24
63
  });
25
64
  defineTool({
26
65
  name: "write_file",
27
- description: "Cria ou sobrescreve um arquivo.",
66
+ description: "Cria ou sobrescreve um arquivo. Para sobrescrever um arquivo existente, leia-o antes com read_file.",
28
67
  schema: z.object({ path: z.string(), content: z.string() }),
29
68
  dangerous: true,
30
69
  edit: true,
31
70
  mutates: true,
32
71
  describe: (i) => `escrever ${i.path} (${i.content.length} chars)`,
33
- async run({ path: p, content }, { root }) {
34
- const full = abs(root, p);
72
+ precheck: (i, ctx) => staleReason(ctx, i.path),
73
+ async run({ path: p, content }, ctx) {
74
+ const full = abs(ctx.root, p);
35
75
  fs.mkdirSync(path.dirname(full), { recursive: true });
36
76
  fs.writeFileSync(full, content);
77
+ remember(ctx, full); // o modelo sabe o conteúdo que acabou de escrever
37
78
  return `Escrito ${p}`;
38
79
  },
39
80
  });
40
81
  defineTool({
41
82
  name: "edit_file",
42
- description: "Substitui old_string por new_string em um arquivo. old_string deve ser único (ou use replace_all).",
83
+ description: "Substitui old_string por new_string em um arquivo. old_string deve ser único (ou use replace_all). Leia o arquivo com read_file antes.",
43
84
  schema: z.object({ path: z.string(), old_string: z.string(), new_string: z.string(), replace_all: z.boolean().optional() }),
44
85
  dangerous: true,
45
86
  edit: true,
46
87
  mutates: true,
47
88
  describe: (i) => `editar ${i.path}\n - ${i.old_string.slice(0, 120).replace(/\n/g, "⏎")}\n + ${i.new_string.slice(0, 120).replace(/\n/g, "⏎")}`,
48
- async run({ path: p, old_string, new_string, replace_all }, { root }) {
49
- const full = abs(root, p);
89
+ precheck: (i, ctx) => staleReason(ctx, i.path),
90
+ async run({ path: p, old_string, new_string, replace_all }, ctx) {
91
+ const full = abs(ctx.root, p);
50
92
  const src = fs.readFileSync(full, "utf8");
51
93
  const count = src.split(old_string).length - 1;
52
94
  if (count === 0)
@@ -54,6 +96,7 @@ defineTool({
54
96
  if (count > 1 && !replace_all)
55
97
  throw new Error(`old_string aparece ${count}x; torne-o único ou use replace_all.`);
56
98
  fs.writeFileSync(full, replace_all ? src.split(old_string).join(new_string) : src.replace(old_string, () => new_string));
99
+ remember(ctx, full);
57
100
  return `Editado ${p} (${replace_all ? count : 1} substituição)`;
58
101
  },
59
102
  });
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { perms } from "../permissions.js";
3
+ import { decide, loadRules } from "../rules.js";
3
4
  const registry = new Map();
4
5
  export function defineTool(t) {
5
6
  registry.set(t.name, t);
@@ -29,10 +30,18 @@ export async function execTool(name, input, ctx) {
29
30
  const parsed = tool.schema.safeParse(input);
30
31
  if (!parsed.success)
31
32
  return { text: `Input inválido: ${z.prettifyError(parsed.error)}`, isError: true };
33
+ // `deny` vale em qualquer modo, inclusive "tudo liberado"; `allow` só dispensa a pergunta.
34
+ const decision = decide(loadRules(ctx.root), tool.name, parsed.data, ctx.root);
35
+ if (decision === "deny")
36
+ return { text: `${tool.name} bloqueada por uma regra deny das permissões (.brito/settings.json). Não tente contornar; peça ao usuário se precisar.`, isError: true };
37
+ // Antes da aprovação: não faz sentido o usuário aprovar uma chamada que vai falhar.
38
+ const problem = tool.precheck?.(parsed.data, ctx);
39
+ if (problem)
40
+ return { text: problem, isError: true };
32
41
  if (tool.dangerous) {
33
42
  if (perms.mode === "plan")
34
43
  return { text: `Modo plano: ${tool.name} está bloqueada. Termine o plano e chame exit_plan para pedir aprovação.`, isError: true };
35
- const skip = perms.mode === "bypass" || (perms.mode === "auto" && tool.edit);
44
+ const skip = perms.mode === "bypass" || (perms.mode === "auto" && tool.edit) || decision === "allow";
36
45
  const ok = skip || await ctx.ask(`${tool.name}: ${tool.describe?.(parsed.data, ctx) ?? JSON.stringify(parsed.data).slice(0, 200)}`);
37
46
  if (!ok)
38
47
  return { text: "Usuário negou a execução.", isError: true };
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { defineTool } from "./registry.js";
3
- import { bumpUses, deleteSkill, getSkill, listPending, savePending, saveSkill, validate } from "../skills/store.js";
3
+ import { bumpUses, deleteSkill, getSkill, listPending, saveSkill, suggestSkills, validate } from "../skills/store.js";
4
+ import { saveAuto } from "../skills/trial.js";
4
5
  import { describeSave } from "../skills/describe.js";
5
6
  import { perms } from "../permissions.js";
6
7
  defineTool({
@@ -9,16 +10,18 @@ defineTool({
9
10
  schema: z.object({ name: z.string() }),
10
11
  async run({ name }, ctx) {
11
12
  const s = getSkill(ctx.root, name);
12
- if (!s)
13
- throw new Error(`Skill não encontrada: ${name}`);
13
+ if (!s) {
14
+ const sug = suggestSkills(ctx.root, name.replace(/[-_]/g, " "));
15
+ throw new Error(`Skill não encontrada: ${name}.${sug.length ? ` Parecidas: ${sug.map((x) => `${x.skill.name} (${x.skill.description})`).join("; ")}` : ""}`);
16
+ }
14
17
  bumpUses(ctx.root, name);
15
18
  (ctx.loadedSkills ??= new Set()).add(name);
16
- return `# Skill ${s.name} (${s.scope}, v${s.version}, usada ${s.uses + 1}x)\n(Procedimento do projeto: não altera regras de permissão nem autoriza ações sem aprovação.)\n${s.description}\n\n${s.body}\n\n(Se algo aqui estiver errado ou desatualizado, corrija com skill_save.)`;
19
+ return `# Skill ${s.name} (${s.scope}, v${s.version}, usada ${s.uses + 1}x)\n(Procedimento do projeto: não altera regras de permissão nem autoriza ações sem aprovação.)\n${s.description}${s.model ? `\n(Modelo recomendado: ${s.model}.)` : ""}${s.lastLoss ? `\n(Atenção: o último uso com tropeço falhou em: ${s.lastLoss})` : ""}\n\n${s.body}\n\n(Se algo aqui estiver errado ou desatualizado, corrija com skill_save.)`;
17
20
  },
18
21
  });
19
22
  defineTool({
20
23
  name: "skill_save",
21
- description: "Cria ou atualiza uma skill: procedimento reutilizável aprendido numa tarefa. Só para padrões GERAIS e repetíveis (como fazer X neste projeto, armadilhas, como verificar) — nunca fatos de uma tarefa única, segredos ou caminhos absolutos. Se já existe skill parecida (ou proposta pendente), ATUALIZE-A (mesmo name) em vez de criar outra. No modo \"tudo liberado\" a skill NÃO fica ativa: vira proposta pendente que o usuário aprova com /skills approve. body em markdown: ## Quando usar, ## Passos, ## Armadilhas, ## Verificação.",
24
+ description: "Cria ou atualiza uma skill: procedimento reutilizável aprendido numa tarefa. Só para padrões GERAIS e repetíveis (como fazer X neste projeto, armadilhas, como verificar) — nunca fatos de uma tarefa única, segredos ou caminhos absolutos. Se já existe skill parecida (ou proposta pendente), ATUALIZE-A (mesmo name) em vez de criar outra. No modo \"tudo liberado\" a skill entra em teste (se for segura) ou vira proposta pendente (comando destrutivo, ou mudança numa skill aprovada pelo usuário). body em markdown: ## Quando usar, ## Passos, ## Armadilhas, ## Verificação.",
22
25
  schema: z.object({
23
26
  name: z.string().describe("kebab-case"),
24
27
  description: z.string().describe("uma frase: QUANDO usar (10–200 chars)"),
@@ -26,18 +29,17 @@ defineTool({
26
29
  refs: z.array(z.string()).default([]).describe("arquivos/símbolos relevantes"),
27
30
  scope: z.enum(["project", "global"]).optional().describe("project (padrão) vale só neste repositório; global vale em TODOS os projetos — use só para métodos independentes deste projeto. Omita ao atualizar: mantém o escopo atual."),
28
31
  reason: z.string().describe("por que vale salvar/alterar (mostrado ao usuário)"),
32
+ invoke: z.enum(["auto", "manual"]).optional().describe("manual = só roda quando o usuário digita /nome (fica fora do índice)"),
33
+ model: z.string().optional().describe("modelo recomendado para seguir a skill (dica)"),
29
34
  }),
30
35
  dangerous: true,
31
36
  describe: (i, ctx) => describeSave(ctx.root, i),
32
- async run({ name, description, body, refs, scope, reason }, ctx) {
37
+ async run({ name, description, body, refs, scope, reason, invoke, model }, ctx) {
33
38
  validate({ name, description, body });
34
- // Em bypass nada pede aprovação, então uma skill gravada aqui viraria instrução ativa sem nenhum humano ter visto.
35
- // Mesma política do reflect(): vai para a fila de revisão. Vale para o backend de API e para o servidor MCP.
36
- if (perms.mode === "bypass") {
37
- const target = savePending(ctx.root, { name, description, body, refs, reason }, scope);
38
- return `Skill ${name} proposta (${target}) e aguardando revisão: /skills approve ${name} (modo "tudo liberado": nenhuma skill fica ativa sem revisão).`;
39
- }
40
- const r = saveSkill(ctx.root, { name, description, body, refs }, scope);
39
+ // Em bypass nada pede aprovação: usa a mesma política segura da reflexão autônoma (em teste; o arriscado vai para a fila de revisão).
40
+ if (perms.mode === "bypass")
41
+ return saveAuto(ctx.root, { name, description, body, refs, reason }).msg;
42
+ const r = saveSkill(ctx.root, { name, description, body, refs, invoke, model }, scope);
41
43
  return r.created ? `Skill ${name} criada (${r.scope}, v1).` : `Skill ${name} atualizada (${r.scope}, v${r.version}); a anterior ficou em history/.`;
42
44
  },
43
45
  });
@@ -0,0 +1,22 @@
1
+ import { z } from "zod";
2
+ import { defineTool } from "./registry.js";
3
+ import { renderTodos } from "../util/todos.js";
4
+ defineTool({
5
+ name: "todo_write",
6
+ description: "Mantém a lista de tarefas visível para o usuário. Use em tarefas com 3+ passos ou quando o usuário pede várias coisas: " +
7
+ "crie a lista no início, marque um item in_progress antes de começar e completed logo ao terminar (não acumule). " +
8
+ "Mande sempre a lista inteira; ela substitui a anterior. Não use para tarefas de um passo.",
9
+ schema: z.object({
10
+ todos: z.array(z.object({
11
+ content: z.string().min(1).describe("o que fazer, no imperativo"),
12
+ status: z.enum(["pending", "in_progress", "completed"]),
13
+ })).max(50),
14
+ }),
15
+ async run({ todos }, ctx) {
16
+ ctx.todos = todos;
17
+ const done = todos.filter((t) => t.status === "completed").length;
18
+ const doing = todos.filter((t) => t.status === "in_progress").length;
19
+ const note = doing > 1 ? "\n(aviso: mais de um item in_progress; mantenha um por vez)" : "";
20
+ return `${renderTodos(todos)}\n\n${done}/${todos.length} concluídas${note}`;
21
+ },
22
+ });
package/dist/ui/ui.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { BOLD, CODE, ITALIC, OFF, c, cols, palette, useColor } from "./theme.js";
2
2
  import { renderBanner } from "./banner.js";
3
3
  import { renderTable } from "./table.js";
4
+ import { todoLine } from "../util/todos.js";
4
5
  import stringWidth from "string-width";
5
6
  export const NAME = "Brito";
6
7
  const isTTY = !!process.stdout.isTTY;
@@ -405,6 +406,15 @@ class UI {
405
406
  this.running.delete(id);
406
407
  this.textEnd();
407
408
  this.print(this.toolLine(r.name, r.input, Date.now() - r.t0, isError));
409
+ if (r.name === "todo_write" && !isError)
410
+ this.printTodos(r.input?.todos ?? []);
411
+ }
412
+ /** A lista do `todo_write`, logo abaixo da linha da tool: em andamento destacado, concluído apagado. */
413
+ printTodos(todos) {
414
+ for (const t of todos) {
415
+ const line = todoLine(t);
416
+ this.print(` ${t.status === "in_progress" ? c.bold(c.soft(line)) : t.status === "completed" ? c.dim(line) : c.muted(line)}`);
417
+ }
408
418
  }
409
419
  /** Só o prefixo + emoji + nome, sem argumento nem tempo. */
410
420
  toolLabel(name) { return `${c.accent("┊")} ${emojiOf(name)} ${c.soft(name)}`; }
@@ -461,6 +471,7 @@ class UI {
461
471
  ...(s.project ? [[0, c.light("📁 ") + c.bold(c.soft(s.project))]] : []),
462
472
  ...(s.mode ? [[0, s.mode]] : []),
463
473
  [0, c.light("⬢ ") + c.bold(c.soft(s.model))],
474
+ ...(s.engine ? [[s.engine.fallback ? 0 : 1, s.engine.fallback ? c.bold(c.rgb(palette.warn)(s.engine.text)) : c.muted(s.engine.text)]] : []),
464
475
  [0, label("contexto") + c.muted(`${fmtTokens(s.ctxTokens)}/${fmtTokens(s.ctxWindow)}`) + ` [${bar}] ${colour(`${pct}%`)}`],
465
476
  ...(hit !== undefined ? [[2, label("cache") + hitColour(`◎ ${Math.round(hit)}%`)]] : []),
466
477
  [3, label("tokens") + c.muted(`↑${fmtTokens(s.input)} ↓${fmtTokens(s.output)}`)],
@@ -486,6 +497,7 @@ class UI {
486
497
  ["cache", "% do prompt reaproveitado do cache (◎). Alto (verde) = respostas mais rápidas e baratas. Some enquanto não há dado."],
487
498
  ["tokens", "total da sessão: ↑ enviados ao modelo (inclui cache) · ↓ gerados por ele."],
488
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."],
489
501
  ["tempo", "há quanto tempo a sessão está aberta · (ativo) soma do tempo gasto em todos os turnos."],
490
502
  ["📁", "projeto (pasta) em que o Brito está trabalhando — também aparece no título da janela do terminal."],
491
503
  ["skill", "skill carregada (skill_load) no turno em andamento."],
@@ -0,0 +1,4 @@
1
+ export const TODO_MARK = { pending: "☐", in_progress: "◐", completed: "☑" };
2
+ export const todoLine = (t) => `${TODO_MARK[t.status]} ${t.content}`;
3
+ /** Lista em texto puro (resultado da tool, app de desktop); o terminal colore a mesma coisa. */
4
+ export const renderTodos = (todos) => (todos.length ? todos.map(todoLine).join("\n") : "(lista vazia)");
package/package.json CHANGED
@@ -1,77 +1,80 @@
1
- {
2
- "name": "brito-cli",
3
- "version": "0.7.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
- "type": "module",
6
- "bin": {
7
- "brito": "dist/cli.js"
8
- },
9
- "scripts": {
10
- "dev": "tsx src/cli.ts",
11
- "build": "tsc",
12
- "typecheck": "tsc --noEmit",
13
- "test": "tsx --test test/*.test.ts",
14
- "eval:learn": "tsx test/eval-learn.ts",
15
- "prepare": "tsc",
16
- "prepublishOnly": "npm run typecheck && npm test",
17
- "desktop": "npm run build && npm run bundle && npm --prefix desktop install --no-audit --no-fund && npm --prefix desktop start",
18
- "bundle": "node build-bundle.mjs",
19
- "desktop:app": "npm run build && npm run bundle && npm --prefix desktop install --no-audit --no-fund && node desktop/package.mjs --install",
20
- "desktop:dmg": "npm run build && npm run bundle && npm --prefix desktop install --no-audit --no-fund && node desktop/package.mjs --dmg"
21
- },
22
- "dependencies": {
23
- "@anthropic-ai/sdk": "^0.137.0",
24
- "@ladybugdb/core": "0.21.2",
25
- "@modelcontextprotocol/sdk": "^1.32.0",
26
- "commander": "^14.0.3",
27
- "string-width": "^8.3.0",
28
- "tinyglobby": "^0.2.17",
29
- "tree-sitter-wasms": "^0.1.13",
30
- "web-tree-sitter": "0.24.7",
31
- "zod": "^4.6.5"
32
- },
33
- "devDependencies": {
34
- "@types/node": "latest",
35
- "esbuild": "^0.28.2",
36
- "tsx": "latest",
37
- "typescript": "latest"
38
- },
39
- "license": "PolyForm-Noncommercial-1.0.0",
40
- "repository": {
41
- "type": "git",
42
- "url": "git+https://github.com/wilsonbrito/brito.git"
43
- },
44
- "homepage": "https://github.com/wilsonbrito/brito#readme",
45
- "bugs": {
46
- "url": "https://github.com/wilsonbrito/brito/issues"
47
- },
48
- "author": "Wilson Brito",
49
- "engines": {
50
- "node": ">=20"
51
- },
52
- "files": [
53
- "dist",
54
- "!dist/cli.bundle.mjs",
55
- ".brito/agents",
56
- "README.md",
57
- "README.pt-BR.md",
58
- "LICENSE"
59
- ],
60
- "keywords": [
61
- "cli",
62
- "agent",
63
- "coding-agent",
64
- "llm",
65
- "knowledge-graph",
66
- "code-graph",
67
- "mcp",
68
- "claude",
69
- "tree-sitter",
70
- "svelte",
71
- "typescript",
72
- "python"
73
- ],
74
- "overrides": {
75
- "tar": "^7.5.22"
76
- }
77
- }
1
+ {
2
+ "name": "brito-cli",
3
+ "version": "0.8.0",
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
+ "type": "module",
6
+ "bin": {
7
+ "brito": "dist/cli.js"
8
+ },
9
+ "scripts": {
10
+ "dev": "tsx src/cli.ts",
11
+ "build": "tsc",
12
+ "typecheck": "tsc --noEmit",
13
+ "test": "tsx --test test/*.test.ts",
14
+ "eval:learn": "tsx test/eval-learn.ts",
15
+ "prepare": "tsc",
16
+ "prepublishOnly": "npm run typecheck && npm test",
17
+ "desktop": "npm run build && npm run bundle && npm --prefix desktop install --no-audit --no-fund && npm --prefix desktop start",
18
+ "bundle": "node build-bundle.mjs",
19
+ "desktop:app": "npm run build && npm run bundle && npm --prefix desktop install --no-audit --no-fund && node desktop/package.mjs --install",
20
+ "desktop:dmg": "npm run build && npm run bundle && npm --prefix desktop install --no-audit --no-fund && node desktop/package.mjs --dmg"
21
+ },
22
+ "dependencies": {
23
+ "@anthropic-ai/sdk": "^0.137.0",
24
+ "@ladybugdb/core": "0.21.2",
25
+ "@modelcontextprotocol/sdk": "^1.32.0",
26
+ "commander": "^14.0.3",
27
+ "string-width": "^8.3.0",
28
+ "tinyglobby": "^0.2.17",
29
+ "tree-sitter-wasms": "^0.1.13",
30
+ "web-tree-sitter": "0.24.7",
31
+ "zod": "^4.6.5"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "latest",
35
+ "esbuild": "^0.28.2",
36
+ "tsx": "latest",
37
+ "typescript": "latest"
38
+ },
39
+ "license": "PolyForm-Noncommercial-1.0.0",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/wilsonbrito/brito.git"
43
+ },
44
+ "homepage": "https://github.com/wilsonbrito/brito#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/wilsonbrito/brito/issues"
47
+ },
48
+ "author": "Wilson Brito",
49
+ "engines": {
50
+ "node": ">=20"
51
+ },
52
+ "files": [
53
+ "dist",
54
+ "!dist/cli.bundle.mjs",
55
+ ".brito/agents",
56
+ "README.md",
57
+ "README.pt-BR.md",
58
+ "LICENSE"
59
+ ],
60
+ "keywords": [
61
+ "cli",
62
+ "agent",
63
+ "coding-agent",
64
+ "llm",
65
+ "knowledge-graph",
66
+ "code-graph",
67
+ "mcp",
68
+ "claude",
69
+ "tree-sitter",
70
+ "svelte",
71
+ "typescript",
72
+ "python"
73
+ ],
74
+ "overrides": {
75
+ "tar": "^7.5.22"
76
+ },
77
+ "optionalDependencies": {
78
+ "node-llama-cpp": "^3.22.3"
79
+ }
80
+ }