brito-cli 0.6.4 → 0.7.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
@@ -10,11 +10,14 @@ import "./tools/plan.js";
10
10
  import fs from "node:fs";
11
11
  import { PLAN_NOTE, perms } from "./permissions.js";
12
12
  import { loadProjectAgents, listAgents } from "./agent/agents.js";
13
- import { GraphLockedError, GraphStore } from "./graph/store.js";
13
+ import { GraphLockedError, GraphStore, openGraphAfterClaude } from "./graph/store.js";
14
14
  import { indexProject } from "./graph/indexer.js";
15
- import { Agent, MODEL } from "./agent/loop.js";
16
- import { EFFORTS, MODEL_ALIASES, MODEL_HELP, effortLabel, initialEffort, isEffort, loadReasoningDisplay, parseModelArgs, saveEffort, saveModel, saveReasoningDisplay } from "./model.js";
17
- import { hasClaudeCli, ClaudeSession } from "./agent/claude-cli.js";
15
+ 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";
17
+ import { hasClaudeCli, historyForFallback, ClaudeSession } from "./agent/claude-cli.js";
18
+ import { FALLBACK_PROFILE, NO_ANTHROPIC, fallbackStatus, learnerModel, openEngine } from "./engine.js";
19
+ import { activateFallback, chooseModel, cliShouldFallBack, fallbackWarning, newFallbackState, onceRestore, renderFallback, retryPrompt } from "./provider/fallback.js";
20
+ import { compactAtFor, contextWindowFor, resolveModel } from "./provider/resolve.js";
18
21
  import { startApprovalServer } from "./agent/approval.js";
19
22
  import { serveMcp } from "./mcp/server.js";
20
23
  import "./tools/skills.js";
@@ -26,7 +29,7 @@ import { claudeCliComplete } from "./skills/learn-cli.js";
26
29
  import { buildSystemPrompt, graphStatsLine } from "./agent/prompt.js";
27
30
  import { allTools } from "./tools/registry.js";
28
31
  import { ui, c, fmtTokens } from "./ui/ui.js";
29
- import { runRepl } from "./repl.js";
32
+ import { runRepl, switchableBackend } from "./repl.js";
30
33
  const pkgVersion = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
31
34
  const program = new Command().name("brito").description("CLI de agentes de desenvolvimento com grafo de conhecimento").version(pkgVersion, "-v, --version", "mostra a versão").enablePositionalOptions();
32
35
  const rootOf = (o) => path.resolve(o.dir ?? process.cwd());
