brito-cli 0.7.5 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,202 @@
1
+ import { words } from "../skills/store.js";
2
+ import { slmGenerate } from "./slm.js";
3
+ const PAGE_RE = /^=== PÁGINA (\d+) ===\s*$/m;
4
+ /** Texto comparável: sem hífen de quebra de linha, sem caixa/acentos, espaços colapsados. */
5
+ export const norm = (s) => s.replace(/­\s*/g, "").replace(/-\s*\n\s*/g, "").toLowerCase().normalize("NFD").replace(/\p{M}/gu, "").replace(/[^\p{L}\p{N}]+/gu, " ").trim();
6
+ /** Comparação tolerante a OCR: só letras e dígitos (espaços no meio de palavras, hifenização e pontuação não contam). */
7
+ export const squash = (s) => norm(s).replace(/ /g, "");
8
+ const META_RE = /sum[áa]rio|[íi]ndice (remissivo|geral)|todos os direitos|catalog[aã]{1,2}o|isbn|copyright|ficha t[ée]cnica|dados internacionais|agradecimentos|impresso no brasil/i;
9
+ /**
10
+ * Tira páginas que não são conteúdo: capa, orelha e elogios (as primeiras páginas de um livro longo) e páginas de
11
+ * metadados (sumário, ficha catalográfica, direitos). Só age em textos com marcas de página e com 60 páginas ou mais:
12
+ * num capítulo curto, a primeira página É conteúdo.
13
+ */
14
+ export function stripFrontMatter(text) {
15
+ const parts = text.split(/(?=^=== PÁGINA \d+ ===\s*$)/m);
16
+ const marked = parts.filter((p) => PAGE_RE.test(p));
17
+ if (marked.length < 60)
18
+ return { text, removed: 0 };
19
+ const head = Math.min(8, Math.ceil(marked.length * 0.02));
20
+ let removed = 0;
21
+ const kept = parts.filter((p) => {
22
+ const n = Number(PAGE_RE.exec(p)?.[1] ?? NaN);
23
+ if (!n)
24
+ return true;
25
+ // cue de metadados só vale perto das pontas, para não apagar um capítulo que cita "índice" ou "direitos"
26
+ const edge = n <= head + 12 || n > marked.length - 6;
27
+ const drop = n <= head || (edge && p.length < 4000 && META_RE.test(p));
28
+ if (drop)
29
+ removed++;
30
+ return !drop;
31
+ });
32
+ return { text: kept.join(""), removed };
33
+ }
34
+ /** Divide em blocos de ~chunkChars, respeitando as marcas "=== PÁGINA N ===" quando existem. */
35
+ export function chunkText(text, chunkChars = 5000) {
36
+ const parts = text.split(/(?=^=== PÁGINA \d+ ===\s*$)/m);
37
+ const pages = parts.map((p) => ({ page: Number(PAGE_RE.exec(p)?.[1] ?? NaN) || null, text: p }));
38
+ const out = [];
39
+ let cur = [];
40
+ let size = 0;
41
+ const flush = () => { if (cur.length)
42
+ out.push({ text: cur.map((p) => p.text).join("\n"), pages: cur }); cur = []; size = 0; };
43
+ for (const pg of pages) {
44
+ // página gigante (ou texto sem marcas): fatia por tamanho
45
+ if (pg.text.length > chunkChars * 1.5) {
46
+ flush();
47
+ for (let i = 0; i < pg.text.length; i += chunkChars) {
48
+ const t = pg.text.slice(i, i + chunkChars);
49
+ out.push({ text: t, pages: [{ page: pg.page, text: t }] });
50
+ }
51
+ continue;
52
+ }
53
+ if (size + pg.text.length > chunkChars)
54
+ flush();
55
+ cur.push(pg);
56
+ size += pg.text.length;
57
+ }
58
+ flush();
59
+ return out;
60
+ }
61
+ /** Afinidade lexical (barata, sem modelo) entre o objetivo e cada bloco: escolhe onde vale gastar o SLM. */
62
+ export function rankChunks(chunks, objective) {
63
+ const q = [...new Set(words(objective))];
64
+ return chunks.map((c, index) => {
65
+ const t = norm(c.text);
66
+ let score = 0;
67
+ for (const w of q) {
68
+ const m = t.split(w).length - 1;
69
+ score += m ? 1 + Math.log(m) : 0;
70
+ }
71
+ return { ...c, score, index };
72
+ });
73
+ }
74
+ /** Página onde a citação aparece (a primeira página do bloco em que ela cabe inteira; senão a primeira em que começa). */
75
+ function pageOf(c, quoteSq) {
76
+ for (const p of c.pages)
77
+ if (squash(p.text).includes(quoteSq))
78
+ return p.page;
79
+ const head = quoteSq.slice(0, 40);
80
+ for (const p of c.pages)
81
+ if (squash(p.text).includes(head))
82
+ return p.page;
83
+ return c.pages[0]?.page ?? null;
84
+ }
85
+ /** Confere as citações propostas contra o texto do bloco. Só passa o que existe literalmente (após normalização). */
86
+ export function verifyExcerpts(c, proposed) {
87
+ const body = squash(c.text);
88
+ const kept = [];
89
+ let discarded = 0;
90
+ for (const p of proposed) {
91
+ const quote = typeof p?.quote === "string" ? p.quote.trim() : "";
92
+ const qn = squash(quote);
93
+ if (qn.length < 25 || !body.includes(qn)) {
94
+ discarded++;
95
+ continue;
96
+ }
97
+ kept.push({ quote, topic: typeof p.topic === "string" && p.topic.trim() ? p.topic.trim().slice(0, 80) : "Geral", page: pageOf(c, qn), chunk: c.index });
98
+ }
99
+ return { kept, discarded };
100
+ }
101
+ const EXTRACT_SCHEMA = {
102
+ type: "object",
103
+ properties: { excerpts: { type: "array", maxItems: 4, items: { type: "object", properties: { quote: { type: "string" }, topic: { type: "string" } }, required: ["quote", "topic"] } } },
104
+ required: ["excerpts"],
105
+ };
106
+ const OUTLINE_SCHEMA = {
107
+ type: "object",
108
+ properties: { sections: { type: "array", maxItems: 8, items: { type: "object", properties: { heading: { type: "string" }, quotes: { type: "array", items: { type: "integer" } } }, required: ["heading", "quotes"] } } },
109
+ required: ["sections"],
110
+ };
111
+ const parse = (s) => { try {
112
+ return JSON.parse(s);
113
+ }
114
+ catch {
115
+ return null;
116
+ } };
117
+ export async function digest(o) {
118
+ const gen = o.gen ?? slmGenerate;
119
+ const front = stripFrontMatter(o.text);
120
+ const ranked = rankChunks(chunkText(front.text, o.chunkChars), o.objective);
121
+ const chosen = [...ranked].filter((c) => c.score > 0).sort((a, b) => b.score - a.score).slice(0, o.maxChunks ?? 8).sort((a, b) => a.index - b.index);
122
+ const stats = { chunks: ranked.length, analyzed: chosen.length, proposed: 0, verified: 0, discarded: 0, skippedPages: front.removed };
123
+ const system = `Você extrai trechos de um texto longo para um objetivo. Responda SOMENTE com JSON {"excerpts":[{"quote","topic"}]}.
124
+ Regras: (1) "quote" é uma cópia EXATA e contínua de 1 a 3 frases do texto dado; nunca parafraseie, resuma nem corrija. (2) Escolha só trechos que ajudam o objetivo; prefira incluir um trecho duvidoso a perder um bom. (3) O texto pode ter erros de OCR (espaços no meio de palavras, hifenização): copie as PALAVRAS exatamente, corrigindo só esses espaços. (4) "topic" é um rótulo curto (2 a 5 palavras) do assunto do trecho. (5) Se nada serve, responda {"excerpts":[]}.`;
125
+ const found = [];
126
+ const seen = new Set();
127
+ let n = 0;
128
+ for (const c of chosen) {
129
+ n++;
130
+ o.onProgress?.(`analisando bloco ${n}/${chosen.length}`);
131
+ let data = null;
132
+ try {
133
+ data = parse(await gen({ system, user: `Objetivo: ${o.objective}\n\nTexto:\n${c.text}`, schema: EXTRACT_SCHEMA, maxTokens: 900, temperature: 0.1, signal: o.signal }));
134
+ }
135
+ catch (e) {
136
+ if (o.signal?.aborted)
137
+ throw e;
138
+ }
139
+ const proposed = Array.isArray(data?.excerpts) ? data.excerpts : [];
140
+ stats.proposed += proposed.length;
141
+ const { kept, discarded } = verifyExcerpts(c, proposed);
142
+ stats.discarded += discarded;
143
+ for (const k of kept) {
144
+ const key = squash(k.quote);
145
+ if (!seen.has(key)) {
146
+ seen.add(key);
147
+ found.push(k);
148
+ }
149
+ }
150
+ }
151
+ stats.verified = found.length;
152
+ // Plano: o SLM agrupa por número; índices inválidos são ignorados, e o que sobrar cai em "Outros".
153
+ let sections = [];
154
+ if (found.length > 1) {
155
+ o.onProgress?.("montando o plano");
156
+ const list = found.map((f, i) => `${i}. [${f.topic}] ${f.quote.replace(/\s+/g, " ").slice(0, 160)}`).join("\n");
157
+ try {
158
+ const plan = parse(await gen({ system: `Você organiza trechos num plano para o objetivo dado. Responda SOMENTE com JSON {"sections":[{"heading","quotes":[números]}]}. Cada número de trecho aparece em UMA seção; os títulos seguem a ordem lógica do argumento (máx. 8).`, user: `Objetivo: ${o.objective}\n\nTrechos:\n${list}`, schema: OUTLINE_SCHEMA, maxTokens: 700, temperature: 0.1, signal: o.signal }));
159
+ const used = new Set();
160
+ for (const s of Array.isArray(plan?.sections) ? plan.sections : []) {
161
+ const items = (Array.isArray(s?.quotes) ? s.quotes : []).filter((i) => Number.isInteger(i) && i >= 0 && i < found.length && !used.has(i));
162
+ items.forEach((i) => used.add(i));
163
+ if (items.length && typeof s.heading === "string")
164
+ sections.push({ heading: s.heading.trim().slice(0, 100), items });
165
+ }
166
+ const rest = found.map((_, i) => i).filter((i) => !used.has(i));
167
+ if (rest.length)
168
+ sections.push({ heading: "Outros trechos", items: rest });
169
+ }
170
+ catch (e) {
171
+ if (o.signal?.aborted)
172
+ throw e;
173
+ sections = [];
174
+ }
175
+ }
176
+ if (!sections.length) {
177
+ const byTopic = new Map();
178
+ found.forEach((f, i) => byTopic.set(f.topic, [...(byTopic.get(f.topic) ?? []), i]));
179
+ sections = [...byTopic].map(([heading, items]) => ({ heading, items }));
180
+ }
181
+ return { markdown: render(o, found, sections, stats), stats };
182
+ }
183
+ function render(o, found, sections, s) {
184
+ const cite = (e) => (e.page ? ` (p. ${e.page})` : "");
185
+ const body = sections.map((sec) => `### ${sec.heading}\n${sec.items.map((i) => `- “${found[i].quote.replace(/\s+/g, " ")}”${cite(found[i])}`).join("\n")}`).join("\n\n");
186
+ return `---
187
+ tipo: digest
188
+ fonte: ${o.source}
189
+ objetivo: ${o.objective.replace(/\n/g, " ")}
190
+ gerado-por: SLM local
191
+ ---
192
+ # Digest: ${o.objective.replace(/\n/g, " ")}
193
+
194
+ > Rascunho de um modelo pequeno. Os trechos entre aspas foram CONFERIDOS no texto-fonte (mesmas palavras; só espaços e hifenização de OCR foram normalizados; páginas conforme as marcas do arquivo). A SELEÇÃO e o agrupamento podem ser incompletos: foram analisados ${s.analyzed} de ${s.chunks} blocos${s.skippedPages ? ` (${s.skippedPages} página(s) de capa/orelha/metadados ignoradas)` : ""} (os de maior afinidade com o objetivo); ${s.discarded} citação(ões) proposta(s) foram descartadas por não existirem no texto.
195
+
196
+ ## Plano
197
+ ${sections.map((sec, i) => `${i + 1}. ${sec.heading} (${sec.items.length})`).join("\n") || "(nenhum trecho verificado)"}
198
+
199
+ ## Trechos
200
+ ${body || "(nenhum trecho verificado)"}
201
+ `;
202
+ }
@@ -0,0 +1,218 @@
1
+ import fs from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+ import { globalHome } from "../skills/store.js";
5
+ /**
6
+ * SLM embutido (llama.cpp via `node-llama-cpp`, dependência OPCIONAL): roda dentro do processo, sem servidor.
7
+ * Hoje serve às tarefas auxiliares (reflexão que propõe skills). Só entra em ação depois de `brito slm pull`
8
+ * (o download é sempre uma decisão explícita) e pode ser desligado com `BRITO_SLM=off`.
9
+ */
10
+ export const SLM_URI = "hf:unsloth/Qwen3-4B-Instruct-2507-GGUF:Q4_K_M";
11
+ export const slmUri = () => process.env.BRITO_SLM_MODEL?.trim() || SLM_URI;
12
+ export const slmDir = () => path.join(globalHome(), "models");
13
+ export const slmDisabled = () => process.env.BRITO_SLM === "off";
14
+ /** O nome do pacote vai numa variável: o tsc e o bundler não tentam resolver uma dependência opcional. */
15
+ const LIB = "node-llama-cpp";
16
+ let libPromise = null;
17
+ export function loadLib() {
18
+ return (libPromise ??= import(LIB).catch(() => null));
19
+ }
20
+ /** Arquivo do modelo já baixado (null = ainda não baixou). Um caminho local no BRITO_SLM_MODEL conta direto. */
21
+ export async function modelFile() {
22
+ const uri = slmUri();
23
+ if (!uri.startsWith("hf:") && !/^https?:/.test(uri))
24
+ return fs.existsSync(uri) ? uri : null;
25
+ const lib = await loadLib();
26
+ if (!lib)
27
+ return null;
28
+ try {
29
+ const p = await lib.resolveModelFile(uri, { directory: slmDir(), download: false, cli: false });
30
+ return p && fs.existsSync(p) ? p : null;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ export async function slmStatus() {
37
+ const lib = await loadLib();
38
+ const file = lib ? await modelFile() : null;
39
+ const disabled = slmDisabled();
40
+ return { lib: !!lib, file, uri: slmUri(), disabled, ready: !!lib && !!file && !disabled };
41
+ }
42
+ /** Baixa o modelo (retomável). `onProgress` recebe bytes baixados e total. */
43
+ export async function pullModel(onProgress, signal) {
44
+ const lib = await loadLib();
45
+ if (!lib)
46
+ throw new Error("node-llama-cpp não está instalado (é uma dependência opcional). Rode: npm install node-llama-cpp");
47
+ return lib.resolveModelFile(slmUri(), {
48
+ directory: slmDir(), download: "auto", cli: false, signal,
49
+ onProgress: (s) => onProgress?.(s.downloadedSize, s.totalSize),
50
+ });
51
+ }
52
+ /** Apaga o modelo baixado. Devolve o que foi removido. */
53
+ export async function removeModel() {
54
+ const f = await modelFile();
55
+ if (!f || !f.startsWith(slmDir()))
56
+ return null;
57
+ fs.rmSync(f, { force: true });
58
+ return f;
59
+ }
60
+ let loaded = null;
61
+ let idleTimer = null;
62
+ const IDLE_MS = 120_000;
63
+ async function ensureLoaded() {
64
+ const lib = await loadLib();
65
+ const file = await modelFile();
66
+ if (!lib || !file)
67
+ throw new Error("SLM indisponível: rode `brito slm pull`");
68
+ loaded ??= (async () => {
69
+ const llama = await lib.getLlama({ progressLogs: false, logLevel: "error" });
70
+ const model = await llama.loadModel({ modelPath: file });
71
+ return { llama, model };
72
+ })().catch((e) => { loaded = null; throw e; });
73
+ return loaded;
74
+ }
75
+ /** Libera a memória do modelo depois de um tempo parado (o processo não fica preso por causa disso). */
76
+ function scheduleUnload() {
77
+ if (idleTimer)
78
+ clearTimeout(idleTimer);
79
+ idleTimer = setTimeout(() => void unloadSlm(), IDLE_MS);
80
+ idleTimer.unref?.();
81
+ }
82
+ export async function unloadSlm() {
83
+ if (idleTimer) {
84
+ clearTimeout(idleTimer);
85
+ idleTimer = null;
86
+ }
87
+ const l = loaded;
88
+ loaded = null;
89
+ if (!l)
90
+ return;
91
+ try {
92
+ const { model, llama } = await l;
93
+ await model.dispose();
94
+ await llama.dispose();
95
+ }
96
+ catch { /* já liberado */ }
97
+ }
98
+ /**
99
+ * Uma geração isolada (sessão nova a cada chamada, sem histórico). Com `schema`, a saída é forçada a ser JSON
100
+ * válido por gramática: é o que torna modelos pequenos confiáveis em tarefas estruturadas.
101
+ */
102
+ export async function slmGenerate(o) {
103
+ const lib = await loadLib();
104
+ const { model, llama } = await ensureLoaded();
105
+ const context = await model.createContext({ contextSize: { max: 8192 } });
106
+ try {
107
+ const session = new lib.LlamaChatSession({ contextSequence: context.getSequence(), systemPrompt: o.system });
108
+ const grammar = o.schema ? await llama.createGrammarForJsonSchema(o.schema) : undefined;
109
+ return await session.prompt(o.user, { grammar, maxTokens: o.maxTokens ?? 1500, temperature: o.temperature ?? 0.2, signal: o.signal });
110
+ }
111
+ finally {
112
+ await context.dispose();
113
+ scheduleUnload();
114
+ }
115
+ }
116
+ // ------------------------------------------------------------------ chat com tools (fallback do Claude)
117
+ /** Contexto do chat do SLM (BRITO_SLM_CTX; o padrão cabe numa GPU/RAM modesta). */
118
+ export const slmChatCtx = () => { const n = Number(process.env.BRITO_SLM_CTX); return n >= 2048 ? Math.floor(n) : 16_384; };
119
+ /** Um passo de chat: devolve o texto e as chamadas de tool pedidas (sem executá-las). */
120
+ export async function slmChat(o) {
121
+ const lib = await loadLib();
122
+ const { model } = await ensureLoaded();
123
+ const context = await model.createContext({ contextSize: { max: slmChatCtx() } });
124
+ try {
125
+ const chat = new lib.LlamaChat({ contextSequence: context.getSequence() });
126
+ const functions = o.functions && Object.keys(o.functions).length
127
+ ? Object.fromEntries(Object.entries(o.functions).map(([name, f]) => [name, lib.defineChatSessionFunction({ description: f.description, params: f.params, handler: () => "" })]))
128
+ : undefined;
129
+ const res = await chat.generateResponse(o.history, {
130
+ functions, maxTokens: o.maxTokens ?? 2048, temperature: o.temperature ?? 0.3, signal: o.signal,
131
+ onTextChunk: (t) => { if (t)
132
+ o.onText?.(t); },
133
+ });
134
+ const calls = (res.functionCalls ?? []).map((c) => ({ name: String(c.functionName), input: c.params ?? {} }));
135
+ return { text: String(res.response ?? ""), calls, truncated: res.metadata?.stopReason === "maxTokens" };
136
+ }
137
+ finally {
138
+ await context.dispose();
139
+ scheduleUnload();
140
+ }
141
+ }
142
+ /** Versão síncrona de `slmStatus().ready` (o `fallbackStatus` é síncrono): biblioteca resolvível e um .gguf baixado. */
143
+ export function slmReadySync() {
144
+ if (slmDisabled())
145
+ return false;
146
+ try {
147
+ createRequire(import.meta.url).resolve(LIB);
148
+ }
149
+ catch {
150
+ return false;
151
+ }
152
+ const uri = slmUri();
153
+ if (!uri.startsWith("hf:") && !/^https?:/.test(uri))
154
+ return fs.existsSync(uri);
155
+ try {
156
+ return fs.readdirSync(slmDir()).some((f) => f.endsWith(".gguf"));
157
+ }
158
+ catch {
159
+ return false;
160
+ }
161
+ }
162
+ // ------------------------------------------------------------------ reflexão
163
+ const SKILLS_SCHEMA = {
164
+ type: "object",
165
+ properties: {
166
+ skills: {
167
+ type: "array", maxItems: 2,
168
+ items: {
169
+ type: "object",
170
+ properties: {
171
+ name: { type: "string" }, description: { type: "string" }, body: { type: "string" },
172
+ refs: { type: "array", items: { type: "string" } }, reason: { type: "string" },
173
+ },
174
+ required: ["name", "description", "body", "refs", "reason"],
175
+ },
176
+ },
177
+ },
178
+ required: ["skills"],
179
+ };
180
+ const JSON_NOTE = `
181
+
182
+ # Formato da resposta
183
+ Responda SOMENTE com JSON no formato {"skills":[{"name","description","body","refs","reason"}]}. Cada item equivale a uma chamada de skill_save. Se nada vale virar skill, responda {"skills":[]}. O name é kebab-case; o body é markdown com ## Quando usar, ## Passos, ## Armadilhas e ## Verificação.
184
+ Regras do conteúdo: (1) o name descreve o TIPO de tarefa (ex.: "ficha-de-leitura"), nunca um autor, livro, arquivo ou pessoa específicos; troque esses por parâmetros ("o autor", "o capítulo"). (2) Use só caminhos RELATIVOS à raiz do projeto, nunca caminhos absolutos nem nomes de usuário (escreva "a partir da raiz do projeto"). (3) Se a skill já existe na lista de skills existentes, reutilize o MESMO name em vez de criar outra. (4) Cada passo é uma ação concreta; a Verificação diz como saber que terminou.`;
185
+ /** Converte a saída JSON do SLM nos blocos `tool_use` que a reflexão já sabe processar. Tolera lixo: devolve [] se inválido. */
186
+ export function skillsToBlocks(json) {
187
+ let data;
188
+ try {
189
+ data = JSON.parse(json);
190
+ }
191
+ catch {
192
+ return [];
193
+ }
194
+ const list = Array.isArray(data?.skills) ? data.skills : [];
195
+ return list.flatMap((s, i) => s && typeof s.name === "string" && typeof s.description === "string" && typeof s.body === "string"
196
+ ? [{ type: "tool_use", id: `slm_${i}`, name: "skill_save", input: { name: s.name, description: s.description, body: s.body, refs: Array.isArray(s.refs) ? s.refs.filter((r) => typeof r === "string") : [], reason: typeof s.reason === "string" ? s.reason : "" } }]
197
+ : []);
198
+ }
199
+ /** `Complete` da reflexão rodando no SLM local. */
200
+ export const slmComplete = async ({ system, user, maxTokens }) => {
201
+ const text = await slmGenerate({ system: system + JSON_NOTE, user, schema: SKILLS_SCHEMA, maxTokens: Math.min(maxTokens, 2500) });
202
+ return skillsToBlocks(text);
203
+ };
204
+ /**
205
+ * Escolhe o `Complete` da reflexão: o SLM local quando está baixado e habilitado; senão o `fallback` (o modelo de
206
+ * sempre). Se o SLM falhar no meio (memória, binário), cai no `fallback` em vez de perder a reflexão.
207
+ */
208
+ export function withSlm(fallback) {
209
+ return async (req) => {
210
+ if (!slmDisabled() && (await slmStatus()).ready) {
211
+ try {
212
+ return await slmComplete(req);
213
+ }
214
+ catch { /* cai no modelo de sempre */ }
215
+ }
216
+ return fallback(req);
217
+ };
218
+ }
@@ -5,6 +5,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
5
5
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
6
6
  import "../tools/graph.js";
7
7
  import "../tools/skills.js";
8
+ import "../tools/digest.js";
8
9
  import { allTools, execTool } from "../tools/registry.js";
9
10
  import { GraphStore } from "../graph/store.js";
10
11
  import { INDEXED_EXTENSIONS, indexProject } from "../graph/indexer.js";
@@ -27,13 +28,13 @@ export const MCP_INSTRUCTIONS = [
27
28
  * tools de consulta, que são baratas e de uso frequente. As de escrita e a de aprovação (interna) continuam adiadas.
28
29
  */
29
30
  export const ALWAYS_LOAD_META = "anthropic/alwaysLoad";
30
- export const alwaysLoaded = (tool) => /^(graph_|recall$|remember$|skill_load$)/.test(tool);
31
+ export const alwaysLoaded = (tool) => /^(graph_|recall$|remember$|skill_load$|digest$)/.test(tool);
31
32
  /** Prefixo com que o claude CLI enxerga as tools deste servidor. */
32
33
  export const MCP_PREFIX = `mcp__${MCP_NAME}__`;
33
34
  /** Tool usada como --permission-prompt-tool: encaminha o pedido ao REPL do Brito. */
34
35
  export const APPROVE_TOOL = "approve";
35
36
  // fs/bash/delegate o próprio claude já tem (Read/Edit/Bash/Task); aqui vai só o que é do Brito.
36
- const exposed = () => allTools().filter((t) => /^(graph_|skill_)/.test(t.name) || t.name === "remember" || t.name === "recall");
37
+ const exposed = () => allTools().filter((t) => /^(graph_|skill_)/.test(t.name) || t.name === "remember" || t.name === "recall" || t.name === "digest");
37
38
  /** Pergunta ao REPL (socket unix) se o claude pode usar a tool; sem socket, nega. */
38
39
  function askRepl(toolName, input) {
39
40
  const sock = process.env.BRITO_APPROVE_SOCK;
@@ -19,9 +19,9 @@ export const MODE_LABEL = {
19
19
  bypass: { icon: "⚡", name: "tudo liberado", hint: "executa tudo sem perguntar" },
20
20
  };
21
21
  /** Acrescentado ao system prompt do backend de API enquanto o modo plano está ativo. */
22
- export const PLAN_NOTE = `
23
-
24
- # MODO PLANO ATIVO
22
+ export const PLAN_NOTE = `
23
+
24
+ # MODO PLANO ATIVO
25
25
  Você só pode ler, pesquisar e consultar o grafo. Edições e comandos shell estão bloqueados. Investigue, monte um plano concreto (arquivos, mudanças, riscos, como verificar) e chame exit_plan com ele. Só execute depois que o usuário aprovar.`;
26
26
  /** Texto do pedido de permissão para uma tool do claude CLI (Bash, Edit, Write…). Primeira linha = resumo. */
27
27
  export function describeClaudeTool(name, input) {
@@ -0,0 +1,32 @@
1
+ import { clampEffort, isEffort } from "../model.js";
2
+ /** Modelos com raciocínio adaptativo e `output_config.effort` (4.6 em diante). Os mais antigos seguem sem raciocínio. */
3
+ const ADAPTIVE = /claude-(fable|mythos)-|claude-(opus|sonnet|haiku)-5|claude-opus-4-[678]|claude-sonnet-4-6/;
4
+ /** Não aceitam desligar o raciocínio: o mínimo é esforço `low`. */
5
+ const ALWAYS_THINKS = /claude-(fable|mythos)-|claude-opus-5-5/;
6
+ /** Desligar é `between_tools` (`disabled` dá 400). */
7
+ const BETWEEN_TOOLS = /claude-sonnet-5-5/;
8
+ /** Os 4.6 não têm `xhigh`. */
9
+ const NO_XHIGH = /claude-(opus|sonnet)-4-6/;
10
+ /**
11
+ * Traduz o esforço do Brito (`/reasoning`) nos parâmetros da API da Anthropic para o modelo dado.
12
+ * `thinking: false` (ou esforço `none`) desliga o raciocínio onde o modelo permite; vazio = padrão do modelo.
13
+ */
14
+ export function thinkingParams(wireModel, effort, thinking = true) {
15
+ if (!ADAPTIVE.test(wireModel))
16
+ return {};
17
+ const off = !thinking || effort === "none";
18
+ if (off) {
19
+ if (ALWAYS_THINKS.test(wireModel))
20
+ return { thinking: { type: "adaptive" }, output_config: { effort: "low" } };
21
+ if (BETWEEN_TOOLS.test(wireModel))
22
+ return { thinking: { type: "between_tools" } };
23
+ return { thinking: { type: "disabled" } };
24
+ }
25
+ if (!isEffort(effort))
26
+ return { thinking: { type: "adaptive" } };
27
+ const supported = NO_XHIGH.test(wireModel) ? ["low", "medium", "high", "max"] : ["low", "medium", "high", "xhigh", "max"];
28
+ return { thinking: { type: "adaptive" }, output_config: { effort: clampEffort(effort, supported) } };
29
+ }
30
+ /** Raciocinar e responder dividem o `max_tokens`: com raciocínio ligado, um teto pequeno cortaria a resposta. */
31
+ export const THINKING_MIN_TOKENS = 16_000;
32
+ export const maxTokensFor = (p, requested) => p.thinking?.type === "adaptive" ? Math.max(requested, THINKING_MIN_TOKENS) : requested;
@@ -1,25 +1,29 @@
1
1
  import Anthropic from "@anthropic-ai/sdk";
2
2
  import { resolveModel } from "./resolve.js";
3
+ import { maxTokensFor, thinkingParams } from "./anthropic-params.js";
3
4
  /** API da Anthropic, exatamente como o Brito sempre chamou (system em cache, streaming quando há quem leia o texto). */
4
5
  export class AnthropicProvider {
5
6
  kind = "anthropic";
6
7
  client = new Anthropic();
7
8
  async send(req, hooks = {}) {
9
+ const wireModel = resolveModel(req.model).wireModel; // aliases (sonnet, opus…) viram o id real
10
+ const thinking = thinkingParams(wireModel, req.effort, req.thinking ?? true);
8
11
  const params = {
9
- model: resolveModel(req.model).wireModel, // aliases (sonnet, opus…) viram o id real
10
- max_tokens: req.maxTokens,
12
+ model: wireModel,
13
+ max_tokens: maxTokensFor(thinking, req.maxTokens),
14
+ // Dois pontos de cache: o system (estável na sessão) e, pelo `cache_control` do topo, o fim da conversa.
15
+ // Sem o segundo, cada passo do loop pagaria o histórico inteiro de novo.
16
+ cache_control: { type: "ephemeral" },
11
17
  system: [{ type: "text", text: req.system, cache_control: { type: "ephemeral" } }],
12
18
  ...(req.tools?.length ? { tools: req.tools } : {}),
19
+ ...thinking,
13
20
  messages: req.messages,
14
21
  };
15
- let msg;
16
- if (hooks.onText) {
17
- const stream = this.client.messages.stream(params, { signal: hooks.signal });
22
+ // Sempre em streaming: com `max_tokens` alto, o SDK recusa (ou deixa estourar o timeout de) uma chamada simples.
23
+ const stream = this.client.messages.stream(params, { signal: hooks.signal });
24
+ if (hooks.onText)
18
25
  stream.on("text", (t) => hooks.onText(t));
19
- msg = await stream.finalMessage();
20
- }
21
- else
22
- msg = await this.client.messages.create(params, { signal: hooks.signal });
26
+ const msg = await stream.finalMessage();
23
27
  const u = msg.usage;
24
28
  const content = msg.content.flatMap((b) => b.type === "text" ? [{ type: "text", text: b.text }] : b.type === "tool_use" ? [{ type: "tool_use", id: b.id, name: b.name, input: b.input }] : []);
25
29
  const stopReason = msg.stop_reason === "end_turn" || msg.stop_reason === "tool_use" || msg.stop_reason === "max_tokens" ? msg.stop_reason : "other";
@@ -1,5 +1,6 @@
1
1
  import Anthropic from "@anthropic-ai/sdk";
2
- import { FALLBACK_PROFILE } from "../engine.js";
2
+ import { FALLBACK_CHAIN } from "../engine.js";
3
+ import { SLM_MODEL, resolveModel } from "./resolve.js";
3
4
  export const triggersFallback = (k) => k !== "other";
4
5
  /**
5
6
  * Classifica um erro da API da Anthropic (já depois dos retries do SDK). Na dúvida, `other`: melhor não trocar do que
@@ -100,15 +101,57 @@ export function chooseModel(s, model) {
100
101
  s.active = false;
101
102
  s.reason = undefined;
102
103
  s.at = undefined;
104
+ s.until = undefined;
105
+ s.using = undefined;
103
106
  }
104
107
  /** A troca automática aconteceu. */
105
- export function activateFallback(s, reason, at = Date.now()) {
108
+ export function activateFallback(s, reason, at = Date.now(), until, using) {
106
109
  s.active = true;
107
110
  s.reason = reason;
108
111
  s.at = at;
112
+ if (until !== undefined)
113
+ s.until = until;
114
+ if (using !== undefined)
115
+ s.using = using;
116
+ }
117
+ /** Minutos de fallback por tipo de falha (BRITO_FALLBACK_MINUTES sobrepõe): cota/auth demoram mais a voltar que sobrecarga. */
118
+ const WINDOW_MIN = { quota: 60, auth: 60, rate_limit: 10, overloaded: 10, network: 10, other: 10 };
119
+ /**
120
+ * Até quando o fallback vale. Usa o horário de reset que o erro informa ("resets 3pm", "resets at 15:30") quando existe
121
+ * (no futuro e em até 12h); senão a janela por tipo de falha.
122
+ */
123
+ export function fallbackUntil(kind, why, env = process.env, now = Date.now()) {
124
+ const m = /resets?(?: at)?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i.exec(why);
125
+ if (m) {
126
+ let h = Number(m[1]);
127
+ const min = Number(m[2] ?? 0);
128
+ const ap = m[3]?.toLowerCase();
129
+ if (ap === "pm" && h < 12)
130
+ h += 12;
131
+ if (ap === "am" && h === 12)
132
+ h = 0;
133
+ if (h <= 23 && min <= 59) {
134
+ const d = new Date(now);
135
+ d.setHours(h, min, 0, 0);
136
+ if (d.getTime() <= now)
137
+ d.setDate(d.getDate() + 1);
138
+ if (d.getTime() - now <= 12 * 3_600_000)
139
+ return d.getTime() + 60_000;
140
+ }
141
+ }
142
+ const custom = Number(env.BRITO_FALLBACK_MINUTES);
143
+ return now + (custom > 0 ? custom : WINDOW_MIN[kind]) * 60_000;
144
+ }
145
+ /** A janela do fallback acabou: o próximo turno deve tentar o Claude de novo. */
146
+ export const fallbackExpired = (s, now = Date.now()) => s.active && s.until !== undefined && now >= s.until;
147
+ /** Volta ao modelo principal (sem apagar o motivo da última troca: o aviso o usa). */
148
+ export function returnToPrimary(s) {
149
+ s.active = false;
150
+ s.until = undefined;
151
+ s.using = undefined;
109
152
  }
110
153
  /** Aviso impresso na troca (modo API e claude CLI): a troca nunca é silenciosa. */
111
- export const fallbackWarning = (kind, why, label) => `⚠ Claude falhou (${kind}: ${why}) — seguindo com ${label}. /fallback para detalhes, /model <claude> para voltar.`;
154
+ export const fallbackWarning = (kind, why, label, until) => `⚠ Claude falhou (${kind}: ${why}) — seguindo com ${label}${until ? `; tento o Claude de novo às ${hhmm(until)}` : ""}. /fallback para detalhes, /model <claude> para voltar.`;
112
155
  /**
113
156
  * O turno do claude CLI que falhou deve ser refeito no qwen? Só com falha de conta/capacidade, fallback pronto e
114
157
  * ainda não usado na sessão. Interrupção não deixa `failure` (a sessão filtra), mas o `aborted` fica de garantia.
@@ -127,6 +170,19 @@ export const retryPrompt = (text, resumed = false) => `${RETRY_NOTE}${resumed ?
127
170
  */
128
171
  export const onceRestore = (restore, fallbackActive) => (fallbackActive ? null : restore);
129
172
  const hhmm = (ms) => { const d = new Date(ms); return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; };
173
+ /**
174
+ * Selo do motor para a barra de status: de onde vêm as respostas agora (nuvem, endpoint ou local) e se é fallback.
175
+ * `fallback` = true quando a troca automática está ativa (o selo vira aviso e diz quando o Claude é tentado de novo).
176
+ */
177
+ export function engineChip(model, fb) {
178
+ const r = resolveModel(model);
179
+ const where = r.kind === "anthropic" ? "☁ nuvem" : r.local ? "💻 local" : "🌐 endpoint";
180
+ const text = `${where} · ${r.label}`;
181
+ if (!fb?.active)
182
+ return { text, fallback: false };
183
+ const back = fb.until ? `; Claude volta ${hhmm(fb.until)}` : "; /model para voltar";
184
+ return { text: `⚠ FALLBACK ${text}${back}`, fallback: true };
185
+ }
130
186
  /**
131
187
  * Linhas do `/fallback`. Principal = o modelo vivo, salvo com o fallback ativo (aí é o principal original e o qwen
132
188
  * aparece em uso). `status` vem de `fallbackStatus(state.primary, env)`; `engineLabel` é o motor do principal.
@@ -134,12 +190,15 @@ const hhmm = (ms) => { const d = new Date(ms); return `${String(d.getHours()).pa
134
190
  export function renderFallback(state, liveModel, engineLabel, status) {
135
191
  const primary = state.active ? state.primary : liveModel;
136
192
  const name = status.label ?? "—";
137
- const self = !state.active && primary.trim().toLowerCase() === FALLBACK_PROFILE;
138
- const fb = state.active ? `${name} qwen · ~/.brito/config.json ● em uso`
139
- : status.ready ? `${name} qwen · ~/.brito/config.json ✓ pronto`
140
- : self ? `${name} — o modelo principal já é o qwen`
193
+ const self = !state.active && !status.ready && FALLBACK_CHAIN.includes(primary.trim().toLowerCase());
194
+ const using = state.using === SLM_MODEL ? "SLM local" : "qwen";
195
+ const src = (state.active ? state.using : status.ready ? ("name" in status ? status.name : "") : "") === SLM_MODEL ? "local · brito slm" : "qwen · ~/.brito/config.json";
196
+ const fb = state.active ? `${name} ${src} ● em uso`
197
+ : status.ready ? `${name} ${src} ✓ pronto`
198
+ : self ? `${name} — o modelo principal já é o ${primary.trim().toLowerCase() === SLM_MODEL ? "SLM" : "qwen"}`
141
199
  : `${name} ✗ ${status.reason}`;
142
- const estado = state.active ? `ATIVO desde ${hhmm(state.at ?? Date.now())} — Claude falhou: ${state.reason ?? "?"}. /model <claude> para voltar`
200
+ const volta = state.until ? ` — o Claude é tentado de novo às ${hhmm(state.until)}` : "";
201
+ const estado = state.active ? `ATIVO desde ${hhmm(state.at ?? Date.now())}${state.using ? ` (${using})` : ""} — Claude falhou: ${state.reason ?? "?"}${volta}. /model <claude> para voltar${state.until ? " agora" : ""}`
143
202
  : status.ready ? "inativo — troca automática se o Claude falhar por limite, crédito, auth ou sobrecarga"
144
203
  : self ? "não se aplica"
145
204
  : "indisponível — sem troca automática nesta sessão";