brito-cli 0.7.5 → 0.9.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.
Files changed (43) hide show
  1. package/README.md +54 -4
  2. package/README.pt-BR.md +54 -4
  3. package/dist/agent/approval.js +19 -2
  4. package/dist/agent/claude-cli.js +10 -4
  5. package/dist/agent/loop.js +38 -9
  6. package/dist/agent/prompt.js +27 -1
  7. package/dist/agent/sessions.js +98 -0
  8. package/dist/cli.js +296 -26
  9. package/dist/desktop/panels.js +93 -0
  10. package/dist/desktop/protocol.js +5 -0
  11. package/dist/desktop/serve.js +191 -23
  12. package/dist/engine.js +28 -4
  13. package/dist/local/digest.js +202 -0
  14. package/dist/local/slm.js +218 -0
  15. package/dist/mcp/server.js +36 -3
  16. package/dist/model.js +21 -0
  17. package/dist/orchestrator.js +131 -0
  18. package/dist/permissions.js +4 -0
  19. package/dist/provider/anthropic-params.js +32 -0
  20. package/dist/provider/anthropic.js +13 -9
  21. package/dist/provider/fallback.js +67 -8
  22. package/dist/provider/index.js +4 -0
  23. package/dist/provider/local.js +88 -0
  24. package/dist/provider/resolve.js +8 -0
  25. package/dist/repl.js +49 -6
  26. package/dist/rules.js +102 -0
  27. package/dist/skills/builtin.js +89 -0
  28. package/dist/skills/describe.js +3 -2
  29. package/dist/skills/learn.js +2 -2
  30. package/dist/skills/store.js +64 -8
  31. package/dist/skills/trial.js +3 -3
  32. package/dist/tools/delegate.js +31 -22
  33. package/dist/tools/digest.js +29 -0
  34. package/dist/tools/fs.js +51 -8
  35. package/dist/tools/implement.js +50 -0
  36. package/dist/tools/registry.js +10 -1
  37. package/dist/tools/skills.js +10 -6
  38. package/dist/tools/spec.js +47 -0
  39. package/dist/tools/todo.js +22 -0
  40. package/dist/ui/ui.js +12 -0
  41. package/dist/util/todos.js +4 -0
  42. package/package.json +10 -6
  43. package/dist/setup.js +0 -110
@@ -2,29 +2,38 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import readline from "node:readline";
5
+ import { execFileSync } from "node:child_process";
6
+ import { PROTOCOL } from "./protocol.js";
7
+ import { Panels, graphRootFor } from "./panels.js";
5
8
  import { randomUUID } from "node:crypto";
6
9
  import "../tools/fs.js";
7
10
  import "../tools/bash.js";
8
11
  import "../tools/graph.js";
9
12
  import "../tools/delegate.js";
10
13
  import "../tools/plan.js";
14
+ import "../tools/implement.js";
15
+ import "../tools/spec.js";
11
16
  import "../tools/skills.js";
17
+ import "../tools/digest.js";
18
+ import "../tools/todo.js";
12
19
  import { Agent } from "../agent/loop.js";
20
+ import { loadSession, repairHistory, saveSession, titleOf } from "../agent/sessions.js";
13
21
  import { ClaudeSession, hasClaudeCli, historyForFallback } from "../agent/claude-cli.js";
14
22
  import { startApprovalServer } from "../agent/approval.js";
15
23
  import { loadProjectAgents } from "../agent/agents.js";
16
- import { buildSystemPrompt, graphStatsLine } from "../agent/prompt.js";
24
+ import { buildSystemPrompt, graphStatsLine, memoSystem, systemKey } from "../agent/prompt.js";
17
25
  import { openGraphAfterClaude } from "../graph/store.js";
18
26
  import { indexProject } from "../graph/indexer.js";
19
27
  import { initialEffort, initialModel, isEffort, loadProfiles, MODEL_ALIASES } from "../model.js";
