brito-cli 0.6.4 → 0.7.1
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/README.md +42 -0
- package/README.pt-BR.md +49 -0
- package/dist/agent/agents.js +1 -1
- package/dist/agent/claude-cli.js +127 -9
- package/dist/agent/context.js +6 -3
- package/dist/agent/loop.js +69 -23
- package/dist/cli.js +312 -111
- package/dist/desktop/serve.js +133 -32
- package/dist/engine.js +79 -0
- package/dist/graph/store.js +15 -0
- package/dist/model.js +48 -2
- package/dist/provider/anthropic.js +31 -0
- package/dist/provider/fallback.js +147 -0
- package/dist/provider/index.js +27 -0
- package/dist/provider/openai-compat.js +151 -0
- package/dist/provider/openai-wire.js +89 -0
- package/dist/provider/resolve.js +27 -0
- package/dist/provider/sse.js +33 -0
- package/dist/provider/types.js +1 -0
- package/dist/repl.js +50 -0
- package/dist/skills/learn.js +3 -2
- package/dist/tools/delegate.js +3 -1
- package/package.json +1 -1
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
|
|
16
|
-
import { EFFORTS, MODEL_ALIASES,
|
|
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
|
-
|
|
106
|
-
const
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
const
|
|
396
|
-
|
|
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
|
-
|
|
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:
|
|
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 (
|
|
418
|
-
|
|
419
|
-
|
|
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) =>
|
|
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(
|
|
676
|
+
await runRepl(sw, {
|
|
463
677
|
root, yes, startedAt, io,
|
|
464
678
|
banner: {
|
|
465
|
-
dir: root, symbols:
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
|
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
|
-
|
|
537
|
-
|
|
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:
|
|
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
|
});
|