brito-cli 0.7.4 → 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.
package/dist/cli.js CHANGED
@@ -7,7 +7,10 @@ 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/todo.js";
10
11
  import fs from "node:fs";
12
+ import { randomUUID } from "node:crypto";
13
+ import { latestSession, listSessions, repairHistory, saveSession, titleOf } from "./agent/sessions.js";
11
14
  import { PLAN_NOTE, perms } from "./permissions.js";
12
15
  import { loadProjectAgents, listAgents } from "./agent/agents.js";
13
16
  import { GraphLockedError, GraphStore, openGraphAfterClaude } from "./graph/store.js";
@@ -15,18 +18,21 @@ import { indexProject } from "./graph/indexer.js";
15
18
  import { Agent } from "./agent/loop.js";
16
19
  import { EFFORTS, MODEL_ALIASES, modelHelp, effortLabel, initialEffort, initialModel, loadProfiles, isEffort, loadReasoningDisplay, parseModelArgs, saveEffort, saveModel, saveReasoningDisplay } from "./model.js";
17
20
  import { hasClaudeCli, historyForFallback, ClaudeSession } from "./agent/claude-cli.js";
18
- import { FALLBACK_PROFILE, NO_ANTHROPIC, fallbackStatus, learnerModel, openEngine } from "./engine.js";
21
+ import { NO_ANTHROPIC, fallbackStatus, learnerModel, openEngine } from "./engine.js";
19
22
  import { activateFallback, chooseModel, cliShouldFallBack, fallbackWarning, newFallbackState, onceRestore, renderFallback, retryPrompt } from "./provider/fallback.js";
20
23
  import { compactAtFor, contextWindowFor, resolveModel } from "./provider/resolve.js";
21
24
  import { startApprovalServer } from "./agent/approval.js";
25
+ import { addRule, loadRules, projectSettingsPath } from "./rules.js";
22
26
  import { serveMcp } from "./mcp/server.js";
23
27
  import "./tools/skills.js";
24
- import { approvePending, lintSkill, listPending, listSkills, rejectPending, skillsIndex, staleRefs } from "./skills/store.js";
28
+ import "./tools/digest.js";
29
+ import { adviseSkill, approvePending, lintSkill, listBuiltins, listPending, listSkills, rejectPending, skillsIndex, staleRefs } from "./skills/store.js";
25
30
  import { applyProposal, autoConsolidate, describeProposal, proposeConsolidation, pruneCandidates } from "./skills/consolidate.js";
26
31
  import { defaultComplete, recordOutcomes, reflect, shouldReflect } from "./skills/learn.js";
27
32
  import { expireTrials, skillsMode } from "./skills/trial.js";
28
33
  import { claudeCliComplete } from "./skills/learn-cli.js";
29
- import { buildSystemPrompt, graphStatsLine } from "./agent/prompt.js";
34
+ import { pullModel, removeModel, slmStatus, withSlm } from "./local/slm.js";
35
+ import { buildSystemPrompt, graphStatsLine, memoSystem, systemKey } from "./agent/prompt.js";
30
36
  import { allTools } from "./tools/registry.js";
31
37
  import { ui, c, fmtTokens } from "./ui/ui.js";
32
38
  import { runRepl, switchableBackend } from "./repl.js";
@@ -64,6 +70,55 @@ program
64
70
  else
65
71
  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
72
  });