20
- import { FALLBACK_PROFILE, NO_ANTHROPIC, fallbackStatus, openEngine } from "../engine.js";
21
- import { activateFallback, chooseModel, cliShouldFallBack, fallbackWarning, newFallbackState, renderFallback, retryPrompt } from "../provider/fallback.js";
28
+ import { NO_ANTHROPIC, fallbackStatus, openEngine } from "../engine.js";
29
+ import { engineChip, activateFallback, chooseModel, cliShouldFallBack, fallbackWarning, newFallbackState, renderFallback, retryPrompt } from "../provider/fallback.js";
22
30
  import { contextWindowFor, resolveModel } from "../provider/resolve.js";
23
31
  import { PLAN_NOTE, perms } from "../permissions.js";
24
- import { skillsIndex } from "../skills/store.js";
32
+ import { ORCH_NOTE, implStatus, mainTools, orch, orchGuard, restoreOrch } from "../orchestrator.js";
33
+ import { getSkill, listBuiltins, listSkills, skillsIndex } from "../skills/store.js";
25
34
  /**
26
35
  * Backend do app desktop, controlado por JSON em linhas.
27
- * stdin → {t:"send",text} {t:"abort"} {t:"approve",id,allow} {t:"config",model?,effort?,mode?} {t:"reset"} {t:"quit"}
36
+ * stdin → {t:"send",text} {t:"abort"} {t:"approve",id,allow,scope?:"session"|"project"} {t:"config",model?,effort?,mode?,orch?} {t:"reset"} {t:"query"} {t:"action"} {t:"quit"}
28
37
  * stdout → linhas "@@{json}" (qualquer outra linha, como logs perdidos, a janela ignora).
29
38
  * Motor: perfil de endpoint (ex.: Qwen) ou ANTHROPIC_API_KEY usam o loop próprio (como o `brito` no terminal); sem
30
39
  * nada disso, o `claude` CLI (assinatura). Se o Claude cai por conta/capacidade (ou nem há conta), segue no qwen.
@@ -32,6 +41,66 @@ import { skillsIndex } from "../skills/store.js";
32
41
  */
33
42
  const MARK = "@@";
34
43
  const emit = (ev) => process.stdout.write(MARK + JSON.stringify(ev) + "\n");
