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
package/dist/cli.js CHANGED
@@ -7,26 +7,35 @@ import "./tools/bash.js";
7
7
  import "./tools/graph.js";
8
8
  import "./tools/delegate.js";
9
9
  import "./tools/plan.js";
10
+ import "./tools/implement.js";
11
+ import "./tools/spec.js";
12
+ import "./tools/todo.js";
10
13
  import fs from "node:fs";
14
+ import { randomUUID } from "node:crypto";
15
+ import { latestSession, listSessions, repairHistory, saveSession, titleOf } from "./agent/sessions.js";
11
16
  import { PLAN_NOTE, perms } from "./permissions.js";
17
+ import { ORCH_NOTE, implStatus, mainTools, orch, orchGuard, restoreOrch } from "./orchestrator.js";
12
18
  import { loadProjectAgents, listAgents } from "./agent/agents.js";
13
19
  import { GraphLockedError, GraphStore, openGraphAfterClaude } from "./graph/store.js";
14
20
  import { indexProject } from "./graph/indexer.js";
15
21
  import { Agent } from "./agent/loop.js";
16
- import { EFFORTS, MODEL_ALIASES, modelHelp, effortLabel, initialEffort, initialModel, loadProfiles, isEffort, loadReasoningDisplay, parseModelArgs, saveEffort, saveModel, saveReasoningDisplay } from "./model.js";
22
+ import { EFFORTS, MODEL_ALIASES, modelHelp, effortLabel, initialEffort, initialModel, loadProfiles, loadSavedOrch, isEffort, loadReasoningDisplay, parseModelArgs, saveEffort, saveImplModel, saveModel, saveOrch, saveReasoningDisplay } from "./model.js";
17
23
  import { hasClaudeCli, historyForFallback, ClaudeSession } from "./agent/claude-cli.js";
18
- import { FALLBACK_PROFILE, NO_ANTHROPIC, fallbackStatus, learnerModel, openEngine } from "./engine.js";
24
+ import { NO_ANTHROPIC, fallbackStatus, learnerModel, openEngine } from "./engine.js";
19
25
  import { activateFallback, chooseModel, cliShouldFallBack, fallbackWarning, newFallbackState, onceRestore, renderFallback, retryPrompt } from "./provider/fallback.js";
20
26
  import { compactAtFor, contextWindowFor, resolveModel } from "./provider/resolve.js";
21
27
  import { startApprovalServer } from "./agent/approval.js";
28
+ import { addRule, loadRules, projectSettingsPath } from "./rules.js";
22
29
  import { serveMcp } from "./mcp/server.js";
23
30
  import "./tools/skills.js";
24
- import { approvePending, lintSkill, listPending, listSkills, rejectPending, skillsIndex, staleRefs } from "./skills/store.js";
31
+ import "./tools/digest.js";
32
+ import { adviseSkill, approvePending, lintSkill, listBuiltins, listPending, listSkills, rejectPending, skillsIndex, staleRefs } from "./skills/store.js";
25
33
  import { applyProposal, autoConsolidate, describeProposal, proposeConsolidation, pruneCandidates } from "./skills/consolidate.js";
26
34
  import { defaultComplete, recordOutcomes, reflect, shouldReflect } from "./skills/learn.js";
27
35
  import { expireTrials, skillsMode } from "./skills/trial.js";
28
36
  import { claudeCliComplete } from "./skills/learn-cli.js";
29
- import { buildSystemPrompt, graphStatsLine } from "./agent/prompt.js";
37
+ import { pullModel, removeModel, slmStatus, withSlm } from "./local/slm.js";
38
+ import { buildSystemPrompt, graphStatsLine, memoSystem, systemKey } from "./agent/prompt.js";
30
39
  import { allTools } from "./tools/registry.js";
31
40
  import { ui, c, fmtTokens } from "./ui/ui.js";
32
41
  import { runRepl, switchableBackend } from "./repl.js";
@@ -64,6 +73,55 @@ program
64
73
  else
65
74
  console.log(`OK: ${r.found} nota(s) no banco antigo, ${r.added} copiada(s), ${r.skipped} já existiam. O .brito/graph.kuzu foi mantido; apague-o quando quiser.`);
66
75
  });