@@ -102,8 +105,12 @@ async function migrationAsk(q) {
102
105
  /** Sem API key: o `claude` oficial (login da assinatura) faz o modelo; grafo/memória/skills chegam via MCP. */
103
106
  /** Menus ↑↓+Enter do `/model`: modelo e depois escopo. null = cancelado. */
104
107
  async function pickModel(current) {
105
- const models = Object.entries(MODEL_ALIASES);
106
- const mi = await ui.select("modelo", models.map(([alias, id]) => ({ label: alias, hint: id === current ? `${id} (atual)` : id })), Math.max(0, models.findIndex(([, id]) => id === current)));
108
+ // Aliases do Claude e perfis de endpoint (o perfil é escolhido pelo nome; o id dele é detalhe do config).
109
+ const models = [
110
+ ...Object.entries(MODEL_ALIASES).map(([alias, id]) => [alias, id, id]),
111
+ ...Object.values(loadProfiles().profiles).map((p) => [p.name, p.name, p.label]),
112
+ ];
113
+ const mi = await ui.select("modelo", models.map(([alias, id, hint]) => ({ label: alias, hint: id === current ? `${hint} (atual)` : hint })), Math.max(0, models.findIndex(([, id]) => id === current)));
107
114
  if (mi === null)
108
115
  return null;
109
116
  const scopes = [
@@ -204,17 +211,28 @@ function reasoningCommand(get, set) {
204
211
  },
205
212
  };
206
213
  }
214
+ /** `/fallback` (só leitura): modelo principal, o fallback qwen e o estado da troca. */
215
+ function fallbackCommand(state, live, engine) {
216
+ return {
217
+ desc: "mostra o modelo principal e o fallback (qwen)",
218
+ run: async () => {
219
+ const primary = state.active ? state.primary : live();
220
+ for (const l of renderFallback(state, live(), engine(primary), fallbackStatus(primary, process.env)))
221
+ ui.print(l);
222
+ },
223
+ };
224
+ }
207
225
  /**
208
226
  * `/model [nome|alias] [--global|--session|--once]` (mesma semântica do Hermes). Vale para os dois backends:
209
227
  * `get`/`set` leem e trocam o modelo vivo; `--once` devolve o anterior depois do turno (`restoreOnce`).
228
+ * `divert`: o modelo roda no outro motor (claude CLI ↔ modo API); ele troca o motor e devolve true.
210
229
  */
211
- function modelCommand(get, set, note) {
230
+ function modelCommand(get, set, note, veto = () => null, divert) {
212
231
  let restore = null;
213
232
  return {
214
- restoreOnce: () => { if (restore !== null) {
215
- set(restore);
216
- restore = null;
217
- } },
233
+ /** `fallbackActive`: a troca automática aconteceu no turno; desfazer o `--once` levaria de volta ao Claude que caiu. */
234
+ restoreOnce: (fallbackActive = false) => { const r = onceRestore(restore, fallbackActive); restore = null; if (r !== null)
235
+ set(r); },
218
236
  cmd: {
219
237
  desc: "mostra/troca o modelo (--global grava, --once só o próximo turno)",
220
238
  run: async (args) => {
@@ -226,7 +244,7 @@ function modelCommand(get, set, note) {
226
244
  }
227
245
  if (!req.model) {
228
246
  if (!ui.canSelect) {
229
- ui.print(` ${c.soft(get())} ${c.muted(note)}\n ${c.muted(MODEL_HELP)}`);
247
+ ui.print(` ${c.soft(get())} ${c.muted(note)}\n ${c.muted(modelHelp())}`);
230
248
  return;
231
249
  }
232
250
  const picked = await pickModel(get());
@@ -237,6 +255,13 @@ function modelCommand(get, set, note) {
237
255
  req.model = picked.model;
238
256
  req.scope = picked.scope;
239
257
  }
258
+ if (divert && await divert(req.model, req.scope))
259
+ return;
260
+ const refused = veto(req.model);
261
+ if (refused) {
262
+ ui.warn(refused);
263
+ return;
264
+ }
240
265
  const old = get();
241
266
  if (req.scope === "once")
242
267
  restore ??= old; // dois --once seguidos devolvem o modelo original, não o intermediário
@@ -265,7 +290,7 @@ const skillsCommand = (root, io, llm) => ({
265
290
  ui.turnStart();
266
291
  let proposals;
267
292
  try {
268
- proposals = await proposeConsolidation(root, llm.model, llm.complete);
293
+ proposals = await proposeConsolidation(root, llm.model(), llm.complete);
269
294
  ui.turnEnd(false);
270
295
  }
271
296
  catch (e) {
@@ -348,8 +373,9 @@ const skillsCommand = (root, io, llm) => ({
348
373
  },
349
374
  });
350
375
  /** Reflexão pós-turno que propõe skills (gatilho automático ou `/learn`). Independe do backend: só muda a origem do turno e o modelo. */
351
- function makeLearner(getTurns, ctx, complete, model) {
376
+ function makeLearner(getTurns, ctx, complete, getModel) {
352
377
  return async (force = false) => {
378
+ const model = getModel();
353
379
  const turns = getTurns();
354
380
  const turn = turns[turns.length - 1] ?? null;
355
381
  // O resultado do turno vira evidência para as skills usadas nele (promoção/arquivamento), antes de refletir.
@@ -387,19 +413,99 @@ function makeLearner(getTurns, ctx, complete, model) {
387
413
  }
388
414
  };
389
415
  }
390
- async function runViaClaude(root, promptParts, yes) {
391
- const startedAt = Date.now();
392
- if (yes)
393
- perms.mode = "bypass";
394
- // O REPL troca `io.ask`; em prompt único cai na pergunta simples.
395
- const io = { ask: async (q, _opts) => perms.mode === "bypass" || plainAsk(q) };
396
- const approval = startApprovalServer({ ask: (q, opts) => io.ask(q, opts) });
416
+ /**
417
+ * Monta o loop próprio (`Agent`) e o `ReplBackend` do modo API. Serve à abertura normal e à troca para o qwen quando
418
+ * o claude CLI cai; `perms.mode` é global, então o modo de permissão da sessão segue igual.
419
+ */
420
+ async function buildApiBackend(root, model, io, o = {}) {
421
+ const graph = o.graph ?? await GraphStore.open(root);
422
+ // A contagem do prompt acompanha cada sincronização: sem isso o modelo veria "0 símbolos" a sessão inteira
423
+ // em projeto que começou vazio e nasceu código depois.
424
+ let symCount = 0;
425
+ const sync = async () => { symCount = (await indexProject(root, graph)).symbols; };
426
+ ui.info("sincronizando grafo…");
427
+ await sync(); // incremental: barato quando nada mudou
428
+ const [{ n: notes }] = await graph.run("MATCH (s:Note) RETURN count(s) AS n");
429
+ const noteCount = Number(notes);
430
+ const ctx = { root, graph, ask: (q, opts) => io.ask(q, opts), sync, dirty: false, loadedSkills: o.loadedSkills ?? new Set() };
431
+ 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 });
434
+ if (o.messages)
435
+ agent.messages = o.messages;
436
+ if (o.effort)
437
+ agent.effort = o.effort;
438
+ ctx.model = () => agent.model; // subagentes seguem o modelo vivo
439
+ ui.reasoning = loadReasoningDisplay();
440
+ const learnModel = () => learnerModel(agent.model, process.env);
441
+ loadProjectAgents(root);
442
+ /** Depois de tarefas complexas (ou uso de skill), propõe criar/melhorar skills. */
443
+ const learn = makeLearner(() => agent.recentTurns, ctx, defaultComplete, learnModel);
444
+ const turn = async (text, signal) => {
445
+ ui.turnStart();
446
+ let ok = true;
447
+ try {
448
+ await agent.send(text, signal);
449
+ }
450
+ catch (e) {
451
+ ok = false;
452
+ signal.aborted ? ui.warn("⏹ interrompido") : ui.error(e?.message ?? String(e));
453
+ }
454
+ finally {
455
+ ui.turnEnd(ok);
456
+ }
457
+ return ok;
458
+ };
459
+ // Sem ANTHROPIC_API_KEY o Claude não roda neste loop (sessão aberta no qwen): melhor recusar do que falhar no turno.
460
+ const noApiKey = (m) => (resolveModel(m).kind === "anthropic" && !process.env.ANTHROPIC_API_KEY?.trim()
461
+ ? `o ${m} precisa de ANTHROPIC_API_KEY neste modo; sem ela, reabra com \`brito --model ${m}\` para usar o claude CLI`
462
+ : null);
463
+ 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
+ const backend = {
465
+ model: () => agent.model,
466
+ contextTokens: () => agent.contextTokens,
467
+ usage: () => agent.usage,
468
+ get ctxWindow() { return contextWindowFor(agent.model); },
469
+ cost: () => (Number.isFinite(PRICE_IN) && Number.isFinite(PRICE_OUT) ? (agent.usage.input * PRICE_IN + agent.usage.output * PRICE_OUT) / 1e6 : undefined),
470
+ turn,
471
+ afterTurn: async () => { modelCmd.restoreOnce(fallback.active); await learn(); },
472
+ reset: () => { agent.messages = []; agent.contextTokens = 0; agent.recentTurns = []; agent.unsentTurns = []; agent.lastTurn = null; },
473
+ onShell: () => { ctx.dirty = true; }, // `!cmd` pode ter mudado o código
474
+ commands: {
475
+ "/model": modelCmd.cmd,
476
+ "/fallback": fallbackCommand(fallback, () => agent.model, o.engineLabel ?? ((m) => (resolveModel(m).kind === "openai" ? "endpoint" : "API Anthropic"))),
477
+ "/reasoning": reasoningCommand(() => agent.effort, (e) => { agent.effort = e; }),
478
+ "/tools": { desc: "ferramentas disponíveis", run: async () => { for (const t of allTools())
479
+ ui.print(` ${ui.toolLabel(t.name)}${c.dim(t.dangerous ? " (pede aprovação)" : "")}`); } },
480
+ "/context": {
481
+ desc: "uso da janela de contexto",
482
+ run: async () => {
483
+ const win = contextWindowFor(agent.model);
484
+ const pct = Math.round((agent.contextTokens / win) * 100);
485
+ ui.print(` ${c.soft(`${fmtTokens(agent.contextTokens)} / ${fmtTokens(win)}`)} ${c.muted(`(${pct}%) · ${agent.messages.length} mensagens · compacta em ~${fmtTokens(compactAtFor(agent.model))}`)}`);
486
+ },
487
+ },
488
+ "/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
+ "/learn": { desc: "extrai skills do último turno agora", run: async () => { await learn(true); } },
490
+ "/skills": skillsCommand(root, io, { complete: defaultComplete, model: learnModel }),
491
+ },
492
+ close: async () => { await graph.close(); },
493
+ };
494
+ return { backend, agent, graph, learn, symbols: () => symCount, notes: noteCount };
495
+ }
496
+ /** `--once` não troca de motor: o turno seguinte rodaria no outro motor e a volta exigiria trocar de novo. */
497
+ const onceNoSwitch = (m) => `o ${m} roda no claude CLI: --once não troca de motor; use /model ${m}`;
498
+ /** Um servidor de aprovação por processo (o endereço leva o pid): o claude CLI pode ser aberto de novo na mesma sessão. */
499
+ let approvalServer = null;
500
+ /**
501
+ * Motor claude CLI. `sw` é o backend do REPL: `/model <perfil>` troca para o modo API (com a conversa do claude) e
502
+ * `/model <claude>` lá volta para cá. Também serve ao prompt único, que não troca de motor pelo `/model`.
503
+ */
504
+ async function buildClaudeBackend(root, io, sw, model, openGraph) {
505
+ approvalServer ??= startApprovalServer({ ask: (q, opts) => io.ask(q, opts) });
397
506
  // O banco aceita um único processo com acesso de escrita: indexa, lê as stats e solta o banco para o servidor MCP.
398
- {
399
- const { offerNoteMigration } = await import("./graph/migrate.js");
400
- await offerNoteMigration(root, (q) => migrationAsk(q), (m) => ui.info(m));
401
- }
402
- const graph = await GraphStore.open(root);
507
+ // `openGraph`: o do modo API, que é fechado aqui só depois de indexar (se falhar antes, continua aberto para ele).
508
+ const graph = openGraph ?? await GraphStore.open(root);
403
509
  ui.info("sincronizando grafo…");
404
510
  await indexProject(root, graph);
405
511
  const [{ n: syms }] = await graph.run("MATCH (s:Symbol) RETURN count(s) AS n");
@@ -407,23 +513,88 @@ async function runViaClaude(root, promptParts, yes) {
407
513
  await graph.close();
408
514
  const session = new ClaudeSession({
409
515
  // Foto da abertura: o índice é atualizado pelo servidor MCP (outro processo) e este processo não reabre o banco.
410
- root, approvalSock: approval.path, stats: graphStatsLine(Number(syms), Number(notes), "start"),
516
+ root, approvalSock: approvalServer.path, stats: graphStatsLine(Number(syms), Number(notes), "start"),
411
517
  mcpCommand: { command: process.execPath, args: [...process.execArgv, process.argv[1], "mcp-serve", "-d", root] },
412
518
  });
413
519
  loadProjectAgents(root);
414
520
  // O grafo já foi solto para o servidor MCP; skill_save só usa o store de skills, então `graph` não é tocado.
415
521
  const learnCtx = { root, graph: undefined, ask: (q, opts) => io.ask(q, opts), sync: async () => { }, dirty: false, loadedSkills: session.loadedSkills };
416
- const learn = makeLearner(() => session.recentTurns, learnCtx, claudeCliComplete, process.env.BRITO_LEARN_MODEL ?? ""); // "" = modelo padrão do claude
417
- if (promptParts.length) {
418
- const ok = await session.send(promptParts.join(" "));
419
- if (ok)
420
- await learn();
421
- else
422
- process.exitCode = 1;
423
- return;
522
+ const learn = makeLearner(() => session.recentTurns, learnCtx, claudeCliComplete, () => process.env.BRITO_LEARN_MODEL ?? ""); // "" = modelo padrão do claude
523
+ if (model) {
524
+ session.requestedModel = resolveModel(model).wireModel;
525
+ session.model = session.requestedModel;
424
526
  }
425
- const modelCmd = modelCommand(() => session.requestedModel || session.model, (m) => { session.requestedModel = m; session.model = m; }, "(assinatura do claude CLI)");
426
527
  session.effort = initialEffort();
528
+ const fallback = newFallbackState(session.requestedModel || session.model);
529
+ /** Loop próprio do Brito no `name`, com a conversa do claude; `/model <claude>` nele volta para cá. */
530
+ const openApi = async (name, label, opts = {}) => {
531
+ let api;
532
+ try {
533
+ api = await buildApiBackend(root, name, io, {
534
+ fallback, effort: session.effort, loadedSkills: session.loadedSkills,
535
+ messages: historyForFallback(session.allTurns), graph: await openGraphAfterClaude(root), ...opts,
536
+ divert: async (m, scope) => {
537
+ if (resolveModel(m).kind !== "anthropic")
538
+ return false;
539
+ if (scope === "once") {
540
+ ui.warn(onceNoSwitch(m));
541
+ return true;
542
+ }
543
+ sw().swap(backend);
544
+ await api.backend.close();
545
+ session.handoff(api.agent.unsentTurns.splice(0), api.agent.model);
546
+ ui.info("de volta ao claude CLI: o que foi feito no modo API segue no próximo pedido");
547
+ await modelCmd.cmd.run([m, `--${scope}`]);
548
+ return true;
549
+ },
550
+ });
551
+ }
552
+ catch (e) {
553
+ ui.error(`não consegui abrir o ${label}: ${e?.message ?? e}`);
554
+ return null;
555
+ }
556
+ return api;
557
+ };
558
+ /**
559
+ * O turno do claude caiu por conta/capacidade: monta o loop próprio no qwen com a conversa do claude e refaz o pedido
560
+ * nele. null = a falha não leva ao fallback (ou ele não está pronto); a sessão segue no claude.
561
+ */
562
+ const switchToQwen = async (text, signal) => {
563
+ const status = fallbackStatus(fallback.primary, process.env);
564
+ const f = session.lastFailure;
565
+ if (!f || !status.ready || !cliShouldFallBack(f, fallback, status, signal.aborted))
566
+ return null;
567
+ ui.warn(fallbackWarning(f.kind, f.message, status.label));
568
+ const api = await openApi(status.name, status.label, { engineLabel: () => "claude CLI · assinatura" });
569
+ if (!api)
570
+ return null;
571
+ activateFallback(fallback, `${f.kind} (${f.message})`);
572
+ return { api, retry: () => api.backend.turn(retryPrompt(text), signal) };
573
+ };
574
+ /** `/model <perfil>`: perfis de endpoint rodam no loop próprio do Brito, então o motor troca para o modo API. */
575
+ const toApi = async (m, scope) => {
576
+ const r = resolveModel(m);
577
+ if (r.kind !== "openai")
578
+ return false;
579
+ if (scope === "once") {
580
+ ui.warn(`o ${r.label} roda no modo API: --once não troca de motor; use /model ${r.name}`);
581
+ return true;
582
+ }
583
+ const old = session.requestedModel || session.model;
584
+ const api = await openApi(r.name, r.label);
585
+ if (!api)
586
+ return true;
587
+ modelCmd.restoreOnce(true); // um `--once` pendente devolveria o Claude depois do primeiro turno no outro motor
588
+ sw().swap(api.backend);
589
+ ui.print(` ${c.muted(old)} → ${c.soft(r.name)}`);
590
+ ui.info(`modo API: o ${r.label} segue a conversa do claude`);
591
+ if (scope === "global") {
592
+ saveModel(r.name);
593
+ ui.print(c.muted(" salvo como padrão (~/.brito/config.json)"));
594
+ }
595
+ return true;
596
+ };
597
+ const modelCmd = modelCommand(() => session.requestedModel || session.model, (m) => { session.requestedModel = m; session.model = m; chooseModel(fallback, m); }, "(assinatura do claude CLI)", () => null, toApi);
427
598
  ui.reasoning = loadReasoningDisplay();
428
599
  const unavailable = { desc: "indisponível no modo claude CLI", run: async () => { ui.warn("não está disponível no modo claude CLI"); } };
429
600
  const mcpTools = allTools().map((t) => t.name).filter((n) => /^(graph_|skill_)/.test(n) || n === "remember" || n === "recall");
@@ -433,12 +604,22 @@ async function runViaClaude(root, promptParts, yes) {
433
604
  usage: () => session.usage,
434
605
  cost: () => (session.costUsd > 0 ? session.costUsd : undefined),
435
606
  get ctxWindow() { return session.contextWindow ?? CTX_WINDOW; },
436
- turn: (text) => session.send(text),
607
+ turn: async (text, signal) => {
608
+ if (await session.send(text))
609
+ return true;
610
+ const fb = await switchToQwen(text, signal);
611
+ if (!fb)
612
+ return false;
613
+ modelCmd.restoreOnce(true); // um `--once` pendente voltaria ao Claude que acabou de cair
614
+ sw().swap(fb.api.backend);
615
+ return fb.retry();
616
+ },
437
617
  afterTurn: async () => { modelCmd.restoreOnce(); await learn(); },
438
618
  abort: () => session.abort(),
439
619
  reset: () => session.reset(),
440
620
  commands: {
441
621
  "/model": modelCmd.cmd,
622
+ "/fallback": fallbackCommand(fallback, () => session.model, () => "claude CLI · assinatura"),
442
623
  "/effort": effortCommand(() => session.effort, (e) => { session.effort = e; }),
443
624
  "/reasoning": reasoningCommand(() => session.effort, (e) => { session.effort = e; }),
444
625
  "/tools": { desc: "ferramentas do grafo/memória/skills", run: async () => { for (const n of mcpTools)
@@ -454,16 +635,49 @@ async function runViaClaude(root, promptParts, yes) {
454
635
  },
455
636
  "/context": unavailable,
456
637
  "/learn": { desc: "extrai skills do último turno agora", run: async () => { await learn(true); } },
457
- "/skills": skillsCommand(root, io, { complete: claudeCliComplete, model: process.env.BRITO_LEARN_MODEL ?? "" }),
638
+ "/skills": skillsCommand(root, io, { complete: claudeCliComplete, model: () => process.env.BRITO_LEARN_MODEL ?? "" }),
458
639
  },
459
640
  close: async () => { },
460
641
  };
642
+ return { backend, session, learn, switchToQwen, modelCmd, syms: Number(syms), notes: Number(notes), mcpTools };
643
+ }
644
+ async function runViaClaude(root, promptParts, yes, model) {
645
+ const startedAt = Date.now();
646
+ if (yes)
647
+ perms.mode = "bypass";
648
+ // O REPL troca `io.ask`; em prompt único cai na pergunta simples.
649
+ const io = { ask: async (q, _opts) => perms.mode === "bypass" || plainAsk(q) };
650
+ {
651
+ const { offerNoteMigration } = await import("./graph/migrate.js");
652
+ await offerNoteMigration(root, (q) => migrationAsk(q), (m) => ui.info(m));
653
+ }
654
+ let sw;
655
+ const claude = await buildClaudeBackend(root, io, () => sw, model);
656
+ if (promptParts.length) {
657
+ const text = promptParts.join(" ");
658
+ if (await claude.session.send(text)) {
659
+ await claude.learn();
660
+ return;
661
+ }
662
+ const fb = await claude.switchToQwen(text, new AbortController().signal);
663
+ if (!fb) {
664
+ process.exitCode = 1;
665
+ return;
666
+ }
667
+ if (await fb.retry())
668
+ await fb.api.learn();
669
+ else
670
+ process.exitCode = 1;
671
+ await fb.api.backend.close();
672
+ return;
673
+ }
674
+ sw = switchableBackend(claude.backend);
461
675
  ui.info("modo claude CLI: usa o login da sua conta; tools do grafo/memória/skills via MCP.");
462
- await runRepl(backend, {
676
+ await runRepl(sw, {
463
677
  root, yes, startedAt, io,
464
678
  banner: {
465
- dir: root, symbols: Number(syms), notes: Number(notes),
466
- tools: ["read_file", "write_file", "edit_file", "glob", "grep", "bash", "delegate", ...mcpTools],
679
+ dir: root, symbols: claude.syms, notes: claude.notes,
680
+ tools: ["read_file", "write_file", "edit_file", "glob", "grep", "bash", "delegate", ...claude.mcpTools],
467
681
  skills: skillInfo(root), agents: listAgents().map((a) => a.name),
468
682
  },
469
683
  });
@@ -472,6 +686,7 @@ program
472
686
  .argument("[prompt...]", "prompt único (omita para modo interativo)")
473
687
  .option("-d, --dir <dir>", "raiz do projeto")
474
688
  .option("-y, --yes", "aprovar edições e comandos automaticamente")
689
+ .option("-m, --model <nome>", "modelo ou perfil de endpoint (ex.: sonnet, qwen)")
475
690
  .action(async (promptParts, o) => {
476
691
  // Trava anti-recursão: um brito iniciado por dentro de um claude que o próprio brito lançou (BRITO_DEPTH) pode abrir
477
692
  // outro claude, e assim por diante. Dois níveis bastam; mais que isso é laço de processos.
@@ -479,12 +694,16 @@ program
479
694
  console.error("brito aninhado demais (BRITO_DEPTH): abortando para evitar laço de processos.");
480
695
  process.exit(1);
481
696
  }
482
- if (!process.env.ANTHROPIC_API_KEY) {
483
- if (!hasClaudeCli()) {
484
- console.error(c.err("Defina ANTHROPIC_API_KEY ou instale o Claude Code e rode `claude` → /login."));
485
- process.exit(1);
486
- }
487
- await runViaClaude(rootOf(o), promptParts, !!o.yes);
697
+ for (const e of loadProfiles().errors)
698
+ ui.warn(e);
699
+ const startModel = o.model ?? initialModel();
700
+ const engine = openEngine(startModel, process.env, hasClaudeCli);
701
+ if (engine.kind === "error") {
702
+ console.error(c.err(engine.message));
703
+ process.exit(1);
704
+ }
705
+ if (engine.kind === "claude") {
706
+ await runViaClaude(rootOf(o), promptParts, !!o.yes, o.model);
488
707
  return;
489
708
  }
490
709
  const root = rootOf(o);
@@ -493,78 +712,60 @@ program
493
712
  const { offerNoteMigration } = await import("./graph/migrate.js");
494
713
  await offerNoteMigration(root, (q) => migrationAsk(q), (m) => ui.info(m));
495
714
  }
496
- const graph = await GraphStore.open(root);
497
- // A contagem do prompt acompanha cada sincronização: sem isso o modelo veria "0 símbolos" a sessão inteira
498
- // em projeto que começou vazio e nasceu código depois.
499
- let symCount = 0, noteCount = 0;
500
- const sync = async () => { symCount = (await indexProject(root, graph)).symbols; };
501
- ui.info("sincronizando grafo…");
502
- await sync(); // incremental: barato quando nada mudou
503
- const [{ n: notes }] = await graph.run("MATCH (s:Note) RETURN count(s) AS n");
504
- noteCount = Number(notes);
505
715
  // `io.ask` é substituída pelo REPL (dock); em prompt único cai na pergunta simples.
506
716
  if (o.yes)
507
717
  perms.mode = "bypass";
508
718
  const io = { ask: async (q, _opts) => perms.mode === "bypass" || plainAsk(q) };
509
- const ctx = { root, graph, ask: (q, opts) => io.ask(q, opts), sync, dirty: false, loadedSkills: new Set() };
510
- // 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.
511
- const agent = new Agent(() => buildSystemPrompt(root, graphStatsLine(symCount, noteCount, "now"), skillsIndex(root)) + (perms.mode === "plan" ? PLAN_NOTE : ""), ctx);
512
- loadProjectAgents(root);
513
- /** Depois de tarefas complexas (ou uso de skill), propõe criar/melhorar skills. */
514
- const learn = makeLearner(() => agent.recentTurns, ctx, defaultComplete, process.env.BRITO_LEARN_MODEL ?? MODEL);
515
- const turn = async (text, signal) => {
516
- ui.turnStart();
517
- let ok = true;
518
- try {
519
- await agent.send(text, signal);
520
- }
521
- catch (e) {
522
- ok = false;
523
- signal.aborted ? ui.warn("⏹ interrompido") : ui.error(e?.message ?? String(e));
524
- }
525
- finally {
526
- ui.turnEnd(ok);
527
- }
528
- return ok;
529
- };
719
+ // Claude sem conta nenhuma, com o fallback pronto: a sessão já nasce no qwen (com aviso, nunca em silêncio).
720
+ let opening = {};
721
+ if (engine.kind === "fallback") {
722
+ ui.warn(`⚠ ${NO_ANTHROPIC} (sem ANTHROPIC_API_KEY e sem o claude CLI) — abrindo no ${engine.label}. /fallback para detalhes.`);
723
+ const fallback = newFallbackState(startModel);
724
+ activateFallback(fallback, NO_ANTHROPIC);
725
+ opening = { fallback, engineLabel: () => NO_ANTHROPIC };
726
+ }
727
+ let sw;
728
+ // Sem ANTHROPIC_API_KEY o Claude só roda no claude CLI: `/model <claude>` troca o motor em vez de recusar.
729
+ // A conversa feita aqui vai junto (`handoff`); o claude CLI aberto é reaproveitado nas voltas seguintes.
730
+ let claude = null;
731
+ if (hasClaudeCli())
732
+ opening.divert = async (m, scope) => {
733
+ if (resolveModel(m).kind !== "anthropic" || process.env.ANTHROPIC_API_KEY?.trim())
734
+ return false;
735
+ if (scope === "once") {
736
+ ui.warn(onceNoSwitch(m));
737
+ return true;
738
+ }
739
+ // Indexa com o grafo ainda aberto e só o solta (para o servidor MCP do claude) se tudo deu certo: numa falha,
740
+ // a sessão segue no modo API com o grafo vivo.
741
+ if (claude)
742
+ await api.backend.close();
743
+ else
744
+ try {
745
+ claude = await buildClaudeBackend(root, io, () => sw, undefined, api.graph);
746
+ }
747
+ catch (e) {
748
+ ui.error(`não consegui abrir o claude CLI: ${e?.message ?? e}`);
749
+ return true;
750
+ }
751
+ sw.swap(claude.backend);
752
+ claude.session.handoff(api.agent.unsentTurns.splice(0), api.agent.model);
753
+ ui.info("modo claude CLI: usa o login da sua conta; a conversa feita no modo API segue no próximo pedido");
754
+ await claude.modelCmd.cmd.run([m, `--${scope}`]);
755
+ return true;
756
+ };
757
+ const api = await buildApiBackend(root, engine.kind === "fallback" ? FALLBACK_PROFILE : startModel, io, opening);
530
758
  if (promptParts.length) {
531
- if (await turn(promptParts.join(" "), new AbortController().signal))
532
- await learn();
533
- await graph.close();
759
+ if (await api.backend.turn(promptParts.join(" "), new AbortController().signal))
760
+ await api.learn();
761
+ await api.backend.close();
534
762
  return;
535
763
  }
536
- const modelCmd = modelCommand(() => agent.model, (m) => { agent.model = m; }, "(troque com /model; subagentes explore usam BRITO_EXPLORE_MODEL)");
537
- const backend = {
538
- model: () => agent.model,
539
- contextTokens: () => agent.contextTokens,
540
- usage: () => agent.usage,
541
- ctxWindow: CTX_WINDOW,
542
- cost: () => (Number.isFinite(PRICE_IN) && Number.isFinite(PRICE_OUT) ? (agent.usage.input * PRICE_IN + agent.usage.output * PRICE_OUT) / 1e6 : undefined),
543
- turn,
544
- afterTurn: async () => { modelCmd.restoreOnce(); await learn(); },
545
- reset: () => { agent.messages = []; agent.contextTokens = 0; agent.recentTurns = []; agent.lastTurn = null; },
546
- onShell: () => { ctx.dirty = true; }, // `!cmd` pode ter mudado o código
547
- commands: {
548
- "/model": modelCmd.cmd,
549
- "/tools": { desc: "ferramentas disponíveis", run: async () => { for (const t of allTools())
550
- ui.print(` ${ui.toolLabel(t.name)}${c.dim(t.dangerous ? " (pede aprovação)" : "")}`); } },
551
- "/context": {
552
- desc: "uso da janela de contexto",
553
- run: async () => {
554
- const pct = Math.round((agent.contextTokens / CTX_WINDOW) * 100);
555
- ui.print(` ${c.soft(`${fmtTokens(agent.contextTokens)} / ${fmtTokens(CTX_WINDOW)}`)} ${c.muted(`(${pct}%) · ${agent.messages.length} mensagens · compacta em ~${fmtTokens(Number(process.env.BRITO_COMPACT_TOKENS ?? 120_000))}`)}`);
556
- },
557
- },
558
- "/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 })))); } },
559
- "/learn": { desc: "extrai skills do último turno agora", run: async () => { await learn(true); } },
560
- "/skills": skillsCommand(root, io, { complete: defaultComplete, model: process.env.BRITO_LEARN_MODEL ?? MODEL }),
561
- },
562
- close: async () => { await graph.close(); },
563
- };
564
- await runRepl(backend, {
764
+ sw = switchableBackend(api.backend);
765
+ await runRepl(sw, {
565
766
  root, yes: !!o.yes, startedAt, io,
566
767
  banner: {
567
- dir: root, symbols: symCount, notes: noteCount, tools: allTools().map((t) => t.name),
768
+ dir: root, symbols: api.symbols(), notes: api.notes, tools: allTools().map((t) => t.name),
568
769
  skills: skillInfo(root), agents: listAgents().map((a) => a.name),
569
770
  },
570
771
  });