44
+ /** Selo do motor (☁ nuvem / 🌐 endpoint / 💻 local, ou ⚠ FALLBACK), igual ao da barra de status da CLI. */
45
+ const chipOf = (model, fb) => { try {
46
+ return engineChip(model, fb).text;
47
+ }
48
+ catch {
49
+ return model;
50
+ } };
51
+ const BUILTIN_COMMANDS = [
52
+ { name: "clear", description: "limpa a conversa", kind: "builtin" },
53
+ { name: "fallback", description: "estado do fallback Claude → modelo local", kind: "builtin" },
54
+ { name: "help", description: "lista os comandos", kind: "builtin" },
55
+ ];
56
+ /** Arquivos do projeto para o `@`: git ls-files (respeita .gitignore); sem git, uma varredura rasa. */
57
+ let fileCache = null;
58
+ function projectFiles(root) {
59
+ if (fileCache && fileCache.root === root && Date.now() - fileCache.at < 15000)
60
+ return fileCache.files;
61
+ let files = [];
62
+ try {
63
+ files = execFileSync("git", ["ls-files", "--cached", "--others", "--exclude-standard"], { cwd: root, encoding: "utf8", maxBuffer: 16 << 20, timeout: 5000 }).split("\n").filter(Boolean);
64
+ }
65
+ catch {
66
+ const walk = (dir, depth) => {
67
+ if (depth > 4 || files.length > 5000)
68
+ return;
69
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
70
+ if (e.name.startsWith(".") || e.name === "node_modules")
71
+ continue;
72
+ const rel = path.relative(root, path.join(dir, e.name)).split(path.sep).join("/");
73
+ e.isDirectory() ? walk(path.join(dir, e.name), depth + 1) : files.push(rel);
74
+ }
75
+ };
76
+ try {
77
+ walk(root, 0);
78
+ }
79
+ catch { /* pasta ilegível */ }
80
+ }
81
+ fileCache = { root, at: Date.now(), files };
82
+ return files;
83
+ }
84
+ /** "Permitir sempre neste projeto": tipos de ação liberados, guardados por pasta em ~/.brito/desktop-allow.json. */
85
+ const allowFile = () => path.join(os.homedir(), ".brito", "desktop-allow.json");
86
+ function loadAllow(root) {
87
+ try {
88
+ return JSON.parse(fs.readFileSync(allowFile(), "utf8"))[path.resolve(root)] ?? [];
89
+ }
90
+ catch {
91
+ return [];
92
+ }
93
+ }
94
+ function saveAllow(root, kinds) {
95
+ let all = {};
96
+ try {
97
+ all = JSON.parse(fs.readFileSync(allowFile(), "utf8"));
98
+ }
99
+ catch { /* primeiro uso */ }
100
+ all[path.resolve(root)] = kinds;
101
+ fs.mkdirSync(path.dirname(allowFile()), { recursive: true });
102
+ fs.writeFileSync(allowFile(), JSON.stringify(all, null, 1));
103
+ }
35
104
  const sink = {
36
105
  turnStart: () => emit({ t: "turnStart" }),
37
106
  turnEnd: (ok = true) => emit({ t: "turnEnd", ok }),
@@ -39,7 +108,7 @@ const sink = {
39
108
  textEnd: () => emit({ t: "textEnd" }),
40
109
  thinking: (text) => emit({ t: "thinking", text }),
41
110
  toolStart: (id, name, input) => emit({ t: "toolStart", id, name, input }),
42
- toolEnd: (id, isError, output) => emit({ t: "toolEnd", id, isError, output: (output ?? "").slice(0, 4000) }),
111
+ toolEnd: (id, isError, output) => emit({ t: "toolEnd", id, isError, output: (output ?? "").slice(0, 20000) }),
43
112
  info: (msg) => emit({ t: "info", msg }),
44
113
  warn: (msg) => emit({ t: "warn", msg }),
45
114
  error: (msg) => emit({ t: "error", msg }),
@@ -47,10 +116,15 @@ const sink = {
47
116
  const apiLabel = (m) => (resolveModel(m).kind === "openai" ? "endpoint" : "API Anthropic");
48
117
  export async function serveDesktop(root, resumeId) {
49
118
  const pending = new Map();
50
- const ask = (text, opts) => perms.mode === "bypass" && !opts?.force ? Promise.resolve(true) : new Promise((resolve) => {
119
+ /** Tipos de pedido liberados com "permitir nesta sessão" (nunca vale para pedidos `force`, como aprovar plano ou skill). */
120
+ const projectAllow = new Set(loadAllow(root));
121
+ const sessionAllow = new Set(projectAllow);
122
+ emit({ t: "hello", protocol: PROTOCOL });
123
+ const kindOf = (text) => text.split(/[:\s]/, 1)[0].toLowerCase();
124
+ const ask = (text, opts) => (perms.mode === "bypass" || sessionAllow.has(kindOf(text))) && !opts?.force ? Promise.resolve(true) : new Promise((resolve) => {
51
125
  const id = randomUUID();
52
- pending.set(id, resolve);
53
- emit({ t: "approval", id, text, force: !!opts?.force });
126
+ pending.set(id, { resolve, kind: opts?.force ? "" : kindOf(text) });
127
+ emit({ t: "approval", id, text, force: !!opts?.force, kind: kindOf(text) });
54
128
  });
55
129
  // Na pasta pessoal (projeto "Home") indexar tudo seria enorme e inútil: sem índice ali.
56
130
  const isHome = path.resolve(root) === path.resolve(os.homedir());
@@ -89,13 +163,29 @@ export async function serveDesktop(root, resumeId) {
89
163
  catch { /* sem notas */ }
90
164
  const ctx = { root, graph, ask, sync, dirty: false, loadedSkills: o.loadedSkills ?? new Set(), model: () => agent.model };
91
165
  const fallback = o.fallback ?? newFallbackState(model);
92
- const agent = new Agent(() => buildSystemPrompt(root, graphStatsLine(syms, notes, "now"), skillsIndex(root)) + (perms.mode === "plan" ? PLAN_NOTE : ""), ctx, { model, sink, fallback });
166
+ const system = memoSystem(() => systemKey(root, perms.mode === "plan", syms + notes === 0) + `|${orch.on}`, () => buildSystemPrompt(root, graphStatsLine(syms, notes, "now"), skillsIndex(root)) + (perms.mode === "plan" ? PLAN_NOTE : "") + (orch.on ? ORCH_NOTE : ""));
167
+ const agent = new Agent(system, ctx, { model, sink, fallback, tools: mainTools });
93
168
  if (o.messages)
94
169
  agent.messages = o.messages;
95
170
  if (o.effort)
96
171
  agent.effort = o.effort;
172
+ if (o.session?.messages)
173
+ agent.messages = repairHistory(o.session.messages);
174
+ if (o.session?.todos)
175
+ ctx.todos = o.session.todos;
97
176
  let ac = null;
98
- const id = randomUUID();
177
+ const id = o.session?.id ?? randomUUID();
178
+ // Depois de uma troca a partir do claude CLI quem guarda a conversa é ele (o id é o dele): nada a salvar aqui.
179
+ const persist = () => {
180
+ if (o.back || !agent.messages.length)
181
+ return;
182
+ try {
183
+ saveSession(root, { id, engine: "api", model: agent.model, title: titleOf(agent.messages), messages: agent.messages, todos: ctx.todos });
184
+ }
185
+ catch (e) {
186
+ sink.warn(`não consegui salvar a sessão: ${e?.message ?? e}`);
187
+ }
188
+ };
99
189
  return {
100
190
  kind: "api", effort: "", fallback,
101
191
  // Depois da troca o id segue o do claude: a janela o guarda para retomar a conversa ao reabrir.
@@ -125,6 +215,7 @@ export async function serveDesktop(root, resumeId) {
125
215
  async send(text) {
126
216
  ac = new AbortController();
127
217
  sink.turnStart();
218
+ orchGuard(agent.model, sink.warn); // troca para o qwen (fallback ou modelo escolhido): não há mais a quem delegar
128
219
  let ok = true;
129
220
  try {
130
221
  await agent.send(text, ac.signal);
@@ -136,6 +227,7 @@ export async function serveDesktop(root, resumeId) {
136
227
  finally {
137
228
  sink.turnEnd(ok);
138
229
  ac = null;
230
+ persist();
139
231
  }
140
232
  },
141
233
  abort: () => ac?.abort(),
@@ -152,20 +244,23 @@ export async function serveDesktop(root, resumeId) {
152
244
  };
153
245
  };
154
246
  if (opening.kind !== "claude") {
155
- if (resumeId)
156
- emit({ t: "info", msg: "No modo API o histórico da conversa não é retomado ao reabrir; a tela mostra o que já foi dito, mas o modelo recomeça." });
247
+ // O id que a janela guarda é o da sessão salva em .brito/sessions: reabrir retoma o histórico de verdade.
248
+ const saved = resumeId ? loadSession(root, resumeId) : null;
249
+ const session = saved?.engine === "api" ? saved : undefined;
250
+ if (resumeId && !session)
251
+ emit({ t: "info", msg: "Esta conversa não foi salva no modo API (é de uma versão antiga ou do claude CLI): a tela mostra o que já foi dito, mas o modelo recomeça." });
157
252
  if (opening.kind === "fallback") {
158
253
  // Claude sem conta nenhuma, com o fallback pronto: a sessão já nasce no qwen (com aviso, nunca em silêncio).
159
254
  emit({ t: "warn", msg: `⚠ ${NO_ANTHROPIC} (sem ANTHROPIC_API_KEY e sem o claude CLI) — abrindo no ${opening.label}. Envie /fallback para detalhes.` });
160
255
  const fallback = newFallbackState(startModel);
161
- activateFallback(fallback, NO_ANTHROPIC);
162
- engine = await buildApiEngine(FALLBACK_PROFILE, { fallback, engineLabel: () => NO_ANTHROPIC });
256
+ activateFallback(fallback, NO_ANTHROPIC, Date.now(), undefined, opening.name);
257
+ engine = await buildApiEngine(opening.name, { fallback, engineLabel: () => NO_ANTHROPIC, session });
163
258
  }
164
259
  else
165
- engine = await buildApiEngine(startModel);
260
+ engine = await buildApiEngine(startModel, { session });
166
261
  }
167
262
  else {
168
- const approval = startApprovalServer({ ask });
263
+ const approval = startApprovalServer({ ask }, root);
169
264
  closeApproval = approval.close;
170
265
  // O banco aceita um único processo com escrita: indexa, lê as stats e solta o banco para o servidor MCP.
171
266
  if (!isHome)
@@ -228,7 +323,7 @@ export async function serveDesktop(root, resumeId) {
228
323
  sink.error(`não consegui abrir o ${status.label}: ${e?.message ?? e}`);
229
324
  return;
230
325
  }
231
- activateFallback(fallback, `${f.kind} (${f.message})`);
326
+ activateFallback(fallback, `${f.kind} (${f.message})`, Date.now(), undefined, status.name);
232
327
  engine = api;
233
328
  ready();
234
329
  await api.send(retryPrompt(text, resumed));
@@ -241,10 +336,13 @@ export async function serveDesktop(root, resumeId) {
241
336
  engine = claude;
242
337
  }
243
338
  let busy = false;
244
- const ready = () => emit({ t: "ready", engine: engine.kind, sessionId: engine.sessionId, model: engine.model, effort: engine.effort, mode: perms.mode, symbols: syms, notes, root, fallback: engine.fallback.active });
339
+ restoreOrch(engine.model, (m) => emit({ t: "warn", msg: m }), (m) => emit({ t: "info", msg: m }));
340
+ const panels = new Panels(root, graphRootFor(root), isHome);
341
+ const ready = () => emit({ t: "ready", engine: engine.kind, sessionId: engine.sessionId, model: engine.model, effort: engine.effort, mode: perms.mode, orch: orch.on, symbols: syms, notes, root, fallback: engine.fallback.active, chip: chipOf(engine.model, engine.fallback) });
245
342
  ready();
246
343
  const rl = readline.createInterface({ input: process.stdin });
247
344
  const quit = async () => { closeApproval(); try {
345
+ await panels.close();
248
346
  await engine.close();
249
347
  }
250
348
  catch { /* saindo */ } process.exit(0); };
@@ -271,23 +369,83 @@ export async function serveDesktop(root, resumeId) {
271
369
  sink.turnEnd(true);
272
370
  return;
273
371
  }
372
+ // /nome de uma skill: mesma invocação do REPL (skill_load + contexto).
373
+ let text = String(m.text ?? "");
374
+ const [cmd, ...args] = text.trim().split(/\s+/);
375
+ const sk = cmd.startsWith("/") && !cmd.slice(1).includes("/") ? getSkill(root, cmd.slice(1).toLowerCase()) : null;
376
+ if (sk) {
377
+ const ctxText = args.join(" ");
378
+ text = `Invoquei a skill ${sk.name}. Chame skill_load("${sk.name}") agora e siga-a.${ctxText ? `\n\nContexto: ${ctxText}` : "\n\n(Sem contexto extra: comece perguntando sobre o que quero trabalhar.)"}`;
379
+ }
274
380
  busy = true;
275
381
  try {
276
- await engine.send(String(m.text ?? ""));
382
+ await engine.send(text);
277
383
  }
278
384
  finally {
279
385
  busy = false;
280
- emit({ t: "usage", ...engine.stats(), model: engine.model, sessionId: engine.sessionId, fallback: engine.fallback.active });
386
+ emit({ t: "usage", ...engine.stats(), model: engine.model, sessionId: engine.sessionId, fallback: engine.fallback.active, chip: chipOf(engine.model, engine.fallback) });
281
387
  }
282
388
  break;
283
389
  }
284
390
  case "abort":
285
391
  engine.abort();
286
392
  break;
287
- case "approve":
288
- pending.get(m.id)?.(!!m.allow);
393
+ case "query": {
394
+ let data = [];
395
+ try {
396
+ const arg = String(m.arg ?? "").toLowerCase();
397
+ if (["skills", "skill", "agents", "symbols", "symbol", "notes"].includes(m.q)) {
398
+ const raw = String(m.arg ?? "");
399
+ data = m.q === "skills" ? panels.skills() : m.q === "skill" ? panels.skill(raw) : m.q === "agents" ? panels.agents()
400
+ : m.q === "symbols" ? await panels.symbols(raw) : m.q === "symbol" ? await panels.symbol(raw) : await panels.notes(raw);
401
+ emit({ t: "result", id: m.id, data });
402
+ break;
403
+ }
404
+ if (m.q === "files")
405
+ data = projectFiles(root).filter((f) => !f.startsWith(".brito/") && (!arg || f.toLowerCase().includes(arg))).sort((a, b) => a.length - b.length).slice(0, 30);
406
+ else if (m.q === "commands") {
407
+ const skills = [...listBuiltins(root), ...listSkills(root).filter((s) => s.invoke === "manual")].map((s) => ({ name: s.name, description: s.description, kind: "skill" }));
408
+ data = [...BUILTIN_COMMANDS, ...skills].filter((c) => !arg || c.name.includes(arg));
409
+ }
410
+ else if (m.q === "models")
411
+ data = { aliases: Object.keys(MODEL_ALIASES), profiles: Object.keys(loadProfiles().profiles) };
412
+ }
413
+ catch (e) {
414
+ sink.warn(`consulta ${m.q} falhou: ${e?.message ?? e}`);
415
+ }
416
+ emit({ t: "result", id: m.id, data });
417
+ break;
418
+ }
419
+ case "action": {
420
+ try {
421
+ const arg = String(m.arg ?? "");
422
+ const msg = m.op === "graph.reindex" ? `grafo reindexado (${(await panels.reindex()).symbols} símbolos)`
423
+ : m.op === "note.delete" ? await panels.deleteNote(arg)
424
+ : panels.skillAction(String(m.op).replace(/^skill\./, ""), arg);
425
+ emit({ t: "result", id: m.id, data: { ok: true, msg } });
426
+ }
427
+ catch (e) {
428
+ emit({ t: "result", id: m.id, data: { ok: false, msg: e?.message ?? String(e) } });
429
+ }
430
+ break;
431
+ }
432
+ case "approve": {
433
+ const p = pending.get(m.id);
434
+ if (p && m.allow && (m.scope === "session" || m.scope === "project") && p.kind) {
435
+ sessionAllow.add(p.kind);
436
+ if (m.scope === "project")
437
+ try {
438
+ projectAllow.add(p.kind);
439
+ saveAllow(root, [...projectAllow]);
440
+ }
441
+ catch (e) {
442
+ sink.warn(`não consegui salvar a permissão: ${e?.message ?? e}`);
443
+ }
444
+ }
445
+ p?.resolve(!!m.allow);
289
446
  pending.delete(m.id);
290
447
  break;
448
+ }
291
449
  case "config": {
292
450
  if (typeof m.model === "string")
293
451
  engine.setModel(m.model ? MODEL_ALIASES[m.model] ?? m.model : ""); // vazio = modelo/perfil salvo
@@ -295,6 +453,16 @@ export async function serveDesktop(root, resumeId) {
295
453
  engine.effort = isEffort(m.effort) ? m.effort : "";
296
454
  if (["manual", "auto", "plan", "bypass"].includes(m.mode))
297
455
  perms.mode = m.mode;
456
+ // "on" liga o modo orquestrador (se o implementador estiver pronto); "" desliga.
457
+ if (m.orch === "")
458
+ orch.on = false;
459
+ else if (m.orch === "on") {
460
+ const s = implStatus(engine.model, orch.model, process.env);
461
+ if (s.ready)
462
+ orch.on = true;
463
+ else
464
+ sink.warn(`modo orquestrador não ligou: ${s.reason}`);
465
+ }
298
466
  ready();
299
467
  break;
300
468
  }
package/dist/engine.js CHANGED
@@ -1,13 +1,35 @@
1
1
  import { MODEL_ALIASES, loadProfiles } from "./model.js";
2
2
  import { missingKeyMessage } from "./provider/openai-compat.js";
3
- import { resolveModel } from "./provider/resolve.js";
4
- /** O fallback é sempre este perfil de `providers` (não configurável). */
3
+ import { slmReadySync } from "./local/slm.js";
4
+ import { SLM_MODEL, resolveModel } from "./provider/resolve.js";
5
+ /** O primeiro fallback é sempre este perfil de `providers` (não configurável). */
5
6
  export const FALLBACK_PROFILE = "qwen";
7
+ /** Cadeia: Claude → qwen → SLM local (último recurso, sem rede nem conta). */
8
+ export const FALLBACK_CHAIN = [FALLBACK_PROFILE, SLM_MODEL];
6
9
  /**
7
10
  * Pronto só se o perfil existe, é válido, tem a chave no ambiente e o principal não é ele mesmo. Relê o config a
8
11
  * cada chamada, como os perfis.
9
12
  */
10
- export function fallbackStatus(primary, env) {
13
+ export function fallbackStatus(primary, env, skip = []) {
14
+ const q = skip.includes(FALLBACK_PROFILE) ? { ready: false, reason: "o qwen já foi usado nesta troca" } : qwenStatus(primary, env);
15
+ if (q.ready)
16
+ return q;
17
+ const slm = slmFallbackStatus(primary, env, skip);
18
+ return slm.ready ? slm : q;
19
+ }
20
+ /** O SLM local como fallback: baixado (`brito slm pull`), habilitado e não é o próprio principal. */
21
+ function slmFallbackStatus(primary, env, skip) {
22
+ if (skip.includes(SLM_MODEL))
23
+ return { ready: false, reason: "o SLM já foi usado nesta troca" };
24
+ if (primary.trim().toLowerCase() === SLM_MODEL)
25
+ return { ready: false, reason: "o modelo principal já é o SLM" };
26
+ if (env.BRITO_SLM_FALLBACK === "off")
27
+ return { ready: false, reason: "BRITO_SLM_FALLBACK=off" };
28
+ if (!slmReadySync())
29
+ return { ready: false, reason: "SLM local indisponível (rode `brito slm pull`)" };
30
+ return { ready: true, name: SLM_MODEL, label: resolveModel(SLM_MODEL).label };
31
+ }
32
+ function qwenStatus(primary, env) {
11
33
  const { profiles, errors, rejected } = loadProfiles();
12
34
  const p = profiles[FALLBACK_PROFILE];
13
35
  const label = p?.label;
@@ -36,6 +58,8 @@ export function pickEngine(model, env, hasCli) {
36
58
  const why = loadProfiles().errors.find((e) => e.toLowerCase().includes(`"${name}"`)) ?? "";
37
59
  return { kind: "error", message: `o perfil "${name}" em ~/.brito/config.json está inválido (${why}); corrija antes de usar` };
38
60
  }
61
+ if (r.local)
62
+ return slmReadySync() ? { kind: "api" } : { kind: "error", message: "o SLM local não está pronto: rode `brito slm pull`" };
39
63
  if (r.kind === "openai")
40
64
  return env[r.profile.apiKeyEnv]?.trim() ? { kind: "api" } : { kind: "error", message: missingKeyMessage(r.profile) };
41
65
  if (env.ANTHROPIC_API_KEY)
@@ -56,7 +80,7 @@ export function openEngine(model, env, hasCli) {
56
80
  if (e.kind !== "error" || resolveModel(model).kind !== "anthropic" || loadProfiles().rejected.includes(model.trim().toLowerCase()))
57
81
  return e;
58
82
  const s = fallbackStatus(model, env);
59
- return s.ready ? { kind: "fallback", label: s.label } : { kind: "error", message: `${e.message}\nFallback (qwen) indisponível: ${s.reason}.` };
83
+ return s.ready ? { kind: "fallback", label: s.label, name: s.name } : { kind: "error", message: `${e.message}\nFallback (qwen) indisponível: ${s.reason}.` };
60
84
  }
61
85
  /**
62
86
  * Modelo de um subagente. BRITO_EXPLORE_MODEL manda no `explore`. Com o agente principal num endpoint, subagente
@@ -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
+ }