76
+ program
77
+ .command("sessoes")
78
+ .alias("sessions")
79
+ .description("Lista as sessões salvas deste projeto (retome com brito --resume <id> ou brito -c)")
80
+ .option("-d, --dir <dir>", "raiz do projeto")
81
+ .action((o) => {
82
+ const lines = sessionLines(rootOf(o));
83
+ console.log(lines.length ? lines.join("\n") : c.muted("nenhuma sessão salva neste projeto"));
84
+ });
85
+ program
86
+ .command("slm [acao]")
87
+ .description("SLM local embutido (llama.cpp): status | pull (baixa o modelo) | rm (apaga). Usado na reflexão de skills; desligue com BRITO_SLM=off")
88
+ .action(async (acao) => {
89
+ const st = await slmStatus();
90
+ if (acao === "pull") {
91
+ if (!st.lib) {
92
+ console.error(c.err("node-llama-cpp não está instalado (dependência opcional). Rode: npm install node-llama-cpp"));
93
+ process.exit(1);
94
+ }
95
+ if (st.file) {
96
+ console.log(c.ok("✦ ") + `modelo já baixado: ${st.file}`);
97
+ return;
98
+ }
99
+ console.log(`baixando ${st.uri} (alguns GB; é retomável com Ctrl+C e rodar de novo)…`);
100
+ let last = -1;
101
+ const file = await pullModel((done, total) => {
102
+ const pct = total ? Math.floor((done / total) * 100) : 0;
103
+ if (pct >= last + 5 || pct === 100) {
104
+ last = pct;
105
+ process.stdout.write(`\r${pct}% (${(done / 1e9).toFixed(2)}/${(total / 1e9).toFixed(2)} GB)`);
106
+ }
107
+ });
108
+ console.log("\n" + c.ok("✦ ") + `pronto: ${file}`);
109
+ return;
110
+ }
111
+ if (acao === "rm") {
112
+ const f = await removeModel();
113
+ console.log(f ? c.ok("✦ ") + `removido: ${f}` : c.muted("nenhum modelo baixado"));
114
+ return;
115
+ }
116
+ if (acao && acao !== "status") {
117
+ console.error(c.err("uso: brito slm [status|pull|rm]"));
118
+ process.exit(1);
119
+ }
120
+ console.log(` modelo ${st.uri}`);
121
+ console.log(` biblioteca ${st.lib ? c.ok("node-llama-cpp ok") : c.warn("node-llama-cpp ausente (npm install node-llama-cpp)")}`);
122
+ console.log(` arquivo ${st.file ?? c.muted("não baixado: brito slm pull")}`);
123
+ console.log(` estado ${st.disabled ? c.warn("desligado (BRITO_SLM=off)") : st.ready ? c.ok("ativo: a reflexão de skills roda localmente") : c.muted("inativo")}`);
124
+ });
67
125
  program
68
126
  .command("query <cypher>")
69
127
  .description("Executa Cypher no grafo")
@@ -282,6 +340,37 @@ function modelCommand(get, set, note, veto = () => null, divert) {
282
340
  };
283
341
  }
284
342
  /** `/skills`: lista, revisa propostas pendentes e consolida a biblioteca. Vale para os dois backends. */