73
+ program
74
+ .command("sessoes")
75
+ .alias("sessions")
76
+ .description("Lista as sessões salvas deste projeto (retome com brito --resume <id> ou brito -c)")
77
+ .option("-d, --dir <dir>", "raiz do projeto")
78
+ .action((o) => {
79
+ const lines = sessionLines(rootOf(o));
80
+ console.log(lines.length ? lines.join("\n") : c.muted("nenhuma sessão salva neste projeto"));
81
+ });
82
+ program
83
+ .command("slm [acao]")
84
+ .description("SLM local embutido (llama.cpp): status | pull (baixa o modelo) | rm (apaga). Usado na reflexão de skills; desligue com BRITO_SLM=off")
85
+ .action(async (acao) => {
86
+ const st = await slmStatus();
87
+ if (acao === "pull") {
88
+ if (!st.lib) {
89
+ console.error(c.err("node-llama-cpp não está instalado (dependência opcional). Rode: npm install node-llama-cpp"));
90
+ process.exit(1);
91
+ }
92
+ if (st.file) {
93
+ console.log(c.ok("✦ ") + `modelo já baixado: ${st.file}`);
94
+ return;
95
+ }
96
+ console.log(`baixando ${st.uri} (alguns GB; é retomável com Ctrl+C e rodar de novo)…`);
97
+ let last = -1;
98
+ const file = await pullModel((done, total) => {
99
+ const pct = total ? Math.floor((done / total) * 100) : 0;
100
+ if (pct >= last + 5 || pct === 100) {
101
+ last = pct;
102
+ process.stdout.write(`\r${pct}% (${(done / 1e9).toFixed(2)}/${(total / 1e9).toFixed(2)} GB)`);
103
+ }
104
+ });
105
+ console.log("\n" + c.ok("✦ ") + `pronto: ${file}`);
106
+ return;
107
+ }
108
+ if (acao === "rm") {
109
+ const f = await removeModel();
110
+ console.log(f ? c.ok("✦ ") + `removido: ${f}` : c.muted("nenhum modelo baixado"));
111
+ return;
112
+ }
113
+ if (acao && acao !== "status") {
114
+ console.error(c.err("uso: brito slm [status|pull|rm]"));
115
+ process.exit(1);
116
+ }
117
+ console.log(` modelo ${st.uri}`);
118
+ console.log(` biblioteca ${st.lib ? c.ok("node-llama-cpp ok") : c.warn("node-llama-cpp ausente (npm install node-llama-cpp)")}`);
119
+ console.log(` arquivo ${st.file ?? c.muted("não baixado: brito slm pull")}`);
120
+ 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")}`);
121
+ });
67
122
  program
68
123
  .command("query <cypher>")
69
124
  .description("Executa Cypher no grafo")
@@ -282,6 +337,37 @@ function modelCommand(get, set, note, veto = () => null, divert) {
282
337
  };
283
338
  }
284
339
  /** `/skills`: lista, revisa propostas pendentes e consolida a biblioteca. Vale para os dois backends. */
