brito-cli 0.9.0 → 0.10.1
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 +12 -0
- package/README.pt-BR.md +11 -0
- package/dist/agent/context.js +17 -10
- package/dist/cli.js +67 -2
- package/dist/desktop/serve.js +3 -0
- package/dist/graph/indexer.js +7 -1
- package/dist/import/claude-code.js +286 -0
- package/dist/local/compact.js +66 -0
- package/dist/local/slm.js +5 -1
- package/dist/local/stats.js +17 -0
- package/dist/provider/resolve.js +3 -1
- package/dist/skills/by.js +7 -0
- package/dist/skills/learn.js +5 -3
- package/dist/skills/store.js +21 -2
- package/dist/skills/trial.js +32 -3
- package/dist/usage/command.js +20 -0
- package/dist/usage/plan.js +111 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,6 +10,14 @@ Brito is an agent for software development. Use it in the terminal (`brito-cli`
|
|
|
10
10
|
|
|
11
11
|
*A real session on a small SvelteKit project: one question, two graph calls (0.1 s and 0.0 s), and an answer that already includes the `.svelte` components depending on the file. The status bar shows the real cost of the turn.*
|
|
12
12
|
|
|
13
|
+
## What's new in 0.10
|
|
14
|
+
|
|
15
|
+
- **Read your project inside the desktop app.** Open the **Files** panel → **Project** tab: folder tree plus a reader for Markdown (front matter, task lists, tables, Obsidian callouts, `[[wikilinks]]`), syntax-highlighted code, images, **PDF, Word (.docx) and PowerPoint (.pptx)**. ` ```mermaid ` blocks render as diagrams. Paths the agent mentions in the chat (`src/cli.ts:120`) are clickable. It is read-only, and it only reads inside the project folders you added.
|
|
16
|
+
- **Bring your Claude Code setup.** Run `brito importar claude-code` to see what would come over (skills, subagents, `deny` rules). Nothing is written until you add `--aplicar`. Imported skills start "in trial" and go through the safety lint; `--desfazer` undoes it. Add `--incluir-allow` for `allow` rules and `--memoria` for the project memory. Credentials are never read.
|
|
17
|
+
- **Know which model writes good skills.** Every automatic skill now records its author. `/skills stats` shows, per model (local SLM vs. Claude), created / promoted / archived skills and the promotion rate, so you can decide whether a cheaper model is good enough.
|
|
18
|
+
|
|
19
|
+
Upgrading from 0.9: `npm i -g brito-cli@latest`, and update the desktop app to the same version (the app warns when it and the CLI are out of sync). Full details in the [CHANGELOG](CHANGELOG.md).
|
|
20
|
+
|
|
13
21
|
## Highlights
|
|
14
22
|
|
|
15
23
|
> **Upgrading to 0.6:** the graph engine changed from Kùzu (discontinued) to LadybugDB. The graph is rebuilt automatically on first use; your old `.brito/graph.kuzu` is kept. Brito asks once whether to copy your notes (memory) to the new graph, or run `brito migrar-notas` any time. Tests run in CI on macOS, Linux and Windows (use 0.6.1 or later on Windows/Linux). See the [CHANGELOG](CHANGELOG.md).
|
|
@@ -22,6 +30,9 @@ Brito is an agent for software development. Use it in the terminal (`brito-cli`
|
|
|
22
30
|
- **Two backends, one interface.** Use an Anthropic API key, or your Claude Code subscription through the `claude` CLI. Brito never reads or sends your subscription token.
|
|
23
31
|
- **Terminal or desktop app.** `npm i -g brito-cli` gives you the `brito` command; the desktop app is a native executable with a window (projects and sessions in a sidebar, chat, permission prompts), no Node or terminal needed. See [Install](#install).
|
|
24
32
|
- **Images.** Drag an image file into the prompt (or paste its path) and it is attached to the turn: PNG, JPEG, GIF or WebP, up to 5 MB.
|
|
33
|
+
- **File preview in the desktop app (read-only).** The Files panel has a "Project" tab: repository tree plus a reader for Markdown (front matter, nested lists, task lists, tables, Obsidian callouts and `[[wikilinks]]`), syntax-highlighted code and images. Mermaid blocks render as diagrams; file paths cited in the chat (`src/cli.ts:120`) open the preview.
|
|
34
|
+
- **Import from Claude Code.** `brito importar claude-code` shows what it would bring (skills as "trial", subagents, `deny` rules; `--incluir-allow` and `--memoria` are opt-in) and writes only with `--aplicar`. `--desfazer` reverts. Credentials are never read.
|
|
35
|
+
- **Measuring the reflection.** Each automatic skill records the model that wrote it; `/skills stats` shows the promotion rate per model (local SLM vs. Claude).
|
|
25
36
|
- **Subagents.** `explore`, `plan` and `general` run in isolated contexts (read-only ones in parallel); add your own in `.brito/agents/*.md`.
|
|
26
37
|
- **MCP server.** `brito mcp-serve` exposes the graph, memory and skills to other MCP clients such as Claude Code. The query tools are marked `alwaysLoad`, so they are not hidden behind tool search.
|
|
27
38
|
|
|
@@ -81,6 +92,7 @@ The lint **rejects** secrets/tokens, absolute user paths, credentials in URLs (`
|
|
|
81
92
|
- **`digest` tool**: a scout and refiner for long texts. The local model picks the blocks most related to your objective and proposes excerpts; code then checks every quote against the source and drops what is not there, so the main model gets a small markdown draft with verified quotes and page numbers.
|
|
82
93
|
|
|
83
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
|
+
- **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.
|
|
84
96
|
|
|
85
97
|
`BRITO_SLM=off` disables it; `BRITO_SLM_MODEL` points to another GGUF (`hf:` URI or file path).
|
|
86
98
|
|
package/README.pt-BR.md
CHANGED
|
@@ -10,6 +10,14 @@ O Brito é um agente para desenvolvimento de software. Use no terminal (`brito-c
|
|
|
10
10
|
|
|
11
11
|
*Uma sessão real num pequeno projeto SvelteKit: uma pergunta, duas consultas ao grafo (0,1 s e 0,0 s) e uma resposta que já inclui os componentes `.svelte` que dependem do arquivo. A barra de status mostra o custo real do turno.*
|
|
12
12
|
|
|
13
|
+
## Novidades da 0.10
|
|
14
|
+
|
|
15
|
+
- **Leia o seu projeto dentro do app de desktop.** Abra o painel **Arquivos** → aba **Projeto**: árvore de pastas e um leitor de Markdown (frontmatter, tarefas, tabelas, callouts e `[[wikilinks]]` do Obsidian), código com realce e imagens. Blocos ` ```mermaid ` viram diagramas. Os caminhos que o agente cita no chat (`src/cli.ts:120`) são clicáveis. É só leitura, e só lê dentro das pastas que você adicionou como projeto.
|
|
16
|
+
- **Traga a sua configuração do Claude Code.** Rode `brito importar claude-code` para ver o que viria (skills, subagentes, regras `deny`). Nada é gravado até você adicionar `--aplicar`. As skills importadas começam "em teste" e passam pelo lint de segurança; `--desfazer` reverte. Use `--incluir-allow` para as regras `allow` e `--memoria` para a memória do projeto. Credenciais nunca são lidas.
|
|
17
|
+
- **Descubra qual modelo escreve boas skills.** Toda skill automática agora registra o autor. `/skills stats` mostra, por modelo (SLM local × Claude), skills criadas, promovidas e arquivadas e a taxa de promoção, para você decidir se um modelo mais barato basta.
|
|
18
|
+
|
|
19
|
+
Atualizando da 0.9: `npm i -g brito-cli@latest` e atualize o app de desktop para a mesma versão (o app avisa quando ele e a CLI estão fora de sincronia). Detalhes no [CHANGELOG](CHANGELOG.md).
|
|
20
|
+
|
|
13
21
|
## Destaques
|
|
14
22
|
|
|
15
23
|
> **Atualizando para a 0.6:** o motor do grafo mudou do Kùzu (descontinuado) para o LadybugDB. O grafo é reconstruído sozinho no primeiro uso; o seu `.brito/graph.kuzu` antigo é mantido. O Brito pergunta uma vez se copia as suas notas (memória) para o grafo novo, ou rode `brito migrar-notas` quando quiser. Os testes rodam no CI em macOS, Linux e Windows (use a 0.6.1 ou posterior no Windows/Linux). Veja o [CHANGELOG](CHANGELOG.md).
|
|
@@ -22,6 +30,9 @@ O Brito é um agente para desenvolvimento de software. Use no terminal (`brito-c
|
|
|
22
30
|
- **Dois backends, uma interface.** Use uma chave da API da Anthropic ou a sua assinatura do Claude Code pela CLI `claude`. O Brito nunca lê nem envia o token da sua assinatura.
|
|
23
31
|
- **Terminal ou app de desktop.** `npm i -g brito-cli` instala o comando `brito`; o app de desktop é um executável nativo com janela (projetos e sessões numa barra lateral, chat, pedidos de permissão), sem precisar de Node nem de terminal. Veja [Instalação](#instalação).
|
|
24
32
|
- **Imagens.** Arraste um arquivo de imagem para o prompt (ou cole o caminho) e ele é anexado ao turno: PNG, JPEG, GIF ou WebP, até 5 MB.
|
|
33
|
+
- **Preview de arquivos no desktop (somente leitura).** O painel Arquivos tem a aba "Projeto": árvore do repositório e leitor de Markdown (frontmatter, listas aninhadas, tarefas, tabelas, callouts e `[[wikilinks]]` do Obsidian), código com realce e imagens. Blocos Mermaid viram diagrama; caminhos citados no chat (`src/cli.ts:120`) abrem o preview.
|
|
34
|
+
- **Importar do Claude Code.** `brito importar claude-code` mostra o que traria (skills em teste, subagentes, regras `deny`; `--incluir-allow` e `--memoria` são opcionais) e só grava com `--aplicar`. `--desfazer` reverte. Credenciais nunca são lidas.
|
|
35
|
+
- **Medição da reflexão.** Cada skill automática registra o modelo que a gerou; `/skills stats` mostra a taxa de promoção por modelo (SLM local × Claude).
|
|
25
36
|
- **Subagentes.** `explore`, `plan` e `general` rodam em contextos isolados (os somente-leitura, em paralelo); crie os seus em `.brito/agents/*.md`.
|
|
26
37
|
- **Servidor MCP.** `brito mcp-serve` expõe grafo, memória e skills a outros clientes MCP, como o Claude Code. As tools de consulta são marcadas `alwaysLoad`, para não ficarem escondidas atrás da busca de tools.
|
|
27
38
|
|
package/dist/agent/context.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { providerFor } from "../provider/index.js";
|
|
2
|
+
import { resolveModel } from "../provider/resolve.js";
|
|
3
|
+
import { slmCompactSummary } from "../local/compact.js";
|
|
2
4
|
/** Imagem em base64 tem megabytes, mas custa ~1,6 mil tokens: troca o conteúdo por um marcador antes de medir ou resumir. */
|
|
3
5
|
const stubImages = (_k, v) => (v && typeof v === "object" && (v.type === "image" || v.type === "document") && v.source ? { type: v.type, note: "x".repeat(6400) } : v);
|
|
4
6
|
export const estimateTokens = (msgs) => Math.ceil(JSON.stringify(msgs, stubImages).length / 4);
|
|
@@ -20,7 +22,7 @@ export function trimOldToolResults(msgs, keep = 8) {
|
|
|
20
22
|
});
|
|
21
23
|
}
|
|
22
24
|
/** Fase 2: resume o meio da conversa via LLM, mantendo o início e os últimos turnos. */
|
|
23
|
-
export async function summarizeMiddle(model, msgs, keepTail = 6) {
|
|
25
|
+
export async function summarizeMiddle(model, msgs, keepTail = 6, slmSummary = slmCompactSummary) {
|
|
24
26
|
if (msgs.length <= keepTail + 2)
|
|
25
27
|
return msgs;
|
|
26
28
|
let cut = msgs.length - keepTail;
|
|
@@ -32,14 +34,19 @@ export async function summarizeMiddle(model, msgs, keepTail = 6) {
|
|
|
32
34
|
const head = msgs.slice(0, 1);
|
|
33
35
|
const middle = msgs.slice(1, cut);
|
|
34
36
|
const tail = msgs.slice(cut);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
37
|
+
const body = JSON.stringify(middle, (_k, v) => (v && typeof v === "object" && (v.type === "image" || v.type === "document") && v.source ? { type: v.type, note: "[anexo omitido]" } : v)).slice(0, 150_000);
|
|
38
|
+
// O SLM local resume primeiro (não gasta token do Claude); se o modelo da sessão já é local, não há o que poupar.
|
|
39
|
+
let summary = resolveModel(model).local ? null : await slmSummary(body);
|
|
40
|
+
if (!summary) {
|
|
41
|
+
// Raciocínio desligado: num endpoint com raciocínio ele comeria o orçamento do resumo.
|
|
42
|
+
const res = await providerFor(model).send({
|
|
43
|
+
model,
|
|
44
|
+
maxTokens: 2000,
|
|
45
|
+
thinking: false,
|
|
46
|
+
system: "Resuma a conversa de um agente de programação. Preserve: objetivo, decisões, arquivos/símbolos tocados, erros encontrados, estado atual e próximos passos. Seja denso.",
|
|
47
|
+
messages: [{ role: "user", content: body }],
|
|
48
|
+
});
|
|
49
|
+
summary = res.content.map((b) => (b.type === "text" ? b.text : "")).join("");
|
|
50
|
+
}
|
|
44
51
|
return [...head, { role: "user", content: `[Resumo da conversa anterior]\n${summary}` }, { role: "assistant", content: "Entendido, continuando a partir do resumo." }, ...tail];
|
|
45
52
|
}
|
package/dist/cli.js
CHANGED
|
@@ -32,9 +32,11 @@ import "./tools/digest.js";
|
|
|
32
32
|
import { adviseSkill, approvePending, lintSkill, listBuiltins, listPending, listSkills, rejectPending, skillsIndex, staleRefs } from "./skills/store.js";
|
|
33
33
|
import { applyProposal, autoConsolidate, describeProposal, proposeConsolidation, pruneCandidates } from "./skills/consolidate.js";
|
|
34
34
|
import { defaultComplete, recordOutcomes, reflect, shouldReflect } from "./skills/learn.js";
|
|
35
|
-
import { expireTrials, skillsMode } from "./skills/trial.js";
|
|
35
|
+
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
|
+
import { slmStatsLine } from "./local/stats.js";
|
|
39
|
+
import { usageCommand } from "./usage/command.js";
|
|
38
40
|
import { buildSystemPrompt, graphStatsLine, memoSystem, systemKey } from "./agent/prompt.js";
|
|
39
41
|
import { allTools } from "./tools/registry.js";
|
|
40
42
|
import { ui, c, fmtTokens } from "./ui/ui.js";
|
|
@@ -82,6 +84,53 @@ program
|
|
|
82
84
|
const lines = sessionLines(rootOf(o));
|
|
83
85
|
console.log(lines.length ? lines.join("\n") : c.muted("nenhuma sessão salva neste projeto"));
|
|
84
86
|
});
|
|
87
|
+
program
|
|
88
|
+
.command("importar [origem]")
|
|
89
|
+
.alias("import")
|
|
90
|
+
.description("Importa skills, subagentes, regras de permissão e memória do Claude Code (origem: claude-code). Sem --aplicar só mostra o que faria")
|
|
91
|
+
.option("-d, --dir <dir>", "raiz do projeto")
|
|
92
|
+
.option("--aplicar", "grava de verdade (sem isto é só uma prévia)")
|
|
93
|
+
.option("--incluir-allow", "importa também as regras allow (liberam ações sem perguntar)")
|
|
94
|
+
.option("--memoria", "importa a memória do Claude Code deste projeto como notas do grafo")
|
|
95
|
+
.option("--desfazer", "desfaz um import anterior (skills e agentes; regras e notas ficam)")
|
|
96
|
+
.action(async (origem, o) => {
|
|
97
|
+
if (origem && origem !== "claude-code") {
|
|
98
|
+
console.error(c.warn(`origem "${origem}" não suportada: por enquanto só claude-code`));
|
|
99
|
+
process.exitCode = 1;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const root = rootOf(o);
|
|
103
|
+
const { applyImport, describePlan, planImport, undoImport } = await import("./import/claude-code.js");
|
|
104
|
+
if (o.desfazer) {
|
|
105
|
+
const r = undoImport(root);
|
|
106
|
+
console.log(`OK: ${r.skills} skill(s) e ${r.agents} agente(s) removidos (skills ficam na lixeira .trash).`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const plan = planImport(root, { includeAllow: !!o.incluirAllow, includeMemory: !!o.memoria });
|
|
110
|
+
for (const l of describePlan(plan))
|
|
111
|
+
console.log(" " + l);
|
|
112
|
+
if (!o.aplicar) {
|
|
113
|
+
console.log(c.muted("\n prévia: nada foi gravado. Rode de novo com --aplicar."));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const r = applyImport(root, plan);
|
|
117
|
+
if (r.notes.length) {
|
|
118
|
+
const g = await GraphStore.open(root);
|
|
119
|
+
try {
|
|
120
|
+
for (const n of r.notes) {
|
|
121
|
+
const [{ c: has }] = await g.run("MATCH (x:Note {id:$id}) RETURN count(x) AS c", { id: n.id });
|
|
122
|
+
if (Number(has) === 0)
|
|
123
|
+
await g.run("CREATE (:Note {id:$id, kind:$k, text:$t, refs:$r, ts:$ts})", { id: n.id, k: n.kind, t: n.text, r: [], ts: Date.now() });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
await g.close();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
console.log(c.ok(`OK: ${r.skills} skill(s) em teste, ${r.pending} pendente(s) (/skills pending), ${r.agents} agente(s), ${r.rules} regra(s), ${r.notes.length} nota(s).`));
|
|
131
|
+
for (const e of r.errors)
|
|
132
|
+
console.error(c.warn(" falhou: " + e));
|
|
133
|
+
});
|
|
85
134
|
program
|
|
86
135
|
.command("slm [acao]")
|
|
87
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")
|
|
@@ -372,7 +421,7 @@ const permissionsCommand = (root) => ({
|
|
|
372
421
|
},
|
|
373
422
|
});
|
|
374
423
|
const skillsCommand = (root, io, llm) => ({
|
|
375
|
-
desc: "lista skills; pending | approve <nome> | reject <nome> | consolidate",
|
|
424
|
+
desc: "lista skills; pending | approve <nome> | reject <nome> | consolidate | stats",
|
|
376
425
|
run: async ([sub, name]) => {
|
|
377
426
|
if (sub === "consolidate") {
|
|
378
427
|
ui.info("✦ analisando a biblioteca de skills…");
|
|
@@ -449,6 +498,17 @@ const skillsCommand = (root, io, llm) => ({
|
|
|
449
498
|
}
|
|
450
499
|
return;
|
|
451
500
|
}
|
|
501
|
+
if (sub === "stats") {
|
|
502
|
+
const rows = skillStats(root);
|
|
503
|
+
if (!rows.length) {
|
|
504
|
+
ui.print(c.muted(" ainda não há skills automáticas para medir."));
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
for (const r of rows)
|
|
508
|
+
ui.print(` ${c.soft(r.by)} ${c.muted(`${r.created} criadas · ${r.promoted} promovidas · ${r.trial} em teste · ${r.archived} arquivadas · placar ${r.wins}✓/${r.losses}✗ · promoção ${r.promotionRate === null ? "sem veredito" : Math.round(r.promotionRate * 100) + "%"}`)}`);
|
|
509
|
+
ui.print(c.muted(" promoção = promovidas ÷ (promovidas + arquivadas); skills em teste ainda não contam."));
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
452
512
|
const skills = listSkills(root);
|
|
453
513
|
for (const s of skills) {
|
|
454
514
|
const stale = staleRefs(root, s);
|
|
@@ -697,8 +757,12 @@ async function buildApiBackend(root, model, io, o = {}) {
|
|
|
697
757
|
const win = contextWindowFor(agent.model);
|
|
698
758
|
const pct = Math.round((agent.contextTokens / win) * 100);
|
|
699
759
|
ui.print(` ${c.soft(`${fmtTokens(agent.contextTokens)} / ${fmtTokens(win)}`)} ${c.muted(`(${pct}%) · ${agent.messages.length} mensagens · compacta em ~${fmtTokens(compactAtFor(agent.model))}`)}`);
|
|
760
|
+
const slmLine = slmStatsLine();
|
|
761
|
+
if (slmLine)
|
|
762
|
+
ui.print(` ${c.muted(slmLine)}`);
|
|
700
763
|
},
|
|
701
764
|
},
|
|
765
|
+
"/usage": usageCommand(() => ({ ...agent.usage, costUsd: backend.cost?.() })),
|
|
702
766
|
"/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 })))); } },
|
|
703
767
|
"/learn": { desc: "extrai skills do último turno agora", run: async () => { await learn(true); } },
|
|
704
768
|
"/skills": skillsCommand(root, io, { complete: withSlm(defaultComplete), model: learnModel }),
|
|
@@ -868,6 +932,7 @@ async function buildClaudeBackend(root, io, sw, model, openGraph, resume) {
|
|
|
868
932
|
},
|
|
869
933
|
},
|
|
870
934
|
"/context": unavailable,
|
|
935
|
+
"/usage": usageCommand(() => ({ ...session.usage, costUsd: session.costUsd })),
|
|
871
936
|
"/learn": { desc: "extrai skills do último turno agora", run: async () => { await learn(true); } },
|
|
872
937
|
"/skills": skillsCommand(root, io, { complete: withSlm(claudeCliComplete), model: () => process.env.BRITO_LEARN_MODEL ?? "" }),
|
|
873
938
|
"/permissions": permissionsCommand(root),
|
package/dist/desktop/serve.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fetchPlanUsage } from "../usage/plan.js";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
@@ -407,6 +408,8 @@ export async function serveDesktop(root, resumeId) {
|
|
|
407
408
|
const skills = [...listBuiltins(root), ...listSkills(root).filter((s) => s.invoke === "manual")].map((s) => ({ name: s.name, description: s.description, kind: "skill" }));
|
|
408
409
|
data = [...BUILTIN_COMMANDS, ...skills].filter((c) => !arg || c.name.includes(arg));
|
|
409
410
|
}
|
|
411
|
+
else if (m.q === "usage")
|
|
412
|
+
data = { plan: await fetchPlanUsage({ force: arg === "force" }), session: engine.stats(), model: engine.model };
|
|
410
413
|
else if (m.q === "models")
|
|
411
414
|
data = { aliases: Object.keys(MODEL_ALIASES), profiles: Object.keys(loadProfiles().profiles) };
|
|
412
415
|
}
|
package/dist/graph/indexer.js
CHANGED
|
@@ -223,7 +223,13 @@ async function scan(root, aliases) {
|
|
|
223
223
|
const files = await sourceFiles(root);
|
|
224
224
|
const out = new Map();
|
|
225
225
|
for (const rel of files) {
|
|
226
|
-
|
|
226
|
+
let src;
|
|
227
|
+
try {
|
|
228
|
+
src = fs.readFileSync(path.join(root, rel), "utf8");
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
continue;
|
|
232
|
+
} // ilegível (OneDrive, travado, permissão): ignora
|
|
227
233
|
if (src.length > 1_000_000)
|
|
228
234
|
continue;
|
|
229
235
|
out.set(rel, { src, hash: crypto.createHash("sha1").update(src).digest("hex") + ":" + INDEXER_VERSION + ":" + aliases.forFile(rel).signature });
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { parseRule, addRule, loadRules } from "../rules.js";
|
|
6
|
+
import { NAME_RE, deleteSkill, getSkillIn, lintSkill, listSkills, savePending, validate, writeSkill } from "../skills/store.js";
|
|
7
|
+
import { stringifyFrontmatter } from "../util/frontmatter.js";
|
|
8
|
+
/**
|
|
9
|
+
* Import do Claude Code: skills, subagentes, regras de permissão e memória. Sempre em duas etapas — `planImport` só lê
|
|
10
|
+
* e decide (nada é gravado), `applyImport` grava o que o plano marcou como `import`.
|
|
11
|
+
*
|
|
12
|
+
* Princípios: nada importado vira ativo sem teste (skills entram "em teste"); credenciais nunca são lidas; `deny` entra
|
|
13
|
+
* direto (só restringe) mas `allow` exige pedido explícito; tudo importado é reversível (`undoImport`).
|
|
14
|
+
*/
|
|
15
|
+
export const IMPORT_TAG = "import:claude-code";
|
|
16
|
+
export const claudeHome = () => process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
17
|
+
/** Frontmatter YAML simples, aceitando valores em várias linhas (`>`, `|` ou continuação indentada), como nas skills do Claude. */
|
|
18
|
+
export function parseRichFrontmatter(text) {
|
|
19
|
+
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
20
|
+
if (!m)
|
|
21
|
+
return null;
|
|
22
|
+
const meta = {};
|
|
23
|
+
let key = "";
|
|
24
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
25
|
+
const kv = /^([\w-]+):\s*(.*)$/.exec(line);
|
|
26
|
+
if (kv) {
|
|
27
|
+
key = kv[1];
|
|
28
|
+
meta[key] = /^[>|][+-]?$/.test(kv[2].trim()) ? "" : kv[2].trim().replace(/^(['"])(.*)\1$/, "$2");
|
|
29
|
+
}
|
|
30
|
+
else if (key && /^\s+\S/.test(line))
|
|
31
|
+
meta[key] = `${meta[key]} ${line.trim()}`.trim();
|
|
32
|
+
}
|
|
33
|
+
return { meta, body: m[2] };
|
|
34
|
+
}
|
|
35
|
+
const readText = (file) => { try {
|
|
36
|
+
return fs.readFileSync(file, "utf8");
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
} };
|
|
41
|
+
const dirsIn = (dir) => { try {
|
|
42
|
+
return fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith(".")).map((d) => d.name);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return [];
|
|
46
|
+
} };
|
|
47
|
+
const mdIn = (dir) => { try {
|
|
48
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith(".md"));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return [];
|
|
52
|
+
} };
|
|
53
|
+
/** Descrição do Claude pode passar de 200 caracteres: corta numa fronteira de frase/palavra. */
|
|
54
|
+
function fitDescription(d) {
|
|
55
|
+
const t = d.replace(/\s+/g, " ").trim();
|
|
56
|
+
if (t.length <= 200)
|
|
57
|
+
return t;
|
|
58
|
+
const cut = t.slice(0, 197);
|
|
59
|
+
return cut.slice(0, Math.max(cut.lastIndexOf(" "), 100)) + "…";
|
|
60
|
+
}
|
|
61
|
+
function planSkills(root, home) {
|
|
62
|
+
const out = [];
|
|
63
|
+
const sources = [
|
|
64
|
+
{ dir: path.join(root, ".claude", "skills"), scope: "project", label: ".claude/skills" },
|
|
65
|
+
{ dir: path.join(home, "skills"), scope: "global", label: "~/.claude/skills" },
|
|
66
|
+
];
|
|
67
|
+
for (const { dir, scope, label } of sources) {
|
|
68
|
+
for (const folder of dirsIn(dir)) {
|
|
69
|
+
const source = `${label}/${folder}`;
|
|
70
|
+
const text = readText(path.join(dir, folder, "SKILL.md"));
|
|
71
|
+
const fm = text ? parseRichFrontmatter(text) : null;
|
|
72
|
+
const name = (fm?.meta.name || folder).trim();
|
|
73
|
+
const skip = (note) => ({ kind: "skill", name, source, action: "skip", note, scope });
|
|
74
|
+
if (!fm) {
|
|
75
|
+
out.push(skip("SKILL.md ausente ou sem frontmatter"));
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (!NAME_RE.test(name)) {
|
|
79
|
+
out.push(skip(`nome "${name}" fora do padrão kebab-case de 2 a 49 caracteres`));
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const description = fitDescription(fm.meta.description ?? "");
|
|
83
|
+
const body = fm.body.trim();
|
|
84
|
+
const input = { name, description, body, invoke: fm.meta["disable-model-invocation"] === "true" ? "manual" : "auto" };
|
|
85
|
+
try {
|
|
86
|
+
validate(input);
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
out.push(skip(String(e?.message ?? e)));
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (getSkillIn(root, name, scope)) {
|
|
93
|
+
out.push(skip("já existe uma skill com esse nome"));
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const extras = dirsIn(path.join(dir, folder)).concat(fs.readdirSync(path.join(dir, folder)).filter((f) => f !== "SKILL.md" && !fs.statSync(path.join(dir, folder, f)).isDirectory()));
|
|
97
|
+
const warnings = lintSkill(input).warnings.filter((w) => w.startsWith("comando destrutivo"));
|
|
98
|
+
const notes = [extras.length ? `arquivos de apoio não copiados: ${extras.join(", ")}` : "", fm.meta["allowed-tools"] ? "allowed-tools do Claude é ignorado" : "", warnings.length ? "tem comando destrutivo: vai para /skills pending" : ""].filter(Boolean);
|
|
99
|
+
out.push({ kind: "skill", name, source, action: "import", scope, pending: warnings.length > 0, note: notes.join("; ") || undefined, data: input });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
/** Nomes de tool do Claude → do Brito. */
|
|
105
|
+
const TOOL_MAP = { read: "read_file", write: "write_file", edit: "edit_file", multiedit: "edit_file", bash: "bash", glob: "glob", grep: "grep", notebookedit: "edit_file" };
|
|
106
|
+
const mapTool = (t) => TOOL_MAP[t.trim().toLowerCase()];
|
|
107
|
+
const READ_ONLY = ["read_file", "glob", "grep", "graph_search", "graph_callers", "graph_callees", "graph_impact", "graph_context", "graph_query", "recall"];
|
|
108
|
+
function planAgents(root, home) {
|
|
109
|
+
const out = [];
|
|
110
|
+
for (const { dir, label } of [{ dir: path.join(root, ".claude", "agents"), label: ".claude/agents" }, { dir: path.join(home, "agents"), label: "~/.claude/agents" }]) {
|
|
111
|
+
for (const f of mdIn(dir)) {
|
|
112
|
+
const source = `${label}/${f}`;
|
|
113
|
+
const fm = parseRichFrontmatter(readText(path.join(dir, f)) ?? "");
|
|
114
|
+
const name = (fm?.meta.name || f.replace(/\.md$/, "")).trim();
|
|
115
|
+
const skip = (note) => ({ kind: "agent", name, source, action: "skip", note });
|
|
116
|
+
if (!fm || !fm.meta.description || !fm.body.trim()) {
|
|
117
|
+
out.push(skip("precisa de frontmatter com description e de um corpo"));
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!NAME_RE.test(name)) {
|
|
121
|
+
out.push(skip(`nome "${name}" fora do padrão kebab-case`));
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (fs.existsSync(path.join(root, ".brito", "agents", `${name}.md`))) {
|
|
125
|
+
out.push(skip("já existe um agente com esse nome em .brito/agents"));
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const raw = (fm.meta.tools ?? "").split(",").map((t) => t.trim()).filter(Boolean);
|
|
129
|
+
const mapped = [...new Set(raw.map(mapTool).filter((t) => !!t))];
|
|
130
|
+
const dropped = raw.filter((t) => !mapTool(t));
|
|
131
|
+
const tools = !raw.length ? "all" : mapped.length ? mapped : READ_ONLY;
|
|
132
|
+
const model = /^claude-/.test(fm.meta.model ?? "") ? fm.meta.model : "";
|
|
133
|
+
const notes = [dropped.length ? `tools sem equivalente: ${dropped.join(", ")}` : "", raw.length && !mapped.length ? "nenhuma tool equivalente: ficou somente leitura" : "", fm.meta.model && !model ? `modelo "${fm.meta.model}" não mapeado: usa o padrão do Brito` : ""].filter(Boolean);
|
|
134
|
+
out.push({ kind: "agent", name, source, action: "import", note: notes.join("; ") || undefined, data: { description: fitDescription(fm.meta.description).replace(/\s+/g, " "), tools, model, body: fm.body.trim() } });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
/** `Bash(npm test:*)` → `bash(npm test:*)`; devolve null quando o Brito não tem a tool (WebFetch, mcp__…). */
|
|
140
|
+
export function mapRule(rule) {
|
|
141
|
+
const r = parseRule(rule);
|
|
142
|
+
if (!r)
|
|
143
|
+
return null;
|
|
144
|
+
const tool = mapTool(r.tool);
|
|
145
|
+
if (!tool)
|
|
146
|
+
return null;
|
|
147
|
+
if (r.pattern === undefined)
|
|
148
|
+
return tool;
|
|
149
|
+
const pattern = tool === "bash" ? r.pattern : r.pattern.replace(/^\.\//, "").replace(/^\/\//, "/");
|
|
150
|
+
return `${tool}(${pattern})`;
|
|
151
|
+
}
|
|
152
|
+
function planRules(root, home, includeAllow) {
|
|
153
|
+
const out = [];
|
|
154
|
+
const have = loadRules(root);
|
|
155
|
+
const files = [path.join(root, ".claude", "settings.json"), path.join(root, ".claude", "settings.local.json"), path.join(home, "settings.json")];
|
|
156
|
+
const seen = new Set();
|
|
157
|
+
for (const file of files) {
|
|
158
|
+
let perms;
|
|
159
|
+
try {
|
|
160
|
+
perms = JSON.parse(fs.readFileSync(file, "utf8"))?.permissions;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const source = file.startsWith(root) ? path.relative(root, file).split(path.sep).join("/") : `~/.claude/${path.basename(file)}`;
|
|
166
|
+
for (const kind of ["deny", "allow"]) {
|
|
167
|
+
for (const rule of (Array.isArray(perms?.[kind]) ? perms[kind] : []).filter((x) => typeof x === "string")) {
|
|
168
|
+
const key = `${kind}:${rule}`;
|
|
169
|
+
if (seen.has(key))
|
|
170
|
+
continue;
|
|
171
|
+
seen.add(key);
|
|
172
|
+
const mapped = mapRule(rule);
|
|
173
|
+
const item = (action, note) => ({ kind, name: rule, source, action, note, data: mapped });
|
|
174
|
+
if (!mapped)
|
|
175
|
+
out.push(item("skip", "o Brito não tem tool equivalente"));
|
|
176
|
+
else if (have[kind].includes(mapped))
|
|
177
|
+
out.push(item("skip", "já existe"));
|
|
178
|
+
else if (kind === "allow" && !includeAllow)
|
|
179
|
+
out.push(item("skip", "libera ações sem perguntar: use --incluir-allow para importar"));
|
|
180
|
+
else
|
|
181
|
+
out.push(item("import", mapped !== rule ? `vira ${mapped}` : undefined));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
/** Memória do Claude Code deste projeto: `~/.claude/projects/<caminho com - no lugar de separadores>/memory/*.md`. */
|
|
188
|
+
export const memoryDirOf = (root, home) => path.join(home, "projects", path.resolve(root).replace(/[^a-zA-Z0-9]/g, "-"), "memory");
|
|
189
|
+
const NOTE_KIND = { feedback: "convention", project: "decision", user: "learning", reference: "learning" };
|
|
190
|
+
function planMemory(root, home) {
|
|
191
|
+
const dir = memoryDirOf(root, home);
|
|
192
|
+
return mdIn(dir).filter((f) => f !== "MEMORY.md").flatMap((f) => {
|
|
193
|
+
const text = readText(path.join(dir, f)) ?? "";
|
|
194
|
+
const fm = parseRichFrontmatter(text);
|
|
195
|
+
const body = (fm?.body ?? text).trim();
|
|
196
|
+
const source = `~/.claude/projects/…/memory/${f}`;
|
|
197
|
+
if (body.length < 10)
|
|
198
|
+
return [{ kind: "memory", name: f, source, action: "skip", note: "vazia" }];
|
|
199
|
+
const type = fm?.meta.type ?? /^\s*type:\s*(\w+)/m.exec(text.slice(0, text.indexOf("\n---", 3) + 1))?.[1] ?? ""; // `type` pode estar aninhado em metadata:
|
|
200
|
+
const title = fm?.meta.name ?? f.replace(/\.md$/, "");
|
|
201
|
+
const lint = lintSkill({ name: "x", description: title, body });
|
|
202
|
+
if (lint.errors.length)
|
|
203
|
+
return [{ kind: "memory", name: f, source, action: "skip", note: `não importada: ${lint.errors.join("; ")}` }];
|
|
204
|
+
const id = `cc-${crypto.createHash("sha1").update(f + body).digest("hex").slice(0, 16)}`;
|
|
205
|
+
return [{ kind: "memory", name: title, source, action: "import", data: { id, kind: NOTE_KIND[type] ?? "learning", text: `[importada do Claude Code] ${title}: ${body}`.slice(0, 2000) } }];
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
export function planImport(root, options = {}) {
|
|
209
|
+
const home = options.claudeHome ?? claudeHome();
|
|
210
|
+
const items = [
|
|
211
|
+
...planSkills(root, home),
|
|
212
|
+
...planAgents(root, home),
|
|
213
|
+
...planRules(root, home, !!options.includeAllow),
|
|
214
|
+
...(options.includeMemory ? planMemory(root, home) : []),
|
|
215
|
+
];
|
|
216
|
+
const claudeMd = ["CLAUDE.md", path.join(".claude", "CLAUDE.md")].find((f) => fs.existsSync(path.join(root, f)));
|
|
217
|
+
if (claudeMd)
|
|
218
|
+
items.push({ kind: "info", name: claudeMd, source: claudeMd, action: "skip", note: "o Brito já lê o CLAUDE.md sozinho; nada a importar" });
|
|
219
|
+
if (fs.existsSync(path.join(root, ".mcp.json")))
|
|
220
|
+
items.push({ kind: "info", name: ".mcp.json", source: ".mcp.json", action: "skip", note: "o Brito ainda não conecta a servidores MCP externos; os servidores não foram importados" });
|
|
221
|
+
return { items, options };
|
|
222
|
+
}
|
|
223
|
+
/** Grava o plano (menos as notas de memória, que precisam do grafo: ficam em `result.notes` para quem tem a conexão). */
|
|
224
|
+
export function applyImport(root, plan) {
|
|
225
|
+
const res = { skills: 0, agents: 0, rules: 0, pending: 0, notes: [], errors: [] };
|
|
226
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
227
|
+
for (const it of plan.items) {
|
|
228
|
+
if (it.action !== "import")
|
|
229
|
+
continue;
|
|
230
|
+
try {
|
|
231
|
+
if (it.kind === "skill") {
|
|
232
|
+
const d = it.data;
|
|
233
|
+
if (it.pending) {
|
|
234
|
+
savePending(root, { ...d, reason: `importada do Claude Code (${it.source})` }, it.scope);
|
|
235
|
+
res.pending++;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const skill = { name: d.name, description: d.description, version: 1, uses: 0, created: today, updated: today, lastUsed: today, refs: [], status: "trial", origin: "auto", wins: 0, losses: 0, body: d.body, scope: it.scope ?? "project", invoke: d.invoke, model: "", lastLoss: "", by: IMPORT_TAG };
|
|
239
|
+
writeSkill(root, skill);
|
|
240
|
+
res.skills++;
|
|
241
|
+
}
|
|
242
|
+
else if (it.kind === "agent") {
|
|
243
|
+
const d = it.data;
|
|
244
|
+
const file = path.join(root, ".brito", "agents", `${it.name}.md`);
|
|
245
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
246
|
+
fs.writeFileSync(file, stringifyFrontmatter({ name: it.name, description: d.description, ...(d.tools !== "all" ? { tools: d.tools.join(", ") } : {}), ...(d.model ? { model: d.model } : {}), imported: IMPORT_TAG }, d.body));
|
|
247
|
+
res.agents++;
|
|
248
|
+
}
|
|
249
|
+
else if (it.kind === "deny" || it.kind === "allow") {
|
|
250
|
+
addRule(root, it.kind, it.data);
|
|
251
|
+
res.rules++;
|
|
252
|
+
}
|
|
253
|
+
else if (it.kind === "memory") {
|
|
254
|
+
res.notes.push(it.data);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
catch (e) {
|
|
258
|
+
res.errors.push(`${it.kind} ${it.name}: ${e?.message ?? e}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return res;
|
|
262
|
+
}
|
|
263
|
+
/** Desfaz o que o import criou: skills (vão para a lixeira do escopo) e agentes marcados. Regras e notas ficam: apague à mão. */
|
|
264
|
+
export function undoImport(root) {
|
|
265
|
+
let skills = 0, agents = 0;
|
|
266
|
+
for (const s of listSkills(root))
|
|
267
|
+
if (s.by === IMPORT_TAG && deleteSkill(root, s.name, s.scope))
|
|
268
|
+
skills++;
|
|
269
|
+
const dir = path.join(root, ".brito", "agents");
|
|
270
|
+
for (const f of mdIn(dir)) {
|
|
271
|
+
const text = readText(path.join(dir, f)) ?? "";
|
|
272
|
+
if (parseRichFrontmatter(text)?.meta.imported === IMPORT_TAG) {
|
|
273
|
+
fs.rmSync(path.join(dir, f));
|
|
274
|
+
agents++;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return { skills, agents };
|
|
278
|
+
}
|
|
279
|
+
const LABEL = { skill: "skill", agent: "agente", deny: "deny", allow: "allow", memory: "memória", info: "aviso" };
|
|
280
|
+
/** Relatório legível do plano (antes de gravar) ou do resultado. */
|
|
281
|
+
export function describePlan(plan) {
|
|
282
|
+
const lines = plan.items.map((i) => `${i.action === "import" ? "+" : "·"} ${LABEL[i.kind]} ${i.name} ← ${i.source}${i.note ? ` (${i.note})` : ""}`);
|
|
283
|
+
const n = (k) => plan.items.filter((i) => i.kind === k && i.action === "import").length;
|
|
284
|
+
lines.push(`resumo: ${n("skill")} skill(s), ${n("agent")} agente(s), ${n("deny") + n("allow")} regra(s), ${n("memory")} nota(s) a importar; ${plan.items.filter((i) => i.action === "skip" && i.kind !== "info").length} ignorado(s)`);
|
|
285
|
+
return lines;
|
|
286
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { slmGenerate, slmStatus } from "./slm.js";
|
|
2
|
+
import { slmStats } from "./stats.js";
|
|
3
|
+
export const slmCompactEnabled = () => process.env.BRITO_SLM_COMPACT !== "off";
|
|
4
|
+
/** Tamanho de cada fatia em caracteres (~2,5k tokens de JSON de conversa: cabe com folga junto do prompt e da saída). */
|
|
5
|
+
const CHUNK_CHARS = 9_000;
|
|
6
|
+
/** Teto de reduções encadeadas, para nunca entrar em laço. */
|
|
7
|
+
const MAX_ROUNDS = 3;
|
|
8
|
+
const SYSTEM = "Você resume trechos de uma conversa de um agente de programação. Preserve: objetivo, decisões, arquivos e símbolos tocados (caminhos exatos), erros encontrados, estado atual e próximos passos. Não invente nada que não esteja no texto. Seja denso e use tópicos curtos. Responda só com o resumo.";
|
|
9
|
+
/** Fatia em pedaços de até `size` caracteres, preferindo cortar em quebra de linha ou vírgula. */
|
|
10
|
+
export function chunkText(text, size = CHUNK_CHARS) {
|
|
11
|
+
const out = [];
|
|
12
|
+
let i = 0;
|
|
13
|
+
while (i < text.length) {
|
|
14
|
+
let end = Math.min(i + size, text.length);
|
|
15
|
+
if (end < text.length) {
|
|
16
|
+
const cut = Math.max(text.lastIndexOf("\n", end), text.lastIndexOf(",", end));
|
|
17
|
+
if (cut > i + size * 0.5)
|
|
18
|
+
end = cut + 1;
|
|
19
|
+
}
|
|
20
|
+
out.push(text.slice(i, end));
|
|
21
|
+
i = end;
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
/** Resume `text` com o SLM. `null` = não deu (indisponível, erro ou resposta vazia). */
|
|
26
|
+
export async function slmSummarize(text, gen = slmGenerate, signal) {
|
|
27
|
+
try {
|
|
28
|
+
let parts = chunkText(text);
|
|
29
|
+
for (let round = 0; round < MAX_ROUNDS; round++) {
|
|
30
|
+
const sums = [];
|
|
31
|
+
for (const p of parts) {
|
|
32
|
+
const s = (await gen({ system: SYSTEM, user: p, maxTokens: 700, temperature: 0.1, signal })).trim();
|
|
33
|
+
if (s)
|
|
34
|
+
sums.push(s);
|
|
35
|
+
}
|
|
36
|
+
if (!sums.length)
|
|
37
|
+
return null;
|
|
38
|
+
const joined = sums.join("\n\n");
|
|
39
|
+
if (joined.length <= CHUNK_CHARS)
|
|
40
|
+
return joined;
|
|
41
|
+
parts = chunkText(joined);
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Resumo da compactação via SLM, ou `null` quando o SLM não está pronto/habilitado ou falhou.
|
|
51
|
+
* Registra a economia em `slmStats` (tokens que o modelo principal não precisou ler).
|
|
52
|
+
*/
|
|
53
|
+
export async function slmCompactSummary(text, gen = slmGenerate) {
|
|
54
|
+
if (!slmCompactEnabled())
|
|
55
|
+
return null;
|
|
56
|
+
if (gen === slmGenerate && !(await slmStatus()).ready)
|
|
57
|
+
return null;
|
|
58
|
+
const s = await slmSummarize(text, gen);
|
|
59
|
+
if (s === null) {
|
|
60
|
+
slmStats.compactFallbacks++;
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
slmStats.compactCalls++;
|
|
64
|
+
slmStats.tokensSaved += Math.max(0, Math.ceil((text.length - s.length) / 4));
|
|
65
|
+
return s;
|
|
66
|
+
}
|
package/dist/local/slm.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { setReflector } from "../skills/by.js";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import { createRequire } from "node:module";
|
|
3
4
|
import path from "node:path";
|
|
@@ -209,10 +210,13 @@ export function withSlm(fallback) {
|
|
|
209
210
|
return async (req) => {
|
|
210
211
|
if (!slmDisabled() && (await slmStatus()).ready) {
|
|
211
212
|
try {
|
|
212
|
-
|
|
213
|
+
const r = await slmComplete(req);
|
|
214
|
+
setReflector("slm");
|
|
215
|
+
return r;
|
|
213
216
|
}
|
|
214
217
|
catch { /* cai no modelo de sempre */ }
|
|
215
218
|
}
|
|
219
|
+
setReflector(req.model || "remoto");
|
|
216
220
|
return fallback(req);
|
|
217
221
|
};
|
|
218
222
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Contadores da sessão sobre o trabalho delegado ao SLM local (para o `/context` provar a economia). */
|
|
2
|
+
export const slmStats = {
|
|
3
|
+
/** compactações resumidas pelo SLM */
|
|
4
|
+
compactCalls: 0,
|
|
5
|
+
/** vezes que o SLM falhou e o modelo de sempre resumiu */
|
|
6
|
+
compactFallbacks: 0,
|
|
7
|
+
/** estimativa: tokens de entrada que o modelo principal deixou de processar (texto original − resumo) */
|
|
8
|
+
tokensSaved: 0,
|
|
9
|
+
};
|
|
10
|
+
export const resetSlmStats = () => { slmStats.compactCalls = 0; slmStats.compactFallbacks = 0; slmStats.tokensSaved = 0; };
|
|
11
|
+
/** Linha para o `/context`; `null` quando o SLM não fez nada. */
|
|
12
|
+
export function slmStatsLine() {
|
|
13
|
+
const { compactCalls: c, compactFallbacks: f, tokensSaved: t } = slmStats;
|
|
14
|
+
if (!c && !f)
|
|
15
|
+
return null;
|
|
16
|
+
return `SLM local: ${c} compactaç${c === 1 ? "ão" : "ões"} (~${t.toLocaleString("pt-BR")} tokens poupados)${f ? `, ${f} falha${f === 1 ? "" : "s"} (caiu no Claude)` : ""}`;
|
|
17
|
+
}
|
package/dist/provider/resolve.js
CHANGED
|
@@ -7,6 +7,7 @@ const SLM_PROFILE = {
|
|
|
7
7
|
};
|
|
8
8
|
/** Janela dos modelos Claude quando nada informa outra. */
|
|
9
9
|
const CLAUDE_WINDOW = 200_000;
|
|
10
|
+
const CLAUDE_1M_WINDOW = 1_000_000;
|
|
10
11
|
const COMPACT_DEFAULT = 120_000;
|
|
11
12
|
/** Nome → provedor: perfil configurado (sem diferenciar maiúsculas) > alias do Claude > id cru do Claude. */
|
|
12
13
|
export function resolveModel(name) {
|
|
@@ -17,7 +18,8 @@ export function resolveModel(name) {
|
|
|
17
18
|
if (profile)
|
|
18
19
|
return { kind: "openai", name: key, wireModel: profile.model, label: profile.label, contextWindow: profile.contextWindow, profile };
|
|
19
20
|
const wire = MODEL_ALIASES[key] ?? name.trim();
|
|
20
|
-
|
|
21
|
+
// sufixo [1m] (ex.: claude-sonnet-5-5[1m]) = contexto de 1M tokens
|
|
22
|
+
return { kind: "anthropic", name: wire, wireModel: wire, label: wire, contextWindow: /\[1m\]/i.test(wire) ? CLAUDE_1M_WINDOW : CLAUDE_WINDOW };
|
|
21
23
|
}
|
|
22
24
|
/** Janela mostrada na barra/`/context`: BRITO_CONTEXT_WINDOW > perfil > padrão do Claude. */
|
|
23
25
|
export function contextWindowFor(name) {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quem gerou a última resposta da reflexão: `slm` (modelo local) ou o id do modelo remoto. Fica num módulo próprio
|
|
3
|
+
* para `withSlm` (que decide o motor) e `reflect` (que grava a autoria na skill) não dependerem um do outro.
|
|
4
|
+
*/
|
|
5
|
+
let last = "";
|
|
6
|
+
export const setReflector = (by) => { last = by; };
|
|
7
|
+
export const lastReflector = () => last;
|
package/dist/skills/learn.js
CHANGED
|
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|
|
3
3
|
import { execTool, toApiTools } from "../tools/registry.js";
|
|
4
4
|
import { perms } from "../permissions.js";
|
|
5
5
|
import { getSkill, listPending, listSkills, savePending, staleRefs } from "./store.js";
|
|
6
|
+
import { lastReflector } from "./by.js";
|
|
6
7
|
import { saveAuto, skillsMode, recordOutcome } from "./trial.js";
|
|
7
8
|
import { appendJournal, describeEntry, entryFromTurn, similarEntries } from "./journal.js";
|
|
8
9
|
/** Turno longo sem nenhum outro sinal só vale reflexão a partir deste tamanho. */
|
|
@@ -191,12 +192,12 @@ export function buildReflectInput(turn, ctx, previous) {
|
|
|
191
192
|
}
|
|
192
193
|
const SaveArgs = z.object({ name: z.string(), description: z.string(), body: z.string(), refs: z.array(z.string()).default([]), reason: z.string().default(""), scope: z.enum(["project", "global"]).optional() });
|
|
193
194
|
/** Modo autônomo: grava em teste (ou na fila de revisão, se for arriscado) sem perguntar ao usuário. */
|
|
194
|
-
function stageAuto(input, ctx) {
|
|
195
|
+
function stageAuto(input, ctx, by = "") {
|
|
195
196
|
const parsed = SaveArgs.safeParse(input);
|
|
196
197
|
if (!parsed.success)
|
|
197
198
|
return { saved: false, msg: "skill não salva: input inválido" };
|
|
198
199
|
try {
|
|
199
|
-
return saveAuto(ctx.root, parsed.data);
|
|
200
|
+
return saveAuto(ctx.root, parsed.data, Date.now(), by);
|
|
200
201
|
}
|
|
201
202
|
catch (e) {
|
|
202
203
|
return { saved: false, msg: `skill não salva: ${e?.message ?? e}` };
|
|
@@ -217,6 +218,7 @@ function stage(input, ctx) {
|
|
|
217
218
|
/** Roda a reflexão e salva (com aprovação do usuário, via skill_save) as skills propostas. Devolve mensagens de status. */
|
|
218
219
|
export async function reflect(turn, ctx, model, complete = defaultComplete, previous) {
|
|
219
220
|
const content = await complete({ model, maxTokens: 3000, system: SYSTEM, tools: toApiTools(new Set(["skill_save"])), user: buildReflectInput(turn, ctx, previous) });
|
|
221
|
+
const by = lastReflector() || model;
|
|
220
222
|
const out = [];
|
|
221
223
|
const saved = [];
|
|
222
224
|
let saves = 0;
|
|
@@ -226,7 +228,7 @@ export async function reflect(turn, ctx, model, complete = defaultComplete, prev
|
|
|
226
228
|
saves++;
|
|
227
229
|
const name = String(b.input?.name ?? "");
|
|
228
230
|
if (skillsMode() === "auto") {
|
|
229
|
-
const r = stageAuto(b.input, ctx);
|
|
231
|
+
const r = stageAuto(b.input, ctx, by);
|
|
230
232
|
out.push(r.msg);
|
|
231
233
|
if (r.saved)
|
|
232
234
|
saved.push(name);
|
package/dist/skills/store.js
CHANGED
|
@@ -32,12 +32,13 @@ function parse(text, scope) {
|
|
|
32
32
|
invoke: meta.invoke === "manual" ? "manual" : "auto",
|
|
33
33
|
model: meta.model ?? "",
|
|
34
34
|
lastLoss: meta.lastLoss ?? "",
|
|
35
|
+
by: meta.by ?? "",
|
|
35
36
|
};
|
|
36
37
|
}
|
|
37
38
|
export function writeSkill(root, s) {
|
|
38
39
|
const file = fileOf(root, s.scope, s.name);
|
|
39
40
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
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));
|
|
41
|
+
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.by ? { by: s.by } : {}) }, s.body));
|
|
41
42
|
}
|
|
42
43
|
/** Lê uma skill de um escopo específico. */
|
|
43
44
|
export function getSkillIn(root, name, scope) {
|
|
@@ -60,7 +61,7 @@ export function getBuiltin(name) {
|
|
|
60
61
|
if (!b)
|
|
61
62
|
return null;
|
|
62
63
|
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
|
+
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: "", by: "", builtin: true };
|
|
64
65
|
}
|
|
65
66
|
/** Embutidas ainda não sobrepostas por uma skill do usuário. */
|
|
66
67
|
export function listBuiltins(root) {
|
|
@@ -74,6 +75,23 @@ function listScope(root, scope) {
|
|
|
74
75
|
.filter((d) => d.isDirectory() && !d.name.startsWith("."))
|
|
75
76
|
.flatMap((d) => { const s = getSkillIn(root, d.name, scope); return s ? [s] : []; });
|
|
76
77
|
}
|
|
78
|
+
/** Skills arquivadas (`.trash`), lidas só para estatística: o placar delas é a evidência de que o gerador errou. */
|
|
79
|
+
export function listArchived(root) {
|
|
80
|
+
return ["project", "global"].flatMap((scope) => {
|
|
81
|
+
const dir = path.join(dirOf(root, scope), ".trash");
|
|
82
|
+
if (!fs.existsSync(dir))
|
|
83
|
+
return [];
|
|
84
|
+
return fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).flatMap((d) => {
|
|
85
|
+
try {
|
|
86
|
+
const sk = parse(fs.readFileSync(path.join(dir, d.name, "SKILL.md"), "utf8"), scope);
|
|
87
|
+
return sk ? [sk] : [];
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
77
95
|
const DAY = 86_400_000;
|
|
78
96
|
export const daysSince = (date, now = Date.now()) => Math.max(0, Math.floor((now - new Date(date).getTime()) / DAY));
|
|
79
97
|
/** Uso conta, mas skill recente também: sem isso, skills novas (uses=0) cairiam do corte do índice. */
|
|
@@ -194,6 +212,7 @@ export function saveSkill(root, input, scope, opts = {}) {
|
|
|
194
212
|
invoke: input.invoke ?? prev?.invoke ?? "auto",
|
|
195
213
|
model: input.model ?? prev?.model ?? "",
|
|
196
214
|
lastLoss: prev?.lastLoss ?? "",
|
|
215
|
+
by: prev?.by || (opts.auto ? opts.by ?? "" : ""),
|
|
197
216
|
};
|
|
198
217
|
writeSkill(root, skill);
|
|
199
218
|
return { created: !prev, version: skill.version, scope: target };
|
package/dist/skills/trial.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { daysSince, deleteSkill, getSkill, lintSkill, listSkills, savePending, saveSkill, writeSkill } from "./store.js";
|
|
1
|
+
import { daysSince, deleteSkill, getSkill, lintSkill, listArchived, listSkills, savePending, saveSkill, writeSkill } from "./store.js";
|
|
2
2
|
export const skillsMode = () => (process.env.BRITO_SKILLS === "ask" || process.env.BRITO_SKILLS === "off" ? process.env.BRITO_SKILLS : "auto");
|
|
3
3
|
/** Usos bem-sucedidos para promover (com no máximo 1 tropeço a cada 3 sucessos). */
|
|
4
4
|
export const PROMOTE_WINS = 3;
|
|
@@ -50,7 +50,7 @@ export function expireTrials(root, now = Date.now()) {
|
|
|
50
50
|
* - conteúdo com comando destrutivo também vai para a fila de revisão;
|
|
51
51
|
* - nova skill respeita o teto diário.
|
|
52
52
|
*/
|
|
53
|
-
export function saveAuto(root, input, now = Date.now()) {
|
|
53
|
+
export function saveAuto(root, input, now = Date.now(), by = "") {
|
|
54
54
|
const prev = getSkill(root, input.name);
|
|
55
55
|
if (prev && prev.origin === "manual") {
|
|
56
56
|
const scope = savePending(root, input, prev.scope);
|
|
@@ -66,6 +66,35 @@ export function saveAuto(root, input, now = Date.now()) {
|
|
|
66
66
|
if (created >= DAILY_LIMIT)
|
|
67
67
|
return { saved: false, msg: `skill ${input.name} não criada: teto de ${DAILY_LIMIT} skills automáticas por dia` };
|
|
68
68
|
}
|
|
69
|
-
const r = saveSkill(root, input, prev?.scope ?? "project", { auto: true });
|
|
69
|
+
const r = saveSkill(root, input, prev?.scope ?? "project", { auto: true, by });
|
|
70
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
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Qualidade da reflexão por modelo gerador: das skills automáticas, quantas foram promovidas e quantas arquivadas.
|
|
74
|
+
* Só `origin: auto` entra (as manuais não medem o gerador). Autoria vazia = criada antes do registro.
|
|
75
|
+
*/
|
|
76
|
+
export function skillStats(root) {
|
|
77
|
+
const rows = new Map();
|
|
78
|
+
const row = (by) => rows.get(by) ?? (rows.set(by, { by, created: 0, promoted: 0, trial: 0, archived: 0, wins: 0, losses: 0, promotionRate: null }), rows.get(by));
|
|
79
|
+
const add = (sk, kind) => {
|
|
80
|
+
const r = row(sk.by || "(sem registro)");
|
|
81
|
+
r.created++;
|
|
82
|
+
r.wins += sk.wins;
|
|
83
|
+
r.losses += sk.losses;
|
|
84
|
+
if (kind === "active")
|
|
85
|
+
r.promoted++;
|
|
86
|
+
else if (kind === "trial")
|
|
87
|
+
r.trial++;
|
|
88
|
+
else
|
|
89
|
+
r.archived++;
|
|
90
|
+
};
|
|
91
|
+
for (const s of listSkills(root))
|
|
92
|
+
if (s.origin === "auto")
|
|
93
|
+
add(s, s.status === "trial" ? "trial" : "active");
|
|
94
|
+
for (const s of listArchived(root))
|
|
95
|
+
if (s.origin === "auto")
|
|
96
|
+
add(s, "archived");
|
|
97
|
+
for (const r of rows.values())
|
|
98
|
+
r.promotionRate = r.promoted + r.archived ? r.promoted / (r.promoted + r.archived) : null;
|
|
99
|
+
return [...rows.values()].sort((a, b) => b.created - a.created);
|
|
100
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ui, c, fmtTokens } from "../ui/ui.js";
|
|
2
|
+
import { fetchPlanUsage, renderPlanLines } from "./plan.js";
|
|
3
|
+
/** `/usage` do terminal: limites do plano (como o do Claude Code) + tokens/custo desta sessão. */
|
|
4
|
+
export function usageCommand(session) {
|
|
5
|
+
return {
|
|
6
|
+
desc: "consumo do plano (5h/semana) e da sessão",
|
|
7
|
+
run: async () => {
|
|
8
|
+
const r = await fetchPlanUsage({ force: true });
|
|
9
|
+
if (r.ok) {
|
|
10
|
+
ui.print(` ${c.soft(`Plano${r.data.plan ? ` ${r.data.plan}` : ""}`)}`);
|
|
11
|
+
for (const l of renderPlanLines(r.data))
|
|
12
|
+
ui.print(` ${c.muted(l)}`);
|
|
13
|
+
}
|
|
14
|
+
else
|
|
15
|
+
ui.print(` ${c.muted(`Plano: ${r.reason}`)}`);
|
|
16
|
+
const s = session();
|
|
17
|
+
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)}` : ""}`)}`);
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
/**
|
|
6
|
+
* Consumo do plano (assinatura Claude): janela de 5h, semana e uso extra, o mesmo que o `/usage` do Claude Code mostra.
|
|
7
|
+
* Vem de um endpoint interno da Anthropic, autenticado com o token OAuth que o `claude` já guarda (somente leitura:
|
|
8
|
+
* não renovamos nem gravamos o token). Se o endpoint mudar, devolvemos um erro legível — nunca quebramos o resto.
|
|
9
|
+
*/
|
|
10
|
+
const URL_USAGE = "https://api.anthropic.com/api/oauth/usage";
|
|
11
|
+
const CACHE_MS = 60_000;
|
|
12
|
+
function tokenFromFile(file) {
|
|
13
|
+
try {
|
|
14
|
+
const o = JSON.parse(fs.readFileSync(file, "utf8"))?.claudeAiOauth;
|
|
15
|
+
return o?.accessToken ? { token: o.accessToken, plan: o.subscriptionType, expiresAt: o.expiresAt } : null;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Token do `claude`: ~/.claude/.credentials.json (Windows/Linux) ou o Keychain (macOS). */
|
|
22
|
+
export function readOAuth() {
|
|
23
|
+
const dir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
24
|
+
const f = tokenFromFile(path.join(dir, ".credentials.json"));
|
|
25
|
+
if (f || process.platform !== "darwin")
|
|
26
|
+
return f;
|
|
27
|
+
try {
|
|
28
|
+
const raw = execFileSync("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] });
|
|
29
|
+
const o = JSON.parse(raw)?.claudeAiOauth;
|
|
30
|
+
return o?.accessToken ? { token: o.accessToken, plan: o.subscriptionType, expiresAt: o.expiresAt } : null;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const LABELS = {
|
|
37
|
+
session: "Sessão (5h)", weekly_all: "Semana (todos os modelos)", weekly_opus: "Semana · Opus", weekly_sonnet: "Semana · Sonnet",
|
|
38
|
+
};
|
|
39
|
+
/** Normaliza a resposta; tolera campos ausentes e ignora o que não reconhece. */
|
|
40
|
+
export function parsePlanUsage(j, plan) {
|
|
41
|
+
const buckets = [];
|
|
42
|
+
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
|
43
|
+
if (Array.isArray(j?.limits) && j.limits.length) {
|
|
44
|
+
for (const l of j.limits) {
|
|
45
|
+
const p = num(l?.percent);
|
|
46
|
+
if (p === null)
|
|
47
|
+
continue;
|
|
48
|
+
buckets.push({ label: LABELS[l.kind] ?? String(l.kind), percent: p, resetsAt: l.resets_at ?? undefined });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
for (const [key, label] of [["five_hour", LABELS.session], ["seven_day", LABELS.weekly_all], ["seven_day_opus", LABELS.weekly_opus], ["seven_day_sonnet", LABELS.weekly_sonnet]]) {
|
|
53
|
+
const p = num(j?.[key]?.utilization);
|
|
54
|
+
if (p !== null)
|
|
55
|
+
buckets.push({ label, percent: p, resetsAt: j[key].resets_at ?? undefined });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const e = j?.extra_usage;
|
|
59
|
+
const dec = 10 ** (num(e?.decimal_places) ?? 2);
|
|
60
|
+
const extra = e?.is_enabled && num(e?.monthly_limit) !== null
|
|
61
|
+
? { used: (num(e.used_credits) ?? 0) / dec, limit: e.monthly_limit / dec, percent: num(e.utilization) ?? 0, currency: String(e.currency ?? "") }
|
|
62
|
+
: undefined;
|
|
63
|
+
return { plan, buckets, extra };
|
|
64
|
+
}
|
|
65
|
+
let cache = null;
|
|
66
|
+
export async function fetchPlanUsage(opts = {}) {
|
|
67
|
+
if (!opts.force && cache && Date.now() - cache.at < CACHE_MS)
|
|
68
|
+
return cache.result;
|
|
69
|
+
const auth = readOAuth();
|
|
70
|
+
let result;
|
|
71
|
+
if (!auth)
|
|
72
|
+
result = { ok: false, reason: "sem login do Claude por assinatura (rode `claude` e entre com /login); com ANTHROPIC_API_KEY não há limite de plano a mostrar" };
|
|
73
|
+
else if (auth.expiresAt && auth.expiresAt < Date.now())
|
|
74
|
+
result = { ok: false, reason: "o login do claude expirou: abra o `claude` uma vez para renová-lo" };
|
|
75
|
+
else {
|
|
76
|
+
try {
|
|
77
|
+
const r = await (opts.fetchImpl ?? fetch)(URL_USAGE, {
|
|
78
|
+
headers: { Authorization: `Bearer ${auth.token}`, "anthropic-beta": "oauth-2025-04-20", "User-Agent": "brito-cli" },
|
|
79
|
+
signal: AbortSignal.timeout(8000),
|
|
80
|
+
});
|
|
81
|
+
result = r.ok ? { ok: true, data: parsePlanUsage(await r.json(), auth.plan) }
|
|
82
|
+
: { ok: false, reason: r.status === 401 || r.status === 403 ? "o login do claude não vale mais: abra o `claude` para renová-lo" : `a Anthropic respondeu ${r.status}` };
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
result = { ok: false, reason: `não consegui consultar o plano (${e?.message ?? e})` };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (result.ok)
|
|
89
|
+
cache = { at: Date.now(), result }; // falhas não ficam em cache
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
/** "em 2h 10min" / "em 3d 4h" a partir de um ISO. */
|
|
93
|
+
export function resetIn(iso, now = Date.now()) {
|
|
94
|
+
const t = iso ? Date.parse(iso) : NaN;
|
|
95
|
+
if (!Number.isFinite(t))
|
|
96
|
+
return "";
|
|
97
|
+
const min = Math.max(0, Math.round((t - now) / 60_000));
|
|
98
|
+
if (min < 60)
|
|
99
|
+
return `${min}min`;
|
|
100
|
+
if (min < 48 * 60)
|
|
101
|
+
return `${Math.floor(min / 60)}h ${String(min % 60).padStart(2, "0")}min`;
|
|
102
|
+
return `${Math.floor(min / 1440)}d ${Math.floor((min % 1440) / 60)}h`;
|
|
103
|
+
}
|
|
104
|
+
/** Linhas de texto para o terminal (sem cor; quem chama pinta). */
|
|
105
|
+
export function renderPlanLines(d, now = Date.now()) {
|
|
106
|
+
const bar = (p) => { const n = Math.round(Math.min(100, Math.max(0, p)) / 5); return "█".repeat(n) + "░".repeat(20 - n); };
|
|
107
|
+
const out = d.buckets.map((b) => `${b.label.padEnd(28)} [${bar(b.percent)}] ${String(Math.round(b.percent)).padStart(3)}%${b.resetsAt ? ` · reseta em ${resetIn(b.resetsAt, now)}` : ""}`);
|
|
108
|
+
if (d.extra)
|
|
109
|
+
out.push(`${"Uso extra (mês)".padEnd(28)} [${bar(d.extra.percent)}] ${String(Math.round(d.extra.percent)).padStart(3)}% · ${d.extra.used.toFixed(2)} de ${d.extra.limit.toFixed(2)} ${d.extra.currency}`);
|
|
110
|
+
return out.length ? out : ["(a Anthropic não devolveu limites para esta conta)"];
|
|
111
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brito-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
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": {
|