brito-cli 0.3.2 → 0.5.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.
@@ -22,14 +22,18 @@ function parse(text, scope) {
22
22
  updated: meta.updated ?? today(),
23
23
  lastUsed: meta.lastUsed ?? meta.updated ?? today(),
24
24
  refs: (meta.refs ?? "").split(",").map((r) => r.trim()).filter(Boolean),
25
+ status: meta.status === "trial" ? "trial" : "active",
26
+ origin: meta.origin === "auto" ? "auto" : "manual",
27
+ wins: Number(meta.wins) || 0,
28
+ losses: Number(meta.losses) || 0,
25
29
  body: body.trim(),
26
30
  scope,
27
31
  };
28
32
  }
29
- function write(root, s) {
33
+ export function writeSkill(root, s) {
30
34
  const file = fileOf(root, s.scope, s.name);
31
35
  fs.mkdirSync(path.dirname(file), { recursive: true });
32
- 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(", ") }, s.body));
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));
33
37
  }
34
38
  /** Lê uma skill de um escopo específico. */
35
39
  export function getSkillIn(root, name, scope) {
@@ -131,8 +135,12 @@ export function validate(input) {
131
135
  export function targetScope(root, name, scope) {
132
136
  return scope ?? getSkill(root, name)?.scope ?? "project";
133
137
  }
134
- /** Cria ou atualiza. A versão anterior vai para `history/vN.md`. */
135
- export function saveSkill(root, input, scope) {
138
+ /**
139
+ * Cria ou atualiza. A versão anterior vai para `history/vN.md`.
140
+ * `opts.auto` = gravação sem revisão humana (reflexão autônoma): skill nova nasce em teste; atualizar mantém a origem, e
141
+ * uma skill em teste zera o placar (conteúdo novo = hipótese nova). Sem `auto`, um humano aprovou: vira ativa e manual.
142
+ */
143
+ export function saveSkill(root, input, scope, opts = {}) {
136
144
  validate(input);
137
145
  const target = targetScope(root, input.name, scope);
138
146
  const prev = getSkillIn(root, input.name, target);
@@ -150,10 +158,14 @@ export function saveSkill(root, input, scope) {
150
158
  updated: today(),
151
159
  lastUsed: prev?.lastUsed ?? today(),
152
160
  refs: input.refs ?? prev?.refs ?? [],
161
+ status: opts.auto ? (prev?.status ?? "trial") : "active",
162
+ origin: opts.auto ? (prev?.origin ?? "auto") : "manual",
163
+ wins: opts.auto && prev?.status === "trial" ? 0 : prev?.wins ?? 0,
164
+ losses: opts.auto && prev?.status === "trial" ? 0 : prev?.losses ?? 0,
153
165
  body: input.body.trim(),
154
166
  scope: target,
155
167
  };
156
- write(root, skill);
168
+ writeSkill(root, skill);
157
169
  return { created: !prev, version: skill.version, scope: target };
158
170
  }
159
171
  /** Propostas aguardando revisão (modo bypass): ficam fora do índice, pois `listScope` ignora diretórios com ponto. */
@@ -204,7 +216,7 @@ export function rejectPending(root, name) {
204
216
  export function bumpUses(root, name) {
205
217
  const s = getSkill(root, name);
206
218
  if (s)
207
- write(root, { ...s, uses: s.uses + 1, lastUsed: today() });
219
+ writeSkill(root, { ...s, uses: s.uses + 1, lastUsed: today() });
208
220
  }
209
221
  /** Move para `.trash` do próprio escopo em vez de apagar. */
210
222
  export function deleteSkill(root, name, scope) {
@@ -230,5 +242,5 @@ export function promoteSkill(root, name) {
230
242
  /** Índice compacto para o system prompt (divulgação progressiva: o corpo só entra via skill_load). */
231
243
  export function skillsIndex(root, max = 40, now = Date.now()) {
232
244
  const all = listSkills(root, now).slice(0, max);
233
- return all.length ? all.map((s) => `- ${s.name}${s.scope === "global" ? " (global)" : ""}${staleRefs(root, s).length ? " [refs obsoletas]" : ""}: ${s.description}`).join("\n") : "";
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") : "";
234
246
  }
@@ -0,0 +1,71 @@
1
+ import { daysSince, deleteSkill, getSkill, lintSkill, listSkills, savePending, saveSkill, writeSkill } from "./store.js";
2
+ export const skillsMode = () => (process.env.BRITO_SKILLS === "ask" || process.env.BRITO_SKILLS === "off" ? process.env.BRITO_SKILLS : "auto");
3
+ /** Usos bem-sucedidos para promover (com no máximo 1 tropeço a cada 3 sucessos). */
4
+ export const PROMOTE_WINS = 3;
5
+ /** Falhas que arquivam uma skill em teste sem mais vitórias que falhas. */
6
+ export const ARCHIVE_LOSSES = 2;
7
+ /** Skill em teste que ninguém carregou neste prazo é arquivada. */
8
+ export const TRIAL_DAYS = 21;
9
+ /** Teto de skills novas criadas sozinhas por dia (contido: cada uma vira instrução para o agente). */
10
+ export const DAILY_LIMIT = 3;
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) {
13
+ const s = getSkill(root, name);
14
+ if (!s)
15
+ return null;
16
+ const next = { ...s, wins: s.wins + (win ? 1 : 0), losses: s.losses + (win ? 0 : 1) };
17
+ if (s.origin === "auto") {
18
+ if (s.status === "trial") {
19
+ if (next.wins >= PROMOTE_WINS && next.wins >= 3 * next.losses) {
20
+ writeSkill(root, { ...next, status: "active" });
21
+ return `skill ${name} promovida: ${next.wins} usos bem-sucedidos, ${next.losses} com tropeço`;
22
+ }
23
+ if (next.losses >= ARCHIVE_LOSSES && next.losses >= next.wins) {
24
+ deleteSkill(root, name, s.scope);
25
+ return `skill ${name} arquivada: ${next.losses} usos com tropeço e ${next.wins} bem-sucedidos (recupere em .brito/skills/.trash)`;
26
+ }
27
+ }
28
+ else if (next.losses >= 3 && next.losses > next.wins) {
29
+ deleteSkill(root, name, s.scope);
30
+ return `skill ${name} arquivada: passou a falhar (${next.losses} tropeços, ${next.wins} sucessos)`;
31
+ }
32
+ }
33
+ writeSkill(root, next);
34
+ return null;
35
+ }
36
+ /** Arquiva as skills em teste que ninguém usou no prazo. */
37
+ export function expireTrials(root, now = Date.now()) {
38
+ const out = [];
39
+ for (const s of listSkills(root, now)) {
40
+ if (s.origin === "auto" && s.status === "trial" && daysSince(s.lastUsed, now) > TRIAL_DAYS && deleteSkill(root, s.name, s.scope)) {
41
+ out.push(`skill ${s.name} arquivada: em teste há mais de ${TRIAL_DAYS} dias sem uso`);
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+ /**
47
+ * Grava uma skill proposta pela reflexão sem perguntar ao usuário, mas só quando é seguro:
48
+ * - sempre escopo do projeto (global exige decisão humana);
49
+ * - skill de origem manual nunca é reescrita sozinha: a mudança vira proposta pendente;
50
+ * - conteúdo com comando destrutivo também vai para a fila de revisão;
51
+ * - nova skill respeita o teto diário.
52
+ */
53
+ export function saveAuto(root, input, now = Date.now()) {
54
+ const prev = getSkill(root, input.name);
55
+ if (prev && prev.origin === "manual") {
56
+ const scope = savePending(root, input, prev.scope);
57
+ return { saved: false, msg: `skill ${input.name} (${scope}) é revisada por você: mudança proposta em /skills approve ${input.name}` };
58
+ }
59
+ if (lintSkill(input).warnings.some((w) => w.startsWith("comando destrutivo"))) {
60
+ const scope = savePending(root, input, "project");
61
+ return { saved: false, msg: `skill ${input.name} (${scope}) tem comando destrutivo: aguardando /skills approve ${input.name}` };
62
+ }
63
+ if (!prev) {
64
+ const today = new Date(now).toISOString().slice(0, 10);
65
+ const created = listSkills(root, now).filter((s) => s.origin === "auto" && s.created === today).length;
66
+ if (created >= DAILY_LIMIT)
67
+ return { saved: false, msg: `skill ${input.name} não criada: teto de ${DAILY_LIMIT} skills automáticas por dia` };
68
+ }
69
+ const r = saveSkill(root, input, prev?.scope ?? "project", { auto: true });
70
+ return { saved: true, msg: r.created ? `skill ${input.name} criada em teste (project, v1)` : `skill ${input.name} atualizada (${r.scope}, v${r.version})` };
71
+ }
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { exec } from "node:child_process";
3
3
  import { defineTool } from "./registry.js";
4
+ import { shellFor } from "../util/platform.js";
4
5
  defineTool({
5
6
  name: "bash",
6
7
  description: "Executa um comando shell na raiz do projeto (timeout padrão 120s).",
@@ -9,7 +10,7 @@ defineTool({
9
10
  mutates: true,
10
11
  describe: (i) => i.command,
11
12
  run: ({ command, timeout_ms = 120_000 }, { root }) => new Promise((resolve) => {
12
- exec(command, { cwd: root, timeout: timeout_ms, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
13
+ exec(command, { cwd: root, shell: shellFor(), timeout: timeout_ms, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
13
14
  const out = (stdout + (stderr ? `\n[stderr]\n${stderr}` : "")).trim();
14
15
  resolve(`${err ? `[exit ${err.code ?? "?"}] ` : ""}${out.slice(-30_000) || "(sem saída)"}`);
15
16
  });
package/dist/tools/fs.js CHANGED
@@ -1,11 +1,14 @@
1
1
  import { z } from "zod";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import fg from "fast-glob";
4
+ import { glob as fg } from "tinyglobby";
5
5
  import { defineTool } from "./registry.js";
6
6
  const abs = (root, p) => {
7
7
  const full = path.resolve(root, p);
8
- if (!full.startsWith(path.resolve(root)))
8
+ // path.relative cobre dois defeitos do startsWith: irmão com mesmo prefixo (/proj vs /proj-x) e, no Windows,
9
+ // diferença de caixa na letra da unidade ou nos diretórios (C:\ vs c:\), que fazia caminhos válidos serem recusados.
10
+ const rel = path.relative(path.resolve(root), full);
11
+ if (rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel))
9
12
  throw new Error("Caminho fora do projeto.");
10
13
  return full;
11
14
  };
package/dist/ui/dock.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { droppedImages } from "../util/images.js";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import stringWidth from "string-width";
@@ -7,7 +8,7 @@ const graphemes = (s) => Array.from(seg.segment(s), (x) => x.segment);
7
8
  const ANSI = /\x1b\[[0-9;]*[A-Za-z]/y;
8
9
  const MAX_INPUT_ROWS = 8;
9
10
  const MAX_MENU = 7;
10
- const PASTE_RE = /\[colado #(\d+): [^\]]*\]/g;
11
+ const PASTE_RE = /\[(?:colado|imagem) #(\d+): [^\]]*\]/g;
11
12
  export class Dock {
12
13
  opts;
13
14
  onSubmit = () => { };
@@ -409,7 +410,14 @@ export class Dock {
409
410
  finishPaste() {
410
411
  const text = this.pasteBuf.replace(/\r\n?/g, "\n");
411
412
  const lines = text.split("\n").length;
412
- if (lines >= 2 || text.length > 300) {
413
+ // Arrastar uma imagem para o terminal cola o caminho dela: vira um marcador curto que volta a ser o caminho no envio.
414
+ const imgs = droppedImages(text);
415
+ if (imgs.length) {
416
+ const n = ++this.pasteN;
417
+ this.pastes.set(n, imgs.map((i) => (/\s/.test(i.path) ? `"${i.path}"` : i.path)).join(" "));
418
+ this.insert(`[imagem #${n}: ${imgs.length === 1 ? imgs[0].name : `${imgs.length} imagens`}] `);
419
+ }
420
+ else if (lines >= 2 || text.length > 300) {
413
421
  const n = ++this.pasteN;
414
422
  this.pastes.set(n, text);
415
423
  const size = text.length >= 1000 ? `${(text.length / 1000).toFixed(1)}K` : String(text.length);
@@ -0,0 +1,75 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ const TYPES = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp" };
6
+ const IMG_EXT = /\.(png|jpe?g|gif|webp)$/i;
7
+ /** Limite da API por imagem (5 MB) e teto de anexos por mensagem. */
8
+ export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
9
+ export const MAX_IMAGES = 5;
10
+ /** Divide o texto em tokens, respeitando aspas e (fora do Windows) `\ ` como espaço do nome do arquivo. */
11
+ function tokens(text) {
12
+ const win = process.platform === "win32";
13
+ const re = win ? /'([^']*)'|"([^"]*)"|(\S+)/g : /'([^']*)'|"([^"]*)"|((?:\\.|\S)+)/g;
14
+ return [...text.matchAll(re)].map((m) => {
15
+ if (m[1] !== undefined)
16
+ return m[1];
17
+ if (m[2] !== undefined)
18
+ return m[2];
19
+ return win ? m[3] : m[3].replace(/\\(.)/g, "$1");
20
+ });
21
+ }
22
+ function toPath(token, root) {
23
+ let t = token.trim();
24
+ if (/^file:\/\//i.test(t)) {
25
+ try {
26
+ t = fileURLToPath(t);
27
+ }
28
+ catch {
29
+ return t;
30
+ }
31
+ }
32
+ if (t === "~" || t.startsWith("~/"))
33
+ t = path.join(os.homedir(), t.slice(1));
34
+ return path.resolve(root, t);
35
+ }
36
+ /** Imagens existentes citadas no texto (caminhos soltos, entre aspas, escapados ou file://). Sem duplicatas; para em MAX_IMAGES. */
37
+ export function findImages(text, root = process.cwd()) {
38
+ const seen = new Set();
39
+ const out = [];
40
+ for (const tok of tokens(text)) {
41
+ if (!IMG_EXT.test(tok))
42
+ continue;
43
+ const p = toPath(tok, root);
44
+ if (seen.has(p))
45
+ continue;
46
+ try {
47
+ const st = fs.statSync(p);
48
+ if (!st.isFile())
49
+ continue;
50
+ seen.add(p);
51
+ out.push({ path: p, name: path.basename(p), mediaType: TYPES[path.extname(p).slice(1).toLowerCase()], bytes: st.size });
52
+ }
53
+ catch { /* não existe: era só texto parecido com caminho */ }
54
+ if (out.length >= MAX_IMAGES)
55
+ break;
56
+ }
57
+ return out;
58
+ }
59
+ /** O texto inteiro é só caminhos de imagem (o que um arrastar-e-soltar produz)? Devolve as imagens, ou [] se tem mais coisa. */
60
+ export function droppedImages(text, root = process.cwd()) {
61
+ const toks = tokens(text);
62
+ if (!toks.length || !toks.every((t) => IMG_EXT.test(t)))
63
+ return [];
64
+ const imgs = findImages(text, root);
65
+ return imgs.length === toks.length ? imgs : [];
66
+ }
67
+ export const fmtBytes = (n) => (n >= 1_048_576 ? `${(n / 1_048_576).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} kB`);
68
+ /** Separa o que dá para enviar do que passa do limite da API. */
69
+ export function splitOversize(imgs) {
70
+ return { ok: imgs.filter((i) => i.bytes <= MAX_IMAGE_BYTES), tooBig: imgs.filter((i) => i.bytes > MAX_IMAGE_BYTES) };
71
+ }
72
+ /** Bloco `image` da API da Anthropic, com o arquivo em base64. */
73
+ export function imageBlock(img) {
74
+ return { type: "image", source: { type: "base64", media_type: img.mediaType, data: fs.readFileSync(img.path).toString("base64") } };
75
+ }
@@ -0,0 +1,57 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ let claudeCache;
5
+ /** Alvo real de um shim npm (`claude.cmd`): `"%dp0%\caminho\cli.js"` ou `...\claude.exe`. */
6
+ export function resolveCmdShim(shimPath, content) {
7
+ const target = content.match(/"%dp0%\\([^"]+)"/)?.[1];
8
+ if (!target)
9
+ return null;
10
+ const file = path.win32.join(path.win32.dirname(shimPath), target);
11
+ if (/\.exe$/i.test(file))
12
+ return { command: file, prefix: [] };
13
+ if (/\.(c|m)?js$/i.test(file))
14
+ return { command: process.execPath, prefix: [file] };
15
+ return null;
16
+ }
17
+ function findClaude() {
18
+ const found = spawnSync("where", ["claude"], { encoding: "utf8" }).stdout?.split(/\r?\n/).map((l) => l.trim()).filter(Boolean) ?? [];
19
+ const exe = found.find((f) => /\.exe$/i.test(f));
20
+ if (exe)
21
+ return { command: exe, prefix: [] };
22
+ for (const f of found.filter((x) => /\.cmd$/i.test(x))) {
23
+ try {
24
+ const r = resolveCmdShim(f, fs.readFileSync(f, "utf8"));
25
+ if (r && fs.existsSync(r.prefix[0] ?? r.command))
26
+ return r;
27
+ }
28
+ catch { /* shim ilegível: tenta o próximo */ }
29
+ }
30
+ return { command: "claude", prefix: [] };
31
+ }
32
+ /** Comando e argumentos para executar o `claude` com segurança em qualquer sistema. */
33
+ export function claudeCommand(args) {
34
+ if (process.platform !== "win32")
35
+ return { command: "claude", args };
36
+ claudeCache ??= findClaude();
37
+ return { command: claudeCache.command, args: [...claudeCache.prefix, ...args] };
38
+ }
39
+ let shellCache = null;
40
+ /** Shell para `exec`: `BRITO_SHELL` > Git Bash (só no Windows) > padrão do sistema (undefined). */
41
+ export function shellFor() {
42
+ if (process.env.BRITO_SHELL)
43
+ return process.env.BRITO_SHELL;
44
+ if (process.platform !== "win32")
45
+ return undefined;
46
+ if (shellCache !== null)
47
+ return shellCache;
48
+ const roots = [process.env.ProgramFiles, process.env["ProgramFiles(x86)"], process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, "Programs")].filter((r) => !!r);
49
+ shellCache = roots.map((r) => path.join(r, "Git", "bin", "bash.exe")).find((p) => fs.existsSync(p));
50
+ return shellCache;
51
+ }
52
+ /** Linha para o system prompt dizendo qual shell o comando `bash` usa de fato (só no Windows; nos outros é bash/sh). */
53
+ export function shellNote() {
54
+ if (process.platform !== "win32")
55
+ return "";
56
+ return shellFor() ? "\nShell dos comandos: Git Bash (sintaxe bash)." : "\nShell dos comandos: cmd.exe do Windows (não há bash): use dir/type/findstr e sintaxe do cmd, não ls/cat/grep.";
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brito-cli",
3
- "version": "0.3.2",
3
+ "version": "0.5.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": {
@@ -13,21 +13,26 @@
13
13
  "test": "tsx --test test/*.test.ts",
14
14
  "eval:learn": "tsx test/eval-learn.ts",
15
15
  "prepare": "tsc",
16
- "prepublishOnly": "npm run typecheck && npm test"
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"
17
21
  },
18
22
  "dependencies": {
19
23
  "@anthropic-ai/sdk": "^0.131.0",
20
24
  "@modelcontextprotocol/sdk": "^1.32.0",
21
25
  "commander": "latest",
22
- "fast-glob": "latest",
23
26
  "kuzu": "latest",
24
27
  "string-width": "^8.3.0",
28
+ "tinyglobby": "^0.2.17",
25
29
  "tree-sitter-wasms": "latest",
26
30
  "web-tree-sitter": "0.24.7",
27
31
  "zod": "latest"
28
32
  },
29
33
  "devDependencies": {
30
34
  "@types/node": "latest",
35
+ "esbuild": "^0.28.2",
31
36
  "tsx": "latest",
32
37
  "typescript": "latest"
33
38
  },
@@ -46,6 +51,7 @@
46
51
  },
47
52
  "files": [
48
53
  "dist",
54
+ "!dist/cli.bundle.mjs",
49
55
  ".brito/agents",
50
56
  "README.md",
51
57
  "README.pt-BR.md",
@@ -64,5 +70,8 @@
64
70
  "svelte",
65
71
  "typescript",
66
72
  "python"
67
- ]
73
+ ],
74
+ "overrides": {
75
+ "tar": "^7.5.22"
76
+ }
68
77
  }