340
+ /** `/permissions` lista as regras; `allow <regra>` / `deny <regra>` grava no `.brito/settings.json` do projeto. */
341
+ const permissionsCommand = (root) => ({
342
+ desc: "regras de permissão; allow <regra> | deny <regra>",
343
+ run: async ([sub, ...rest]) => {
344
+ if (sub === "allow" || sub === "deny") {
345
+ const rule = rest.join(" ").trim();
346
+ if (!rule) {
347
+ ui.print(c.muted(` uso: /permissions ${sub} bash(npm test:*) · /permissions ${sub} edit_file(src/**)`));
348
+ return;
349
+ }
350
+ try {
351
+ addRule(root, sub, rule);
352
+ ui.info(`${sub}: ${rule} → ${path.relative(root, projectSettingsPath(root))}`);
353
+ }
354
+ catch (e) {
355
+ ui.error(e?.message ?? String(e));
356
+ }
357
+ return;
358
+ }
359
+ const r = loadRules(root);
360
+ if (!r.allow.length && !r.deny.length) {
361
+ ui.print(c.muted(" nenhuma regra. Ex.: /permissions allow bash(npm test:*) · /permissions deny bash(git push:*)"));
362
+ return;
363
+ }
364
+ for (const a of r.allow)
365
+ ui.print(` ${c.soft("allow")} ${a}`);
366
+ for (const d of r.deny)
367
+ ui.print(` ${c.soft("deny ")} ${d}`);
368
+ ui.print(c.muted(" ~/.brito/settings.json · .brito/settings.json · .brito/settings.local.json"));
369
+ },
370
+ });
285
371
  const skillsCommand = (root, io, llm) => ({
286
372
  desc: "lista skills; pending | approve <nome> | reject <nome> | consolidate",
287
373
  run: async ([sub, name]) => {
@@ -331,7 +417,7 @@ const skillsCommand = (root, io, llm) => ({
331
417
  }
332
418
  ui.print(` ${c.soft(p.name)} ${c.muted(`(${p.scope}) ${p.description}`)}\n${p.body.split("\n").map((l) => ` │ ${l}`).join("\n")}`);
333
419
  // 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)
420
+ for (const w of [...lintSkill(p).warnings, ...adviseSkill(p)])
335
421
  ui.print(c.warn(` ⚠ ${w}`));
336
422
  if (!(await io.ask(`aprovar a skill ${name}?`, { force: true }))) {
337
423
  ui.info("mantida como pendente");
@@ -363,8 +449,10 @@ const skillsCommand = (root, io, llm) => ({
363
449
  const skills = listSkills(root);
364
450
  for (const s of skills) {
365
451
  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(", ")}`) : ""}`);
452
+ 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
453
  }
454
+ for (const s of listBuiltins(root))
455
+ ui.print(` ${c.soft("/" + s.name)} ${c.muted(`(embutida) ${s.description}`)}`);
368
456
  const prune = pruneCandidates(skills);
369
457
  if (prune.length)
370
458
  ui.info(`${prune.length} candidata(s) a poda (sem uso há mais de 30 dias): /skills consolidate`);
@@ -413,6 +501,38 @@ function makeLearner(getTurns, ctx, complete, getModel) {
413
501
  }
414
502
  };
415
503
  }
504
+ /** Linhas da listagem de sessões (`brito sessoes`, `/sessions`): id, quando, motor e título. */
505
+ function sessionLines(root, current) {
506
+ return listSessions(root).slice(0, 20).map((s) => {
507
+ const when = s.updatedAt.slice(0, 16).replace("T", " ");
508
+ const mark = s.id === current ? c.accent(" ●") : "";
509
+ 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}`;
510
+ });
511
+ }
512
+ const sessionsCommand = (root, current) => ({
513
+ desc: "sessões salvas deste projeto (retome com brito --resume <id>)",
514
+ run: async () => {
515
+ const lines = sessionLines(root, current());
516
+ if (!lines.length) {
517
+ ui.print(c.muted(" nenhuma sessão salva ainda"));
518
+ return;
519
+ }
520
+ for (const l of lines)
521
+ ui.print(l);
522
+ ui.print(c.muted(" retome com: brito --resume <id> · brito -c retoma a mais recente"));
523
+ },
524
+ });
525
+ /** `--resume` aceita o id inteiro ou o começo dele (como a listagem mostra). */
526
+ function findSession(root, idOrPrefix) {
527
+ const all = listSessions(root);
528
+ const exact = all.find((s) => s.id === idOrPrefix);
529
+ if (exact)
530
+ return exact;
531
+ const hits = all.filter((s) => s.id.startsWith(idOrPrefix));
532
+ if (hits.length > 1)
533
+ throw new Error(`o prefixo ${idOrPrefix} casa com ${hits.length} sessões; use mais caracteres`);
534
+ return hits[0] ?? null;
535
+ }
416
536
  /**
417
537
  * Monta o loop próprio (`Agent`) e o `ReplBackend` do modo API. Serve à abertura normal e à troca para o qwen quando
418
538
  * o claude CLI cai; `perms.mode` é global, então o modo de permissão da sessão segue igual.
@@ -429,18 +549,36 @@ async function buildApiBackend(root, model, io, o = {}) {
429
549
  const noteCount = Number(notes);
430
550
  const ctx = { root, graph, ask: (q, opts) => io.ask(q, opts), sync, dirty: false, loadedSkills: o.loadedSkills ?? new Set() };
431
551
  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 });
552
+ // Refeito só quando muda o modo plano, o grafo deixa de estar vazio ou o conjunto de skills muda: um system
553
+ // estável mantém o cache de prompt da conversa (veja memoSystem).
554
+ const system = memoSystem(() => systemKey(root, perms.mode === "plan", symCount + noteCount === 0), () => buildSystemPrompt(root, graphStatsLine(symCount, noteCount, "now"), skillsIndex(root)) + (perms.mode === "plan" ? PLAN_NOTE : ""));
555
+ const agent = new Agent(system, ctx, { model, fallback });
434
556
  if (o.messages)
435
557
  agent.messages = o.messages;
436
558
  if (o.effort)
437
559
  agent.effort = o.effort;
560
+ let sessionId = o.session?.id ?? randomUUID();
561
+ if (o.session?.messages)
562
+ agent.messages = repairHistory(o.session.messages);
563
+ if (o.session?.todos)
564
+ ctx.todos = o.session.todos;
438
565
  ctx.model = () => agent.model; // subagentes seguem o modelo vivo
439
566
  ui.reasoning = loadReasoningDisplay();
440
567
  const learnModel = () => learnerModel(agent.model, process.env);
441
568
  loadProjectAgents(root);
442
569
  /** Depois de tarefas complexas (ou uso de skill), propõe criar/melhorar skills. */
443
- const learn = makeLearner(() => agent.recentTurns, ctx, defaultComplete, learnModel);
570
+ const learn = makeLearner(() => agent.recentTurns, ctx, withSlm(defaultComplete), learnModel);
571
+ /** Grava a conversa a cada turno (também nos que falharam): `brito -c` retoma de onde parou. */
572
+ const persist = () => {
573
+ if (!agent.messages.length)
574
+ return;
575
+ try {
576
+ saveSession(root, { id: sessionId, engine: "api", model: agent.model, title: titleOf(agent.messages), messages: agent.messages, todos: ctx.todos });
577
+ }
578
+ catch (e) {
579
+ ui.warn(`não consegui salvar a sessão: ${e?.message ?? e}`);
580
+ }
581
+ };
444
582
  const turn = async (text, signal) => {
445
583
  ui.turnStart();
446
584
  let ok = true;
@@ -453,6 +591,7 @@ async function buildApiBackend(root, model, io, o = {}) {
453
591
  }
454
592
  finally {
455
593
  ui.turnEnd(ok);
594
+ persist();
456
595
  }
457
596
  return ok;
458
597
  };
@@ -463,13 +602,15 @@ async function buildApiBackend(root, model, io, o = {}) {
463
602
  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
603
  const backend = {
465
604
  model: () => agent.model,
605
+ fallback: () => fallback,
466
606
  contextTokens: () => agent.contextTokens,
467
607
  usage: () => agent.usage,
468
608
  get ctxWindow() { return contextWindowFor(agent.model); },
469
609
  cost: () => (Number.isFinite(PRICE_IN) && Number.isFinite(PRICE_OUT) ? (agent.usage.input * PRICE_IN + agent.usage.output * PRICE_OUT) / 1e6 : undefined),
470
610
  turn,
471
611
  afterTurn: async () => { modelCmd.restoreOnce(fallback.active); await learn(); },
472
- reset: () => { agent.messages = []; agent.contextTokens = 0; agent.recentTurns = []; agent.unsentTurns = []; agent.lastTurn = null; },
612
+ // `/clear` começa outra sessão: a anterior continua salva e pode ser retomada com --resume.
613
+ reset: () => { agent.messages = []; agent.contextTokens = 0; agent.recentTurns = []; agent.unsentTurns = []; agent.lastTurn = null; ctx.todos = undefined; sessionId = randomUUID(); },
473
614
  onShell: () => { ctx.dirty = true; }, // `!cmd` pode ter mudado o código
474
615
  commands: {
475
616
  "/model": modelCmd.cmd,
@@ -487,11 +628,14 @@ async function buildApiBackend(root, model, io, o = {}) {
487
628
  },
488
629
  "/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
630
  "/learn": { desc: "extrai skills do último turno agora", run: async () => { await learn(true); } },
490
- "/skills": skillsCommand(root, io, { complete: defaultComplete, model: learnModel }),
631
+ "/skills": skillsCommand(root, io, { complete: withSlm(defaultComplete), model: learnModel }),
632
+ "/permissions": permissionsCommand(root),
633
+ "/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")); } },
634
+ "/sessions": sessionsCommand(root, () => sessionId),
491
635
  },
492
636
  close: async () => { await graph.close(); },
493
637
  };
494
- return { backend, agent, graph, learn, symbols: () => symCount, notes: noteCount };
638
+ return { backend, agent, graph, learn, symbols: () => symCount, notes: noteCount, sessionId: () => sessionId };
495
639
  }
496
640
  /** `--once` não troca de motor: o turno seguinte rodaria no outro motor e a volta exigiria trocar de novo. */
497
641
  const onceNoSwitch = (m) => `o ${m} roda no claude CLI: --once não troca de motor; use /model ${m}`;
@@ -501,8 +645,8 @@ let approvalServer = null;
501
645
  * Motor claude CLI. `sw` é o backend do REPL: `/model <perfil>` troca para o modo API (com a conversa do claude) e
502
646
  * `/model <claude>` lá volta para cá. Também serve ao prompt único, que não troca de motor pelo `/model`.
503
647
  */
504
- async function buildClaudeBackend(root, io, sw, model, openGraph) {
505
- approvalServer ??= startApprovalServer({ ask: (q, opts) => io.ask(q, opts) });
648
+ async function buildClaudeBackend(root, io, sw, model, openGraph, resume) {
649
+ approvalServer ??= startApprovalServer({ ask: (q, opts) => io.ask(q, opts) }, root);
506
650
  // O banco aceita um único processo com acesso de escrita: indexa, lê as stats e solta o banco para o servidor MCP.
507
651
  // `openGraph`: o do modo API, que é fechado aqui só depois de indexar (se falhar antes, continua aberto para ele).
508
652
  const graph = openGraph ?? await GraphStore.open(root);
@@ -517,9 +661,22 @@ async function buildClaudeBackend(root, io, sw, model, openGraph) {
517
661
  mcpCommand: { command: process.execPath, args: [...process.execArgv, process.argv[1], "mcp-serve", "-d", root] },
518
662
  });
519
663
  loadProjectAgents(root);
664
+ if (resume)
665
+ session.resume(resume.id);
666
+ // Só o id fica aqui: a conversa em si o claude CLI guarda, e o `--resume` dele a recupera.
667
+ let title = resume?.title ?? "";
668
+ const persist = (text) => {
669
+ title ||= text.replace(/\s+/g, " ").trim().slice(0, 80);
670
+ try {
671
+ saveSession(root, { id: session.sessionId, engine: "claude", model: session.model, title });
672
+ }
673
+ catch (e) {
674
+ ui.warn(`não consegui salvar a sessão: ${e?.message ?? e}`);
675
+ }
676
+ };
520
677
  // O grafo já foi solto para o servidor MCP; skill_save só usa o store de skills, então `graph` não é tocado.
521
678
  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
679
+ const learn = makeLearner(() => session.recentTurns, learnCtx, withSlm(claudeCliComplete), () => process.env.BRITO_LEARN_MODEL ?? ""); // "" = modelo padrão do claude
523
680
  if (model) {
524
681
  session.requestedModel = resolveModel(model).wireModel;
525
682
  session.model = session.requestedModel;
@@ -568,7 +725,7 @@ async function buildClaudeBackend(root, io, sw, model, openGraph) {
568
725
  const api = await openApi(status.name, status.label, { engineLabel: () => "claude CLI · assinatura" });
569
726
  if (!api)
570
727
  return null;
571
- activateFallback(fallback, `${f.kind} (${f.message})`);
728
+ activateFallback(fallback, `${f.kind} (${f.message})`, Date.now(), undefined, status.name);
572
729
  return { api, retry: () => api.backend.turn(retryPrompt(text), signal) };
573
730
  };
574
731
  /** `/model <perfil>`: perfis de endpoint rodam no loop próprio do Brito, então o motor troca para o modo API. */
@@ -600,12 +757,15 @@ async function buildClaudeBackend(root, io, sw, model, openGraph) {
600
757
  const mcpTools = allTools().map((t) => t.name).filter((n) => /^(graph_|skill_)/.test(n) || n === "remember" || n === "recall");
601
758
  const backend = {
602
759
  model: () => session.model,
760
+ fallback: () => fallback,
603
761
  contextTokens: () => session.contextTokens,
604
762
  usage: () => session.usage,
605
763
  cost: () => (session.costUsd > 0 ? session.costUsd : undefined),
606
764
  get ctxWindow() { return session.contextWindow ?? CTX_WINDOW; },
607
765
  turn: async (text, signal) => {
608
- if (await session.send(text))
766
+ const ok = await session.send(text);
767
+ persist(text);
768
+ if (ok)
609
769
  return true;
610
770
  const fb = await switchToQwen(text, signal);
611
771
  if (!fb)
@@ -616,7 +776,7 @@ async function buildClaudeBackend(root, io, sw, model, openGraph) {
616
776
  },
617
777
  afterTurn: async () => { modelCmd.restoreOnce(); await learn(); },
618
778
  abort: () => session.abort(),
619
- reset: () => session.reset(),
779
+ reset: () => { session.reset(); title = ""; },
620
780
  commands: {
621
781
  "/model": modelCmd.cmd,
622
782
  "/fallback": fallbackCommand(fallback, () => session.model, () => "claude CLI · assinatura"),
@@ -635,13 +795,15 @@ async function buildClaudeBackend(root, io, sw, model, openGraph) {
635
795
  },
636
796
  "/context": unavailable,
637
797
  "/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 ?? "" }),
798
+ "/skills": skillsCommand(root, io, { complete: withSlm(claudeCliComplete), model: () => process.env.BRITO_LEARN_MODEL ?? "" }),
799
+ "/permissions": permissionsCommand(root),
800
+ "/sessions": sessionsCommand(root, () => session.sessionId),
639
801
  },
640
802
  close: async () => { },
641
803
  };
642
804
  return { backend, session, learn, switchToQwen, modelCmd, syms: Number(syms), notes: Number(notes), mcpTools };
643
805
  }
644
- async function runViaClaude(root, promptParts, yes, model) {
806
+ async function runViaClaude(root, promptParts, yes, model, resume) {
645
807
  const startedAt = Date.now();
646
808
  if (yes)
647
809
  perms.mode = "bypass";
@@ -652,7 +814,7 @@ async function runViaClaude(root, promptParts, yes, model) {
652
814
  await offerNoteMigration(root, (q) => migrationAsk(q), (m) => ui.info(m));
653
815
  }
654
816
  let sw;
655
- const claude = await buildClaudeBackend(root, io, () => sw, model);
817
+ const claude = await buildClaudeBackend(root, io, () => sw, model, undefined, resume);
656
818
  if (promptParts.length) {
657
819
  const text = promptParts.join(" ");
658
820
  if (await claude.session.send(text)) {
@@ -687,6 +849,8 @@ program
687
849
  .option("-d, --dir <dir>", "raiz do projeto")
688
850
  .option("-y, --yes", "aprovar edições e comandos automaticamente")
689
851
  .option("-m, --model <nome>", "modelo ou perfil de endpoint (ex.: sonnet, qwen)")
852
+ .option("-c, --continue", "retoma a sessão mais recente deste projeto")
853
+ .option("-r, --resume <id>", "retoma uma sessão salva (id ou começo dele; veja `brito sessoes`)")
690
854
  .action(async (promptParts, o) => {
691
855
  // Trava anti-recursão: um brito iniciado por dentro de um claude que o próprio brito lançou (BRITO_DEPTH) pode abrir
692
856
  // outro claude, e assim por diante. Dois níveis bastam; mais que isso é laço de processos.
@@ -696,17 +860,47 @@ program
696
860
  }
697
861
  for (const e of loadProfiles().errors)
698
862
  ui.warn(e);
699
- const startModel = o.model ?? initialModel();
863
+ let resume = null;
864
+ if (o.resume || o.continue) {
865
+ try {
866
+ resume = o.resume ? findSession(rootOf(o), o.resume) : latestSession(rootOf(o));
867
+ }
868
+ catch (e) {
869
+ console.error(c.err(e?.message ?? String(e)));
870
+ process.exit(1);
871
+ }
872
+ if (!resume) {
873
+ console.error(c.err(o.resume ? `sessão não encontrada: ${o.resume} (veja \`brito sessoes\`)` : "nenhuma sessão salva neste projeto"));
874
+ process.exit(1);
875
+ }
876
+ }
877
+ const startModel = o.model ?? (resume?.engine === "api" ? resume.model : undefined) ?? initialModel();
700
878
  const engine = openEngine(startModel, process.env, hasClaudeCli);
701
879
  if (engine.kind === "error") {
702
880
  console.error(c.err(engine.message));
703
881
  process.exit(1);
704
882
  }
883
+ // A sessão é retomada no motor que a gravou: a conversa de uma está guardada onde a outra não alcança.
884
+ if (resume?.engine === "claude") {
885
+ if (!hasClaudeCli()) {
886
+ console.error(c.err("esta sessão foi feita no claude CLI, que não está instalado aqui"));
887
+ process.exit(1);
888
+ }
889
+ ui.info(`retomando: ${resume.title || resume.id}`);
890
+ await runViaClaude(rootOf(o), promptParts, !!o.yes, o.model, resume);
891
+ return;
892
+ }
893
+ if (resume && engine.kind === "claude") {
894
+ 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`));
895
+ process.exit(1);
896
+ }
705
897
  if (engine.kind === "claude") {
706
898
  await runViaClaude(rootOf(o), promptParts, !!o.yes, o.model);
707
899
  return;
708
900
  }
709
901
  const root = rootOf(o);
902
+ if (resume)
903
+ ui.info(`retomando: ${resume.title || resume.id} (${resume.messages?.length ?? 0} mensagens)`);
710
904
  const startedAt = Date.now();
711
905
  {
712
906
  const { offerNoteMigration } = await import("./graph/migrate.js");
@@ -717,12 +911,12 @@ program
717
911
  perms.mode = "bypass";
718
912
  const io = { ask: async (q, _opts) => perms.mode === "bypass" || plainAsk(q) };
719
913
  // Claude sem conta nenhuma, com o fallback pronto: a sessão já nasce no qwen (com aviso, nunca em silêncio).
720
- let opening = {};
914
+ let opening = resume ? { session: resume } : {};
721
915
  if (engine.kind === "fallback") {
722
916
  ui.warn(`⚠ ${NO_ANTHROPIC} (sem ANTHROPIC_API_KEY e sem o claude CLI) — abrindo no ${engine.label}. /fallback para detalhes.`);
723
917
  const fallback = newFallbackState(startModel);
724
- activateFallback(fallback, NO_ANTHROPIC);
725
- opening = { fallback, engineLabel: () => NO_ANTHROPIC };
918
+ activateFallback(fallback, NO_ANTHROPIC, Date.now(), undefined, engine.name);
919
+ opening = { ...opening, fallback, engineLabel: () => NO_ANTHROPIC };
726
920
  }
727
921
  let sw;
728
922
  // Sem ANTHROPIC_API_KEY o Claude só roda no claude CLI: `/model <claude>` troca o motor em vez de recusar.
@@ -754,7 +948,7 @@ program
754
948
  await claude.modelCmd.cmd.run([m, `--${scope}`]);
755
949
  return true;
756
950
  };
757
- const api = await buildApiBackend(root, engine.kind === "fallback" ? FALLBACK_PROFILE : startModel, io, opening);
951
+ const api = await buildApiBackend(root, engine.kind === "fallback" ? engine.name : startModel, io, opening);
758
952
  if (promptParts.length) {
759
953
  if (await api.backend.turn(promptParts.join(" "), new AbortController().signal))
760
954
  await api.learn();
@@ -9,15 +9,18 @@ import "../tools/graph.js";
9
9
  import "../tools/delegate.js";
10
10
  import "../tools/plan.js";
11
11
  import "../tools/skills.js";
12
+ import "../tools/digest.js";
13
+ import "../tools/todo.js";
12
14
  import { Agent } from "../agent/loop.js";
15
+ import { loadSession, repairHistory, saveSession, titleOf } from "../agent/sessions.js";
13
16
  import { ClaudeSession, hasClaudeCli, historyForFallback } from "../agent/claude-cli.js";
14
17
  import { startApprovalServer } from "../agent/approval.js";
15
18
  import { loadProjectAgents } from "../agent/agents.js";
16
- import { buildSystemPrompt, graphStatsLine } from "../agent/prompt.js";
19
+ import { buildSystemPrompt, graphStatsLine, memoSystem, systemKey } from "../agent/prompt.js";
17
20
  import { openGraphAfterClaude } from "../graph/store.js";
18
21
  import { indexProject } from "../graph/indexer.js";
19
22
  import { initialEffort, initialModel, isEffort, loadProfiles, MODEL_ALIASES } from "../model.js";
20
- import { FALLBACK_PROFILE, NO_ANTHROPIC, fallbackStatus, openEngine } from "../engine.js";
23
+ import { NO_ANTHROPIC, fallbackStatus, openEngine } from "../engine.js";
21
24
  import { activateFallback, chooseModel, cliShouldFallBack, fallbackWarning, newFallbackState, renderFallback, retryPrompt } from "../provider/fallback.js";
22
25
  import { contextWindowFor, resolveModel } from "../provider/resolve.js";
23
26
  import { PLAN_NOTE, perms } from "../permissions.js";
@@ -89,13 +92,29 @@ export async function serveDesktop(root, resumeId) {
89
92
  catch { /* sem notas */ }
90
93
  const ctx = { root, graph, ask, sync, dirty: false, loadedSkills: o.loadedSkills ?? new Set(), model: () => agent.model };
91
94
  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 });
95
+ const system = memoSystem(() => systemKey(root, perms.mode === "plan", syms + notes === 0), () => buildSystemPrompt(root, graphStatsLine(syms, notes, "now"), skillsIndex(root)) + (perms.mode === "plan" ? PLAN_NOTE : ""));
96
+ const agent = new Agent(system, ctx, { model, sink, fallback });
93
97
  if (o.messages)
94
98
  agent.messages = o.messages;
95
99
  if (o.effort)
96
100
  agent.effort = o.effort;
101
+ if (o.session?.messages)
102
+ agent.messages = repairHistory(o.session.messages);
103
+ if (o.session?.todos)
104
+ ctx.todos = o.session.todos;
97
105
  let ac = null;
98
- const id = randomUUID();
106
+ const id = o.session?.id ?? randomUUID();
107
+ // Depois de uma troca a partir do claude CLI quem guarda a conversa é ele (o id é o dele): nada a salvar aqui.
108
+ const persist = () => {
109
+ if (o.back || !agent.messages.length)
110
+ return;
111
+ try {
112
+ saveSession(root, { id, engine: "api", model: agent.model, title: titleOf(agent.messages), messages: agent.messages, todos: ctx.todos });
113
+ }
114
+ catch (e) {
115
+ sink.warn(`não consegui salvar a sessão: ${e?.message ?? e}`);
116
+ }
117
+ };
99
118
  return {
100
119
  kind: "api", effort: "", fallback,
101
120
  // Depois da troca o id segue o do claude: a janela o guarda para retomar a conversa ao reabrir.
@@ -136,6 +155,7 @@ export async function serveDesktop(root, resumeId) {
136
155
  finally {
137
156
  sink.turnEnd(ok);
138
157
  ac = null;
158
+ persist();
139
159
  }
140
160
  },
141
161
  abort: () => ac?.abort(),
@@ -152,20 +172,23 @@ export async function serveDesktop(root, resumeId) {
152
172
  };
153
173
  };
154
174
  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." });
175
+ // O id que a janela guarda é o da sessão salva em .brito/sessions: reabrir retoma o histórico de verdade.
176
+ const saved = resumeId ? loadSession(root, resumeId) : null;
177
+ const session = saved?.engine === "api" ? saved : undefined;
178
+ if (resumeId && !session)
179
+ 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
180
  if (opening.kind === "fallback") {
158
181
  // Claude sem conta nenhuma, com o fallback pronto: a sessão já nasce no qwen (com aviso, nunca em silêncio).
159
182
  emit({ t: "warn", msg: `⚠ ${NO_ANTHROPIC} (sem ANTHROPIC_API_KEY e sem o claude CLI) — abrindo no ${opening.label}. Envie /fallback para detalhes.` });
160
183
  const fallback = newFallbackState(startModel);
161
- activateFallback(fallback, NO_ANTHROPIC);
162
- engine = await buildApiEngine(FALLBACK_PROFILE, { fallback, engineLabel: () => NO_ANTHROPIC });
184
+ activateFallback(fallback, NO_ANTHROPIC, Date.now(), undefined, opening.name);
185
+ engine = await buildApiEngine(opening.name, { fallback, engineLabel: () => NO_ANTHROPIC, session });
163
186
  }
164
187
  else
165
- engine = await buildApiEngine(startModel);
188
+ engine = await buildApiEngine(startModel, { session });
166
189
  }
167
190
  else {
168
- const approval = startApprovalServer({ ask });
191
+ const approval = startApprovalServer({ ask }, root);
169
192
  closeApproval = approval.close;
170
193
  // O banco aceita um único processo com escrita: indexa, lê as stats e solta o banco para o servidor MCP.
171
194
  if (!isHome)
@@ -228,7 +251,7 @@ export async function serveDesktop(root, resumeId) {
228
251
  sink.error(`não consegui abrir o ${status.label}: ${e?.message ?? e}`);
229
252
  return;
230
253
  }
231
- activateFallback(fallback, `${f.kind} (${f.message})`);
254
+ activateFallback(fallback, `${f.kind} (${f.message})`, Date.now(), undefined, status.name);
232
255
  engine = api;
233
256
  ready();
234
257
  await api.send(retryPrompt(text, resumed));
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
@@ -3,12 +3,12 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { GraphStore } from "./store.js";
6
- const READER = `const k = require("kuzu");
7
- const db = new k.Database(process.argv[2], 0, true, true);
8
- const c = new k.Connection(db);
9
- c.query("MATCH (n:Note) RETURN n.id AS id, n.kind AS kind, n.text AS text, n.refs AS refs, n.ts AS ts").then((r) => r.getAll()).then((rows) => {
10
- process.stdout.write(JSON.stringify(rows, (_, v) => (typeof v === "bigint" ? Number(v) : v)));
11
- process.exit(0);
6
+ const READER = `const k = require("kuzu");
7
+ const db = new k.Database(process.argv[2], 0, true, true);
8
+ const c = new k.Connection(db);
9
+ c.query("MATCH (n:Note) RETURN n.id AS id, n.kind AS kind, n.text AS text, n.refs AS refs, n.ts AS ts").then((r) => r.getAll()).then((rows) => {
10
+ process.stdout.write(JSON.stringify(rows, (_, v) => (typeof v === "bigint" ? Number(v) : v)));
11
+ process.exit(0);
12
12
  });`;
13
13
  /** Copia as notas (memória) do grafo antigo (.brito/graph.kuzu) para o novo (.brito/graph.lbug).
14
14
  * O Kùzu só é instalado numa pasta temporária, durante esta chamada: o pacote principal não depende mais dele. */