343
+ /** `/permissions` lista as regras; `allow <regra>` / `deny <regra>` grava no `.brito/settings.json` do projeto. */
344
+ const permissionsCommand = (root) => ({
345
+ desc: "regras de permissão; allow <regra> | deny <regra>",
346
+ run: async ([sub, ...rest]) => {
347
+ if (sub === "allow" || sub === "deny") {
348
+ const rule = rest.join(" ").trim();
349
+ if (!rule) {
350
+ ui.print(c.muted(` uso: /permissions ${sub} bash(npm test:*) · /permissions ${sub} edit_file(src/**)`));
351
+ return;
352
+ }
353
+ try {
354
+ addRule(root, sub, rule);
355
+ ui.info(`${sub}: ${rule} → ${path.relative(root, projectSettingsPath(root))}`);
356
+ }
357
+ catch (e) {
358
+ ui.error(e?.message ?? String(e));
359
+ }
360
+ return;
361
+ }
362
+ const r = loadRules(root);
363
+ if (!r.allow.length && !r.deny.length) {
364
+ ui.print(c.muted(" nenhuma regra. Ex.: /permissions allow bash(npm test:*) · /permissions deny bash(git push:*)"));
365
+ return;
366
+ }
367
+ for (const a of r.allow)
368
+ ui.print(` ${c.soft("allow")} ${a}`);
369
+ for (const d of r.deny)
370
+ ui.print(` ${c.soft("deny ")} ${d}`);
371
+ ui.print(c.muted(" ~/.brito/settings.json · .brito/settings.json · .brito/settings.local.json"));
372
+ },
373
+ });
285
374
  const skillsCommand = (root, io, llm) => ({
286
375
  desc: "lista skills; pending | approve <nome> | reject <nome> | consolidate",
287
376
  run: async ([sub, name]) => {
@@ -331,7 +420,7 @@ const skillsCommand = (root, io, llm) => ({
331
420
  }
332
421
  ui.print(` ${c.soft(p.name)} ${c.muted(`(${p.scope}) ${p.description}`)}\n${p.body.split("\n").map((l) => ` │ ${l}`).join("\n")}`);
333
422
  // Avisos do lint (ex.: comando destrutivo) ficam à vista de quem aprova, não só de quem propõe.
334
- for (const w of lintSkill(p).warnings)
423
+ for (const w of [...lintSkill(p).warnings, ...adviseSkill(p)])
335
424
  ui.print(c.warn(` ⚠ ${w}`));
336
425
  if (!(await io.ask(`aprovar a skill ${name}?`, { force: true }))) {
337
426
  ui.info("mantida como pendente");
@@ -363,8 +452,10 @@ const skillsCommand = (root, io, llm) => ({
363
452
  const skills = listSkills(root);
364
453
  for (const s of skills) {
365
454
  const stale = staleRefs(root, s);
366
- ui.print(` ${c.soft(s.name)} ${c.muted(`(${s.scope}) v${s.version} · ${s.uses} usos${s.origin === "auto" ? ` · ${s.status === "trial" ? "em teste" : "automática"} ${s.wins}✓/${s.losses}✗` : ""} · último uso ${s.lastUsed} · ${s.description}`)}${stale.length ? " " + c.warn(`⚠ refs obsoletas: ${stale.join(", ")}`) : ""}`);
455
+ ui.print(` ${c.soft(s.name)} ${c.muted(`(${s.scope}) v${s.version} · ${s.uses} usos${s.origin === "auto" ? ` · ${s.status === "trial" ? "em teste" : "automática"} ${s.wins}✓/${s.losses}✗` : ""}${s.invoke === "manual" ? " · manual (/" + s.name + ")" : ""}${s.model ? ` · modelo: ${s.model}` : ""}${s.lastLoss ? ` · último tropeço: ${s.lastLoss}` : ""} · último uso ${s.lastUsed} · ${s.description}`)}${stale.length ? " " + c.warn(`⚠ refs obsoletas: ${stale.join(", ")}`) : ""}`);
367
456
  }
457
+ for (const s of listBuiltins(root))
458
+ ui.print(` ${c.soft("/" + s.name)} ${c.muted(`(embutida) ${s.description}`)}`);
368
459
  const prune = pruneCandidates(skills);
369
460
  if (prune.length)
370
461
  ui.info(`${prune.length} candidata(s) a poda (sem uso há mais de 30 dias): /skills consolidate`);
@@ -413,6 +504,106 @@ function makeLearner(getTurns, ctx, complete, getModel) {
413
504
  }
414
505
  };
415
506
  }
507
+ /**
508
+ * `/orquestrar [on|off] [modelo <perfil>] [--global]`: o principal planeja e revisa, o perfil implementa pela tool
509
+ * `implement`. `on`/`off` e `modelo` combinam no mesmo comando; `--global` grava o que foi pedido no config
510
+ * (`orchestrator` e `implModel`), para valer ao abrir o Brito de novo.
511
+ */
512
+ function orchestratorCommand(main) {
513
+ const usage = " uso: /orquestrar on|off [modelo <perfil>] [--global] · /orquestrar modelo <perfil> [--global]";
514
+ return {
515
+ desc: "modo orquestrador: o modelo principal planeja e revisa, um perfil (qwen) implementa",
516
+ run: async (args) => {
517
+ const global = args.includes("--global");
518
+ const words = args.filter((a) => !a.startsWith("--"));
519
+ const st = () => implStatus(main(), orch.model, process.env);
520
+ if (!words.length) {
521
+ const s = st();
522
+ ui.print(` ${c.soft("modo")} ${c.muted(`${orch.on ? "ligado" : "desligado"}${loadSavedOrch() ? " · liga ao abrir (salvo)" : ""}`)}`);
523
+ ui.print(` ${c.soft("implementador")} ${c.muted(`${orch.model} · ${s.ready ? `${s.label} ✓ pronto` : `✗ ${s.reason}`}`)}`);
524
+ ui.print(c.muted(usage));
525
+ return;
526
+ }
527
+ const toggle = words.find((w) => w === "on" || w === "off");
528
+ const mi = words.indexOf("modelo");
529
+ const name = mi >= 0 ? words[mi + 1] : undefined;
530
+ const known = new Set([toggle, "modelo", name].filter(Boolean));
531
+ if ((mi >= 0 && !name) || words.some((w) => !known.has(w)) || words.filter((w) => w === "on" || w === "off").length > 1) {
532
+ ui.warn(`argumento desconhecido: ${args.join(" ")}`);
533
+ ui.print(c.muted(usage));
534
+ return;
535
+ }
536
+ // O implementador primeiro: `on modelo X` liga já com o X.
537
+ if (name) {
538
+ const key = name.trim().toLowerCase();
539
+ if (resolveModel(key).kind !== "openai") {
540
+ ui.error(`"${name}" não é um perfil de endpoint em providers no ~/.brito/config.json: o implementador é um perfil (ex.: qwen); o principal se troca com /model`);
541
+ return;
542
+ }
543
+ orch.model = key;
544
+ if (global)
545
+ saveImplModel(key);
546
+ ui.info(`implementador: ${key}${global ? " (salvo)" : ""}`);
547
+ }
548
+ if (toggle === "off") {
549
+ orch.on = false;
550
+ if (global)
551
+ saveOrch(false);
552
+ ui.info(`modo orquestrador desligado${global ? " (salvo)" : ""}: o modelo principal volta a editar`);
553
+ return;
554
+ }
555
+ if (toggle === "on") {
556
+ const s = st();
557
+ if (!s.ready) {
558
+ ui.error(`modo orquestrador não ligou: ${s.reason}`);
559
+ return;
560
+ }
561
+ orch.on = true;
562
+ if (global)
563
+ saveOrch(true);
564
+ ui.info(`modo orquestrador ligado${global ? " (salvo: liga ao abrir o Brito)" : ""}: ${main()} planeja e revisa, ${s.label} implementa`);
565
+ return;
566
+ }
567
+ const s = st();
568
+ if (orch.on && !s.ready) {
569
+ orch.on = false;
570
+ ui.warn(`modo orquestrador desligado: ${s.reason}`);
571
+ }
572
+ },
573
+ };
574
+ }
575
+ /** Linhas da listagem de sessões (`brito sessoes`, `/sessions`): id, quando, motor e título. */
576
+ function sessionLines(root, current) {
577
+ return listSessions(root).slice(0, 20).map((s) => {
578
+ const when = s.updatedAt.slice(0, 16).replace("T", " ");
579
+ const mark = s.id === current ? c.accent(" ●") : "";
580
+ return ` ${c.soft(s.id.slice(0, 8))} ${c.muted(when)} ${c.dim(s.engine === "claude" ? "claude" : "api ")} ${s.title || c.dim("(sem título)")}${mark}`;
581
+ });
582
+ }
583
+ const sessionsCommand = (root, current) => ({
584
+ desc: "sessões salvas deste projeto (retome com brito --resume <id>)",
585
+ run: async () => {
586
+ const lines = sessionLines(root, current());
587
+ if (!lines.length) {
588
+ ui.print(c.muted(" nenhuma sessão salva ainda"));
589
+ return;
590
+ }
591
+ for (const l of lines)
592
+ ui.print(l);
593
+ ui.print(c.muted(" retome com: brito --resume <id> · brito -c retoma a mais recente"));
594
+ },
595
+ });
596
+ /** `--resume` aceita o id inteiro ou o começo dele (como a listagem mostra). */
597
+ function findSession(root, idOrPrefix) {
598
+ const all = listSessions(root);
599
+ const exact = all.find((s) => s.id === idOrPrefix);
600
+ if (exact)
601
+ return exact;
602
+ const hits = all.filter((s) => s.id.startsWith(idOrPrefix));
603
+ if (hits.length > 1)
604
+ throw new Error(`o prefixo ${idOrPrefix} casa com ${hits.length} sessões; use mais caracteres`);
605
+ return hits[0] ?? null;
606
+ }
416
607
  /**
417
608
  * Monta o loop próprio (`Agent`) e o `ReplBackend` do modo API. Serve à abertura normal e à troca para o qwen quando
418
609
  * o claude CLI cai; `perms.mode` é global, então o modo de permissão da sessão segue igual.
@@ -429,20 +620,39 @@ async function buildApiBackend(root, model, io, o = {}) {
429
620
  const noteCount = Number(notes);
430
621
  const ctx = { root, graph, ask: (q, opts) => io.ask(q, opts), sync, dirty: false, loadedSkills: o.loadedSkills ?? new Set() };
431
622
  const fallback = o.fallback ?? newFallbackState(model);
432
- // Função: o índice de skills e a contagem do grafo são relidos a cada passo e refletem o que mudou na sessão.
433
- const agent = new Agent(() => buildSystemPrompt(root, graphStatsLine(symCount, noteCount, "now"), skillsIndex(root)) + (perms.mode === "plan" ? PLAN_NOTE : ""), ctx, { model, fallback });
623
+ // Refeito só quando muda o modo plano, o modo orquestrador, o grafo deixa de estar vazio ou o conjunto de skills muda:
624
+ // um system estável mantém o cache de prompt da conversa (veja memoSystem).
625
+ const system = memoSystem(() => systemKey(root, perms.mode === "plan", symCount + noteCount === 0) + `|${orch.on}`, () => buildSystemPrompt(root, graphStatsLine(symCount, noteCount, "now"), skillsIndex(root)) + (perms.mode === "plan" ? PLAN_NOTE : "") + (orch.on ? ORCH_NOTE : ""));
626
+ const agent = new Agent(system, ctx, { model, fallback, tools: mainTools });
434
627
  if (o.messages)
435
628
  agent.messages = o.messages;
436
629
  if (o.effort)
437
630
  agent.effort = o.effort;
631
+ let sessionId = o.session?.id ?? randomUUID();
632
+ if (o.session?.messages)
633
+ agent.messages = repairHistory(o.session.messages);
634
+ if (o.session?.todos)
635
+ ctx.todos = o.session.todos;
438
636
  ctx.model = () => agent.model; // subagentes seguem o modelo vivo
439
637
  ui.reasoning = loadReasoningDisplay();
440
638
  const learnModel = () => learnerModel(agent.model, process.env);
441
639
  loadProjectAgents(root);
442
640
  /** Depois de tarefas complexas (ou uso de skill), propõe criar/melhorar skills. */
443
- const learn = makeLearner(() => agent.recentTurns, ctx, defaultComplete, learnModel);
641
+ const learn = makeLearner(() => agent.recentTurns, ctx, withSlm(defaultComplete), learnModel);
642
+ /** Grava a conversa a cada turno (também nos que falharam): `brito -c` retoma de onde parou. */
643
+ const persist = () => {
644
+ if (!agent.messages.length)
645
+ return;
646
+ try {
647
+ saveSession(root, { id: sessionId, engine: "api", model: agent.model, title: titleOf(agent.messages), messages: agent.messages, todos: ctx.todos });
648
+ }
649
+ catch (e) {
650
+ ui.warn(`não consegui salvar a sessão: ${e?.message ?? e}`);
651
+ }
652
+ };
444
653
  const turn = async (text, signal) => {
445
654
  ui.turnStart();
655
+ orchGuard(agent.model, (m) => ui.warn(m)); // `/model qwen` ou a troca do claude CLI para o qwen
446
656
  let ok = true;
447
657
  try {
448
658
  await agent.send(text, signal);
@@ -453,6 +663,7 @@ async function buildApiBackend(root, model, io, o = {}) {
453
663
  }
454
664
  finally {
455
665
  ui.turnEnd(ok);
666
+ persist();
456
667
  }
457
668
  return ok;
458
669
  };
@@ -463,18 +674,21 @@ async function buildApiBackend(root, model, io, o = {}) {
463
674
  const modelCmd = modelCommand(() => agent.model, (m) => { agent.model = m; chooseModel(fallback, m); }, "(troque com /model; subagentes explore usam BRITO_EXPLORE_MODEL)", noApiKey, o.divert);
464
675
  const backend = {
465
676
  model: () => agent.model,
677
+ fallback: () => fallback,
466
678
  contextTokens: () => agent.contextTokens,
467
679
  usage: () => agent.usage,
468
680
  get ctxWindow() { return contextWindowFor(agent.model); },
469
681
  cost: () => (Number.isFinite(PRICE_IN) && Number.isFinite(PRICE_OUT) ? (agent.usage.input * PRICE_IN + agent.usage.output * PRICE_OUT) / 1e6 : undefined),
470
682
  turn,
471
683
  afterTurn: async () => { modelCmd.restoreOnce(fallback.active); await learn(); },
472
- reset: () => { agent.messages = []; agent.contextTokens = 0; agent.recentTurns = []; agent.unsentTurns = []; agent.lastTurn = null; },
684
+ // `/clear` começa outra sessão: a anterior continua salva e pode ser retomada com --resume.
685
+ reset: () => { agent.messages = []; agent.contextTokens = 0; agent.recentTurns = []; agent.unsentTurns = []; agent.lastTurn = null; ctx.todos = undefined; sessionId = randomUUID(); },
473
686
  onShell: () => { ctx.dirty = true; }, // `!cmd` pode ter mudado o código
474
687
  commands: {
475
688
  "/model": modelCmd.cmd,
476
689
  "/fallback": fallbackCommand(fallback, () => agent.model, o.engineLabel ?? ((m) => (resolveModel(m).kind === "openai" ? "endpoint" : "API Anthropic"))),
477
690
  "/reasoning": reasoningCommand(() => agent.effort, (e) => { agent.effort = e; }),
691
+ "/orquestrar": orchestratorCommand(() => agent.model),
478
692
  "/tools": { desc: "ferramentas disponíveis", run: async () => { for (const t of allTools())
479
693
  ui.print(` ${ui.toolLabel(t.name)}${c.dim(t.dangerous ? " (pede aprovação)" : "")}`); } },
480
694
  "/context": {
@@ -487,11 +701,14 @@ async function buildApiBackend(root, model, io, o = {}) {
487
701
  },
488
702
  "/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 })))); } },
489
703
  "/learn": { desc: "extrai skills do último turno agora", run: async () => { await learn(true); } },
490
- "/skills": skillsCommand(root, io, { complete: defaultComplete, model: learnModel }),
704
+ "/skills": skillsCommand(root, io, { complete: withSlm(defaultComplete), model: learnModel }),
705
+ "/permissions": permissionsCommand(root),
706
+ "/todos": { desc: "lista de tarefas da sessão", run: async () => { ctx.todos?.length ? ui.printTodos(ctx.todos) : ui.print(c.muted(" sem lista de tarefas nesta sessão")); } },
707
+ "/sessions": sessionsCommand(root, () => sessionId),
491
708
  },
492
709
  close: async () => { await graph.close(); },
493
710
  };
494
- return { backend, agent, graph, learn, symbols: () => symCount, notes: noteCount };
711
+ return { backend, agent, graph, learn, symbols: () => symCount, notes: noteCount, sessionId: () => sessionId };
495
712
  }
496
713
  /** `--once` não troca de motor: o turno seguinte rodaria no outro motor e a volta exigiria trocar de novo. */
497
714
  const onceNoSwitch = (m) => `o ${m} roda no claude CLI: --once não troca de motor; use /model ${m}`;
@@ -501,8 +718,8 @@ let approvalServer = null;
501
718
  * Motor claude CLI. `sw` é o backend do REPL: `/model <perfil>` troca para o modo API (com a conversa do claude) e
502
719
  * `/model <claude>` lá volta para cá. Também serve ao prompt único, que não troca de motor pelo `/model`.
503
720
  */
504
- async function buildClaudeBackend(root, io, sw, model, openGraph) {
505
- approvalServer ??= startApprovalServer({ ask: (q, opts) => io.ask(q, opts) });
721
+ async function buildClaudeBackend(root, io, sw, model, openGraph, resume) {
722
+ approvalServer ??= startApprovalServer({ ask: (q, opts) => io.ask(q, opts) }, root);
506
723
  // O banco aceita um único processo com acesso de escrita: indexa, lê as stats e solta o banco para o servidor MCP.
507
724
  // `openGraph`: o do modo API, que é fechado aqui só depois de indexar (se falhar antes, continua aberto para ele).
508
725
  const graph = openGraph ?? await GraphStore.open(root);
@@ -517,9 +734,22 @@ async function buildClaudeBackend(root, io, sw, model, openGraph) {
517
734
  mcpCommand: { command: process.execPath, args: [...process.execArgv, process.argv[1], "mcp-serve", "-d", root] },
518
735
  });
519
736
  loadProjectAgents(root);
737
+ if (resume)
738
+ session.resume(resume.id);
739
+ // Só o id fica aqui: a conversa em si o claude CLI guarda, e o `--resume` dele a recupera.
740
+ let title = resume?.title ?? "";
741
+ const persist = (text) => {
742
+ title ||= text.replace(/\s+/g, " ").trim().slice(0, 80);
743
+ try {
744
+ saveSession(root, { id: session.sessionId, engine: "claude", model: session.model, title });
745
+ }
746
+ catch (e) {
747
+ ui.warn(`não consegui salvar a sessão: ${e?.message ?? e}`);
748
+ }
749
+ };
520
750
  // O grafo já foi solto para o servidor MCP; skill_save só usa o store de skills, então `graph` não é tocado.
521
751
  const learnCtx = { root, graph: undefined, ask: (q, opts) => io.ask(q, opts), sync: async () => { }, dirty: false, loadedSkills: session.loadedSkills };
522
- const learn = makeLearner(() => session.recentTurns, learnCtx, claudeCliComplete, () => process.env.BRITO_LEARN_MODEL ?? ""); // "" = modelo padrão do claude
752
+ const learn = makeLearner(() => session.recentTurns, learnCtx, withSlm(claudeCliComplete), () => process.env.BRITO_LEARN_MODEL ?? ""); // "" = modelo padrão do claude
523
753
  if (model) {
524
754
  session.requestedModel = resolveModel(model).wireModel;
525
755
  session.model = session.requestedModel;
@@ -568,7 +798,7 @@ async function buildClaudeBackend(root, io, sw, model, openGraph) {
568
798
  const api = await openApi(status.name, status.label, { engineLabel: () => "claude CLI · assinatura" });
569
799
  if (!api)
570
800
  return null;
571
- activateFallback(fallback, `${f.kind} (${f.message})`);
801
+ activateFallback(fallback, `${f.kind} (${f.message})`, Date.now(), undefined, status.name);
572
802
  return { api, retry: () => api.backend.turn(retryPrompt(text), signal) };
573
803
  };
574
804
  /** `/model <perfil>`: perfis de endpoint rodam no loop próprio do Brito, então o motor troca para o modo API. */
@@ -600,12 +830,15 @@ async function buildClaudeBackend(root, io, sw, model, openGraph) {
600
830
  const mcpTools = allTools().map((t) => t.name).filter((n) => /^(graph_|skill_)/.test(n) || n === "remember" || n === "recall");
601
831
  const backend = {
602
832
  model: () => session.model,
833
+ fallback: () => fallback,
603
834
  contextTokens: () => session.contextTokens,
604
835
  usage: () => session.usage,
605
836
  cost: () => (session.costUsd > 0 ? session.costUsd : undefined),
606
837
  get ctxWindow() { return session.contextWindow ?? CTX_WINDOW; },
607
838
  turn: async (text, signal) => {
608
- if (await session.send(text))
839
+ const ok = await session.send(text);
840
+ persist(text);
841
+ if (ok)
609
842
  return true;
610
843
  const fb = await switchToQwen(text, signal);
611
844
  if (!fb)
@@ -616,11 +849,12 @@ async function buildClaudeBackend(root, io, sw, model, openGraph) {
616
849
  },
617
850
  afterTurn: async () => { modelCmd.restoreOnce(); await learn(); },
618
851
  abort: () => session.abort(),
619
- reset: () => session.reset(),
852
+ reset: () => { session.reset(); title = ""; },
620
853
  commands: {
621
854
  "/model": modelCmd.cmd,
622
855
  "/fallback": fallbackCommand(fallback, () => session.model, () => "claude CLI · assinatura"),
623
856
  "/effort": effortCommand(() => session.effort, (e) => { session.effort = e; }),
857
+ "/orquestrar": orchestratorCommand(() => session.requestedModel || session.model),
624
858
  "/reasoning": reasoningCommand(() => session.effort, (e) => { session.effort = e; }),
625
859
  "/tools": { desc: "ferramentas do grafo/memória/skills", run: async () => { for (const n of mcpTools)
626
860
  ui.print(` ${ui.toolLabel(n)}`); } },
@@ -635,13 +869,15 @@ async function buildClaudeBackend(root, io, sw, model, openGraph) {
635
869
  },
636
870
  "/context": unavailable,
637
871
  "/learn": { desc: "extrai skills do último turno agora", run: async () => { await learn(true); } },
638
- "/skills": skillsCommand(root, io, { complete: claudeCliComplete, model: () => process.env.BRITO_LEARN_MODEL ?? "" }),
872
+ "/skills": skillsCommand(root, io, { complete: withSlm(claudeCliComplete), model: () => process.env.BRITO_LEARN_MODEL ?? "" }),
873
+ "/permissions": permissionsCommand(root),
874
+ "/sessions": sessionsCommand(root, () => session.sessionId),
639
875
  },
640
876
  close: async () => { },
641
877
  };
642
878
  return { backend, session, learn, switchToQwen, modelCmd, syms: Number(syms), notes: Number(notes), mcpTools };
643
879
  }
644
- async function runViaClaude(root, promptParts, yes, model) {
880
+ async function runViaClaude(root, promptParts, yes, model, resume) {
645
881
  const startedAt = Date.now();
646
882
  if (yes)
647
883
  perms.mode = "bypass";
@@ -652,7 +888,7 @@ async function runViaClaude(root, promptParts, yes, model) {
652
888
  await offerNoteMigration(root, (q) => migrationAsk(q), (m) => ui.info(m));
653
889
  }
654
890
  let sw;
655
- const claude = await buildClaudeBackend(root, io, () => sw, model);
891
+ const claude = await buildClaudeBackend(root, io, () => sw, model, undefined, resume);
656
892
  if (promptParts.length) {
657
893
  const text = promptParts.join(" ");
658
894
  if (await claude.session.send(text)) {
@@ -673,6 +909,7 @@ async function runViaClaude(root, promptParts, yes, model) {
673
909
  }
674
910
  sw = switchableBackend(claude.backend);
675
911
  ui.info("modo claude CLI: usa o login da sua conta; tools do grafo/memória/skills via MCP.");
912
+ restoreOrch(claude.session.requestedModel || claude.session.model, (m) => ui.warn(m), (m) => ui.info(m));
676
913
  await runRepl(sw, {
677
914
  root, yes, startedAt, io,
678
915
  banner: {
@@ -687,6 +924,8 @@ program
687
924
  .option("-d, --dir <dir>", "raiz do projeto")
688
925
  .option("-y, --yes", "aprovar edições e comandos automaticamente")
689
926
  .option("-m, --model <nome>", "modelo ou perfil de endpoint (ex.: sonnet, qwen)")
927
+ .option("-c, --continue", "retoma a sessão mais recente deste projeto")
928
+ .option("-r, --resume <id>", "retoma uma sessão salva (id ou começo dele; veja `brito sessoes`)")
690
929
  .action(async (promptParts, o) => {
691
930
  // Trava anti-recursão: um brito iniciado por dentro de um claude que o próprio brito lançou (BRITO_DEPTH) pode abrir
692
931
  // outro claude, e assim por diante. Dois níveis bastam; mais que isso é laço de processos.
@@ -696,17 +935,47 @@ program
696
935
  }
697
936
  for (const e of loadProfiles().errors)
698
937
  ui.warn(e);
699
- const startModel = o.model ?? initialModel();
938
+ let resume = null;
939
+ if (o.resume || o.continue) {
940
+ try {
941
+ resume = o.resume ? findSession(rootOf(o), o.resume) : latestSession(rootOf(o));
942
+ }
943
+ catch (e) {
944
+ console.error(c.err(e?.message ?? String(e)));
945
+ process.exit(1);
946
+ }
947
+ if (!resume) {
948
+ console.error(c.err(o.resume ? `sessão não encontrada: ${o.resume} (veja \`brito sessoes\`)` : "nenhuma sessão salva neste projeto"));
949
+ process.exit(1);
950
+ }
951
+ }
952
+ const startModel = o.model ?? (resume?.engine === "api" ? resume.model : undefined) ?? initialModel();
700
953
  const engine = openEngine(startModel, process.env, hasClaudeCli);
701
954
  if (engine.kind === "error") {
702
955
  console.error(c.err(engine.message));
703
956
  process.exit(1);
704
957
  }
958
+ // A sessão é retomada no motor que a gravou: a conversa de uma está guardada onde a outra não alcança.
959
+ if (resume?.engine === "claude") {
960
+ if (!hasClaudeCli()) {
961
+ console.error(c.err("esta sessão foi feita no claude CLI, que não está instalado aqui"));
962
+ process.exit(1);
963
+ }
964
+ ui.info(`retomando: ${resume.title || resume.id}`);
965
+ await runViaClaude(rootOf(o), promptParts, !!o.yes, o.model, resume);
966
+ return;
967
+ }
968
+ if (resume && engine.kind === "claude") {
969
+ console.error(c.err(`esta sessão foi feita no modo API (${resume.model}); sem ANTHROPIC_API_KEY ela só abre num perfil de endpoint: brito --resume ${resume.id.slice(0, 8)} --model qwen`));
970
+ process.exit(1);
971
+ }
705
972
  if (engine.kind === "claude") {
706
973
  await runViaClaude(rootOf(o), promptParts, !!o.yes, o.model);
707
974
  return;
708
975
  }
709
976
  const root = rootOf(o);
977
+ if (resume)
978
+ ui.info(`retomando: ${resume.title || resume.id} (${resume.messages?.length ?? 0} mensagens)`);
710
979
  const startedAt = Date.now();
711
980
  {
712
981
  const { offerNoteMigration } = await import("./graph/migrate.js");
@@ -717,12 +986,12 @@ program
717
986
  perms.mode = "bypass";
718
987
  const io = { ask: async (q, _opts) => perms.mode === "bypass" || plainAsk(q) };
719
988
  // Claude sem conta nenhuma, com o fallback pronto: a sessão já nasce no qwen (com aviso, nunca em silêncio).
720
- let opening = {};
989
+ let opening = resume ? { session: resume } : {};
721
990
  if (engine.kind === "fallback") {
722
991
  ui.warn(`⚠ ${NO_ANTHROPIC} (sem ANTHROPIC_API_KEY e sem o claude CLI) — abrindo no ${engine.label}. /fallback para detalhes.`);
723
992
  const fallback = newFallbackState(startModel);
724
- activateFallback(fallback, NO_ANTHROPIC);
725
- opening = { fallback, engineLabel: () => NO_ANTHROPIC };
993
+ activateFallback(fallback, NO_ANTHROPIC, Date.now(), undefined, engine.name);
994
+ opening = { ...opening, fallback, engineLabel: () => NO_ANTHROPIC };
726
995
  }
727
996
  let sw;
728
997
  // Sem ANTHROPIC_API_KEY o Claude só roda no claude CLI: `/model <claude>` troca o motor em vez de recusar.
@@ -754,7 +1023,7 @@ program
754
1023
  await claude.modelCmd.cmd.run([m, `--${scope}`]);
755
1024
  return true;
756
1025
  };
757
- const api = await buildApiBackend(root, engine.kind === "fallback" ? FALLBACK_PROFILE : startModel, io, opening);
1026
+ const api = await buildApiBackend(root, engine.kind === "fallback" ? engine.name : startModel, io, opening);
758
1027
  if (promptParts.length) {
759
1028
  if (await api.backend.turn(promptParts.join(" "), new AbortController().signal))
760
1029
  await api.learn();
@@ -762,6 +1031,7 @@ program
762
1031
  return;
763
1032
  }
764
1033
  sw = switchableBackend(api.backend);
1034
+ restoreOrch(api.agent.model, (m) => ui.warn(m), (m) => ui.info(m));
765
1035
  await runRepl(sw, {
766
1036
  root, yes: !!o.yes, startedAt, io,
767
1037
  banner: {
@@ -0,0 +1,93 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { listAgents } from "../agent/agents.js";
4
+ import { GraphStore } from "../graph/store.js";
5
+ import { indexProject } from "../graph/indexer.js";
6
+ import { approvePending, deleteSkill, getSkill, listBuiltins, listPending, listSkills, promoteSkill, rejectPending } from "../skills/store.js";
7
+ /**
8
+ * Dados dos painéis do app (Skills, Agentes, Grafo, Memória). O GraphStore só segura o banco enquanto há consulta
9
+ * (leitura abre somente leitura), então um segundo handle aqui convive com o do motor e com o servidor MCP do claude.
10
+ */
11
+ export class Panels {
12
+ root;
13
+ graphRoot;
14
+ isHome;
15
+ graph = null;
16
+ constructor(root, graphRoot, isHome) {
17
+ this.root = root;
18
+ this.graphRoot = graphRoot;
19
+ this.isHome = isHome;
20
+ }
21
+ async g() { return (this.graph ??= await GraphStore.open(this.graphRoot)); }
22
+ async close() { await this.graph?.close(); this.graph = null; }
23
+ skills() {
24
+ const root = this.root;
25
+ const active = [...listBuiltins(root), ...listSkills(root)].map((s) => ({
26
+ name: s.name, description: s.description, scope: s.scope, status: s.status, invoke: s.invoke, uses: s.uses, builtin: !!s.builtin, origin: s.origin,
27
+ }));
28
+ const pending = listPending(root).map((p) => ({ name: p.name, description: p.description, reason: p.reason, scope: p.scope }));
29
+ return { active, pending };
30
+ }
31
+ skill(name) {
32
+ const s = getSkill(this.root, name);
33
+ return s ? { name: s.name, description: s.description, body: s.body, refs: s.refs, scope: s.scope, model: s.model, wins: s.wins, losses: s.losses } : null;
34
+ }
35
+ skillAction(op, name) {
36
+ const root = this.root;
37
+ switch (op) {
38
+ case "approve":
39
+ approvePending(root, name);
40
+ return `skill ${name} aprovada`;
41
+ case "reject": return rejectPending(root, name) ? `proposta ${name} descartada` : `sem proposta ${name}`;
42
+ case "delete": return deleteSkill(root, name) ? `skill ${name} removida (foi para .trash)` : `não foi possível remover ${name}`;
43
+ case "promote":
44
+ promoteSkill(root, name);
45
+ return `skill ${name} agora é global`;
46
+ default: throw new Error(`ação desconhecida: ${op}`);
47
+ }
48
+ }
49
+ agents() {
50
+ return listAgents().map((a) => ({ name: a.name, description: a.description, tools: a.tools === "all" ? "todas" : a.tools.join(", "), model: a.model ?? "", source: a.source }));
51
+ }
52
+ async symbols(q) {
53
+ const g = await this.g();
54
+ const rows = q
55
+ ? await g.run("MATCH (s:Symbol) WHERE toLower(s.name) CONTAINS $q RETURN s.id AS id, s.name AS name, s.kind AS kind, s.file AS file, s.startLine AS line ORDER BY s.name LIMIT 40", { q: q.toLowerCase() })
56
+ : await g.run("MATCH (s:Symbol) RETURN s.id AS id, s.name AS name, s.kind AS kind, s.file AS file, s.startLine AS line ORDER BY s.file, s.startLine LIMIT 40");
57
+ const stats = await this.stats();
58
+ return { rows, stats };
59
+ }
60
+ async stats() {
61
+ const g = await this.g();
62
+ const n = async (c) => Number((await g.run(c))[0]?.n ?? 0);
63
+ return { symbols: await n("MATCH (s:Symbol) RETURN count(s) AS n"), files: await n("MATCH (f:File) RETURN count(f) AS n"), notes: await n("MATCH (s:Note) RETURN count(s) AS n") };
64
+ }
65
+ async symbol(id) {
66
+ const g = await this.g();
67
+ const cols = "x.id AS id, x.name AS name, x.kind AS kind, x.file AS file, x.startLine AS line";
68
+ const callers = await g.run(`MATCH (x:Symbol)-[:CALLS]->(s:Symbol {id: $id}) RETURN ${cols} LIMIT 30`, { id });
69
+ const callees = await g.run(`MATCH (s:Symbol {id: $id})-[:CALLS]->(x:Symbol) RETURN ${cols} LIMIT 30`, { id });
70
+ return { callers, callees };
71
+ }
72
+ async reindex() {
73
+ if (this.isHome)
74
+ return { symbols: 0, skipped: true };
75
+ const g = await this.g();
76
+ const r = await indexProject(this.root, g);
77
+ return { symbols: r.symbols };
78
+ }
79
+ async notes(q) {
80
+ const g = await this.g();
81
+ const base = "MATCH (n:Note) ";
82
+ const rows = q
83
+ ? await g.run(base + "WHERE toLower(n.text) CONTAINS $q OR toLower(n.kind) CONTAINS $q RETURN n.id AS id, n.kind AS kind, n.text AS text, n.refs AS refs, n.ts AS ts ORDER BY n.ts DESC LIMIT 100", { q: q.toLowerCase() })
84
+ : await g.run(base + "RETURN n.id AS id, n.kind AS kind, n.text AS text, n.refs AS refs, n.ts AS ts ORDER BY n.ts DESC LIMIT 100");
85
+ return rows.map((r) => ({ ...r, ts: Number(r.ts), refs: r.refs ?? [] }));
86
+ }
87
+ async deleteNote(id) {
88
+ const g = await this.g();
89
+ await g.run("MATCH (n:Note {id: $id}) DELETE n", { id });
90
+ return "nota removida";
91
+ }
92
+ }
93
+ export const graphRootFor = (root) => (path.resolve(root) === path.resolve(os.homedir()) ? path.join(os.homedir(), ".brito", "home-graph") : root);
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Contrato entre o app desktop e `brito gui-serve` (JSON em linhas; stdout leva o prefixo "@@").
3
+ * A janela compara `PROTOCOL` com o `hello` do backend e avisa se o app e a CLI estão desalinhados.
4
+ */
5
+ export const PROTOCOL = 2;