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/desktop/serve.js
CHANGED
|
@@ -10,20 +10,25 @@ import "../tools/delegate.js";
|
|
|
10
10
|
import "../tools/plan.js";
|
|
11
11
|
import "../tools/skills.js";
|
|
12
12
|
import { Agent } from "../agent/loop.js";
|
|
13
|
-
import { ClaudeSession, hasClaudeCli } from "../agent/claude-cli.js";
|
|
13
|
+
import { ClaudeSession, hasClaudeCli, historyForFallback } from "../agent/claude-cli.js";
|
|
14
14
|
import { startApprovalServer } from "../agent/approval.js";
|
|
15
15
|
import { loadProjectAgents } from "../agent/agents.js";
|
|
16
16
|
import { buildSystemPrompt, graphStatsLine } from "../agent/prompt.js";
|
|
17
|
-
import {
|
|
17
|
+
import { openGraphAfterClaude } from "../graph/store.js";
|
|
18
18
|
import { indexProject } from "../graph/indexer.js";
|
|
19
|
-
import {
|
|
19
|
+
import { initialEffort, initialModel, isEffort, loadProfiles, MODEL_ALIASES } from "../model.js";
|
|
20
|
+
import { FALLBACK_PROFILE, NO_ANTHROPIC, fallbackStatus, openEngine } from "../engine.js";
|
|
21
|
+
import { activateFallback, chooseModel, cliShouldFallBack, fallbackWarning, newFallbackState, renderFallback, retryPrompt } from "../provider/fallback.js";
|
|
22
|
+
import { contextWindowFor, resolveModel } from "../provider/resolve.js";
|
|
20
23
|
import { PLAN_NOTE, perms } from "../permissions.js";
|
|
21
24
|
import { skillsIndex } from "../skills/store.js";
|
|
22
25
|
/**
|
|
23
26
|
* Backend do app desktop, controlado por JSON em linhas.
|
|
24
27
|
* stdin → {t:"send",text} {t:"abort"} {t:"approve",id,allow} {t:"config",model?,effort?,mode?} {t:"reset"} {t:"quit"}
|
|
25
28
|
* stdout → linhas "@@{json}" (qualquer outra linha, como logs perdidos, a janela ignora).
|
|
26
|
-
* Motor:
|
|
29
|
+
* Motor: perfil de endpoint (ex.: Qwen) ou ANTHROPIC_API_KEY usam o loop próprio (como o `brito` no terminal); sem
|
|
30
|
+
* nada disso, o `claude` CLI (assinatura). Se o Claude cai por conta/capacidade (ou nem há conta), segue no qwen.
|
|
31
|
+
* `/fallback` enviado como texto responde o estado do fallback sem passar pelo modelo.
|
|
27
32
|
*/
|
|
28
33
|
const MARK = "@@";
|
|
29
34
|
const emit = (ev) => process.stdout.write(MARK + JSON.stringify(ev) + "\n");
|
|
@@ -39,7 +44,7 @@ const sink = {
|
|
|
39
44
|
warn: (msg) => emit({ t: "warn", msg }),
|
|
40
45
|
error: (msg) => emit({ t: "error", msg }),
|
|
41
46
|
};
|
|
42
|
-
const
|
|
47
|
+
const apiLabel = (m) => (resolveModel(m).kind === "openai" ? "endpoint" : "API Anthropic");
|
|
43
48
|
export async function serveDesktop(root, resumeId) {
|
|
44
49
|
const pending = new Map();
|
|
45
50
|
const ask = (text, opts) => perms.mode === "bypass" && !opts?.force ? Promise.resolve(true) : new Promise((resolve) => {
|
|
@@ -49,21 +54,26 @@ export async function serveDesktop(root, resumeId) {
|
|
|
49
54
|
});
|
|
50
55
|
// Na pasta pessoal (projeto "Home") indexar tudo seria enorme e inútil: sem índice ali.
|
|
51
56
|
const isHome = path.resolve(root) === path.resolve(os.homedir());
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
57
|
+
for (const e of loadProfiles().errors)
|
|
58
|
+
emit({ t: "warn", msg: e });
|
|
59
|
+
const startModel = initialModel();
|
|
60
|
+
const opening = openEngine(startModel, process.env, hasClaudeCli);
|
|
61
|
+
if (opening.kind === "error") {
|
|
62
|
+
const msg = resolveModel(startModel).kind === "openai" ? opening.message : opening.message.replace(/^Defina ANTHROPIC_API_KEY/, "Sem acesso ao modelo: defina ANTHROPIC_API_KEY (no seu shell)");
|
|
63
|
+
emit({ t: "error", msg });
|
|
55
64
|
process.exit(1);
|
|
56
65
|
}
|
|
57
66
|
loadProjectAgents(root);
|
|
58
67
|
let engine;
|
|
59
68
|
let syms = 0, notes = 0;
|
|
60
69
|
let closeApproval = () => { };
|
|
61
|
-
|
|
62
|
-
|
|
70
|
+
/** Loop próprio (API direta ou endpoint): o grafo fica aberto neste processo (sem servidor MCP), como no `brito` do terminal. */
|
|
71
|
+
const buildApiEngine = async (model, o = {}) => {
|
|
63
72
|
// No Home o banco vai para ~/.brito/home-graph, para não criar .brito dentro da pasta pessoal.
|
|
64
73
|
const graphRoot = isHome ? path.join(os.homedir(), ".brito", "home-graph") : root;
|
|
65
74
|
fs.mkdirSync(graphRoot, { recursive: true });
|
|
66
|
-
|
|
75
|
+
// Na troca, o servidor MCP do claude pode ainda estar soltando o banco.
|
|
76
|
+
const graph = await openGraphAfterClaude(graphRoot);
|
|
67
77
|
const sync = async () => { if (!isHome)
|
|
68
78
|
syms = (await indexProject(root, graph)).symbols; };
|
|
69
79
|
emit({ t: "info", msg: "sincronizando grafo…" });
|
|
@@ -77,16 +87,41 @@ export async function serveDesktop(root, resumeId) {
|
|
|
77
87
|
notes = Number((await graph.run("MATCH (s:Note) RETURN count(s) AS n"))[0].n);
|
|
78
88
|
}
|
|
79
89
|
catch { /* sem notas */ }
|
|
80
|
-
const ctx = { root, graph, ask, sync, dirty: false, loadedSkills: new Set() };
|
|
81
|
-
const
|
|
90
|
+
const ctx = { root, graph, ask, sync, dirty: false, loadedSkills: o.loadedSkills ?? new Set(), model: () => agent.model };
|
|
91
|
+
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 });
|
|
93
|
+
if (o.messages)
|
|
94
|
+
agent.messages = o.messages;
|
|
95
|
+
if (o.effort)
|
|
96
|
+
agent.effort = o.effort;
|
|
82
97
|
let ac = null;
|
|
83
98
|
const id = randomUUID();
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
99
|
+
return {
|
|
100
|
+
kind: "api", effort: "", fallback,
|
|
101
|
+
// Depois da troca o id segue o do claude: a janela o guarda para retomar a conversa ao reabrir.
|
|
102
|
+
get sessionId() { return o.back?.engine.sessionId ?? id; },
|
|
88
103
|
get model() { return agent.model; }, set model(m) { agent.model = m; },
|
|
89
|
-
|
|
104
|
+
engineLabel: o.engineLabel ?? apiLabel,
|
|
105
|
+
setModel: (m) => {
|
|
106
|
+
const target = m || initialModel();
|
|
107
|
+
// Sem ANTHROPIC_API_KEY o Claude não roda neste loop: volta ao claude CLI de onde veio, ou segue no qwen.
|
|
108
|
+
if (resolveModel(target).kind === "anthropic" && !process.env.ANTHROPIC_API_KEY?.trim()) {
|
|
109
|
+
if (o.back) {
|
|
110
|
+
engine = o.back.engine;
|
|
111
|
+
engine.setModel(m);
|
|
112
|
+
if (!m)
|
|
113
|
+
chooseModel(fallback, target); // com m vazio o claude não escolhe (o modelo dele vira o placeholder)
|
|
114
|
+
o.back.release(graph.close().catch(() => { }));
|
|
115
|
+
o.back.handoff(agent.unsentTurns.splice(0), agent.model);
|
|
116
|
+
sink.info("de volta ao claude CLI: o que foi feito no qwen segue no próximo pedido");
|
|
117
|
+
}
|
|
118
|
+
else if (m)
|
|
119
|
+
sink.warn(`o ${m} precisa de ANTHROPIC_API_KEY (ou do claude CLI); seguindo no ${agent.model}`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
agent.model = target;
|
|
123
|
+
chooseModel(fallback, target);
|
|
124
|
+
},
|
|
90
125
|
async send(text) {
|
|
91
126
|
ac = new AbortController();
|
|
92
127
|
sink.turnStart();
|
|
@@ -104,10 +139,30 @@ export async function serveDesktop(root, resumeId) {
|
|
|
104
139
|
}
|
|
105
140
|
},
|
|
106
141
|
abort: () => ac?.abort(),
|
|
107
|
-
reset: () => {
|
|
108
|
-
|
|
142
|
+
reset: () => {
|
|
143
|
+
agent.messages = [];
|
|
144
|
+
agent.contextTokens = 0;
|
|
145
|
+
agent.recentTurns = [];
|
|
146
|
+
agent.unsentTurns = [];
|
|
147
|
+
agent.lastTurn = null;
|
|
148
|
+
o.back?.engine.reset(); // senão a volta ao claude retomaria (--resume) a conversa limpa
|
|
149
|
+
},
|
|
150
|
+
stats: () => ({ usage: agent.usage, contextTokens: agent.contextTokens, contextWindow: contextWindowFor(agent.model), costUsd: o.back?.costUsd() ?? 0 }),
|
|
109
151
|
close: async () => { await graph.close(); },
|
|
110
152
|
};
|
|
153
|
+
};
|
|
154
|
+
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." });
|
|
157
|
+
if (opening.kind === "fallback") {
|
|
158
|
+
// Claude sem conta nenhuma, com o fallback pronto: a sessão já nasce no qwen (com aviso, nunca em silêncio).
|
|
159
|
+
emit({ t: "warn", msg: `⚠ ${NO_ANTHROPIC} (sem ANTHROPIC_API_KEY e sem o claude CLI) — abrindo no ${opening.label}. Envie /fallback para detalhes.` });
|
|
160
|
+
const fallback = newFallbackState(startModel);
|
|
161
|
+
activateFallback(fallback, NO_ANTHROPIC);
|
|
162
|
+
engine = await buildApiEngine(FALLBACK_PROFILE, { fallback, engineLabel: () => NO_ANTHROPIC });
|
|
163
|
+
}
|
|
164
|
+
else
|
|
165
|
+
engine = await buildApiEngine(startModel);
|
|
111
166
|
}
|
|
112
167
|
else {
|
|
113
168
|
const approval = startApprovalServer({ ask });
|
|
@@ -115,7 +170,7 @@ export async function serveDesktop(root, resumeId) {
|
|
|
115
170
|
// O banco aceita um único processo com escrita: indexa, lê as stats e solta o banco para o servidor MCP.
|
|
116
171
|
if (!isHome)
|
|
117
172
|
try {
|
|
118
|
-
const graph = await
|
|
173
|
+
const graph = await openGraphAfterClaude(root);
|
|
119
174
|
emit({ t: "info", msg: "sincronizando grafo…" });
|
|
120
175
|
await indexProject(root, graph);
|
|
121
176
|
syms = Number((await graph.run("MATCH (s:Symbol) RETURN count(s) AS n"))[0].n);
|
|
@@ -133,22 +188,60 @@ export async function serveDesktop(root, resumeId) {
|
|
|
133
188
|
});
|
|
134
189
|
if (resumeId)
|
|
135
190
|
session.resume(resumeId);
|
|
191
|
+
let resumed = !!resumeId; // até o primeiro reset
|
|
136
192
|
session.effort = initialEffort();
|
|
137
|
-
|
|
138
|
-
|
|
193
|
+
const fallback = newFallbackState(session.requestedModel || session.model);
|
|
194
|
+
/** Fechamento do grafo do modo API na volta para cá (ver `back.release`). */
|
|
195
|
+
let graphReleased = Promise.resolve();
|
|
196
|
+
const claude = {
|
|
197
|
+
kind: "claude", fallback,
|
|
139
198
|
get sessionId() { return session.sessionId; },
|
|
140
199
|
get model() { return session.model; },
|
|
141
200
|
get effort() { return session.effort; }, set effort(e) { session.effort = e; },
|
|
142
|
-
|
|
143
|
-
|
|
201
|
+
engineLabel: () => "claude CLI · assinatura",
|
|
202
|
+
setModel: (m) => { session.requestedModel = m; session.model = m || "claude (assinatura)"; if (m)
|
|
203
|
+
chooseModel(fallback, m); },
|
|
204
|
+
send: async (text) => {
|
|
205
|
+
await graphReleased;
|
|
206
|
+
if (await session.send(text))
|
|
207
|
+
return;
|
|
208
|
+
// Caiu por conta/capacidade: monta o loop próprio no qwen com a conversa do claude e refaz o pedido nele.
|
|
209
|
+
const status = fallbackStatus(fallback.primary, process.env);
|
|
210
|
+
const f = session.lastFailure;
|
|
211
|
+
if (!f || !status.ready || !cliShouldFallBack(f, fallback, status))
|
|
212
|
+
return;
|
|
213
|
+
sink.warn(fallbackWarning(f.kind, f.message, status.label));
|
|
214
|
+
// `resume` só reaproveita o id: o que veio antes da reabertura está na sessão do claude, não em `allTurns`.
|
|
215
|
+
if (resumed)
|
|
216
|
+
sink.warn(`a conversa anterior à reabertura ficou no claude CLI: o ${status.label} herda só o que foi dito desde então`);
|
|
217
|
+
let api;
|
|
218
|
+
try {
|
|
219
|
+
api = await buildApiEngine(status.name, {
|
|
220
|
+
fallback, engineLabel: () => "claude CLI · assinatura", messages: historyForFallback(session.allTurns),
|
|
221
|
+
effort: session.effort, loadedSkills: session.loadedSkills, back: {
|
|
222
|
+
engine: claude, costUsd: () => session.costUsd, handoff: (turns, label) => session.handoff(turns, label),
|
|
223
|
+
release: (closing) => { graphReleased = closing; },
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
catch (e) {
|
|
228
|
+
sink.error(`não consegui abrir o ${status.label}: ${e?.message ?? e}`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
activateFallback(fallback, `${f.kind} (${f.message})`);
|
|
232
|
+
engine = api;
|
|
233
|
+
ready();
|
|
234
|
+
await api.send(retryPrompt(text, resumed));
|
|
235
|
+
},
|
|
144
236
|
abort: () => session.abort(),
|
|
145
|
-
reset: () => session.reset(),
|
|
237
|
+
reset: () => { session.reset(); resumed = false; },
|
|
146
238
|
stats: () => ({ usage: session.usage, contextTokens: session.contextTokens, contextWindow: session.contextWindow, costUsd: session.costUsd }),
|
|
147
239
|
close: async () => { },
|
|
148
240
|
};
|
|
241
|
+
engine = claude;
|
|
149
242
|
}
|
|
150
243
|
let busy = false;
|
|
151
|
-
const ready = () => emit({ t: "ready", engine: engine.kind, sessionId: engine.sessionId, model: engine.model, effort: engine.effort, mode: perms.mode, symbols: syms, notes, root });
|
|
244
|
+
const ready = () => emit({ t: "ready", engine: engine.kind, sessionId: engine.sessionId, model: engine.model, effort: engine.effort, mode: perms.mode, symbols: syms, notes, root, fallback: engine.fallback.active });
|
|
152
245
|
ready();
|
|
153
246
|
const rl = readline.createInterface({ input: process.stdin });
|
|
154
247
|
const quit = async () => { closeApproval(); try {
|
|
@@ -168,13 +261,23 @@ export async function serveDesktop(root, resumeId) {
|
|
|
168
261
|
case "send": {
|
|
169
262
|
if (busy)
|
|
170
263
|
return emit({ t: "warn", msg: "já existe um turno em andamento" });
|
|
264
|
+
if (String(m.text ?? "").trim() === "/fallback") {
|
|
265
|
+
const fb = engine.fallback;
|
|
266
|
+
const primary = fb.active ? fb.primary : engine.model;
|
|
267
|
+
const lines = renderFallback(fb, engine.model, engine.engineLabel(primary), fallbackStatus(primary, process.env));
|
|
268
|
+
sink.turnStart();
|
|
269
|
+
sink.text("```\n" + lines.join("\n") + "\n```");
|
|
270
|
+
sink.textEnd();
|
|
271
|
+
sink.turnEnd(true);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
171
274
|
busy = true;
|
|
172
275
|
try {
|
|
173
276
|
await engine.send(String(m.text ?? ""));
|
|
174
277
|
}
|
|
175
278
|
finally {
|
|
176
279
|
busy = false;
|
|
177
|
-
emit({ t: "usage", ...engine.stats(), model: engine.model, sessionId: engine.sessionId });
|
|
280
|
+
emit({ t: "usage", ...engine.stats(), model: engine.model, sessionId: engine.sessionId, fallback: engine.fallback.active });
|
|
178
281
|
}
|
|
179
282
|
break;
|
|
180
283
|
}
|
|
@@ -186,10 +289,8 @@ export async function serveDesktop(root, resumeId) {
|
|
|
186
289
|
pending.delete(m.id);
|
|
187
290
|
break;
|
|
188
291
|
case "config": {
|
|
189
|
-
if (typeof m.model === "string"
|
|
190
|
-
engine.setModel(MODEL_ALIASES[m.model] ?? m.model);
|
|
191
|
-
else if (m.model === "")
|
|
192
|
-
engine.setModel(engine.kind === "api" ? DEFAULT_MODEL : "");
|
|
292
|
+
if (typeof m.model === "string")
|
|
293
|
+
engine.setModel(m.model ? MODEL_ALIASES[m.model] ?? m.model : ""); // vazio = modelo/perfil salvo
|
|
193
294
|
if (m.effort !== undefined)
|
|
194
295
|
engine.effort = isEffort(m.effort) ? m.effort : "";
|
|
195
296
|
if (["manual", "auto", "plan", "bypass"].includes(m.mode))
|
package/dist/engine.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { MODEL_ALIASES, loadProfiles } from "./model.js";
|
|
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). */
|
|
5
|
+
export const FALLBACK_PROFILE = "qwen";
|
|
6
|
+
/**
|
|
7
|
+
* 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
|
+
* cada chamada, como os perfis.
|
|
9
|
+
*/
|
|
10
|
+
export function fallbackStatus(primary, env) {
|
|
11
|
+
const { profiles, errors, rejected } = loadProfiles();
|
|
12
|
+
const p = profiles[FALLBACK_PROFILE];
|
|
13
|
+
const label = p?.label;
|
|
14
|
+
if (primary.trim().toLowerCase() === FALLBACK_PROFILE)
|
|
15
|
+
return { ready: false, reason: "o modelo principal já é o qwen", label };
|
|
16
|
+
if (rejected.includes(FALLBACK_PROFILE)) {
|
|
17
|
+
const why = errors.find((e) => e.toLowerCase().includes(`"${FALLBACK_PROFILE}"`)) ?? "";
|
|
18
|
+
return { ready: false, reason: `o perfil "${FALLBACK_PROFILE}" em ~/.brito/config.json está inválido (${why})` };
|
|
19
|
+
}
|
|
20
|
+
if (!p)
|
|
21
|
+
return { ready: false, reason: `sem perfil "${FALLBACK_PROFILE}" em providers no ~/.brito/config.json` };
|
|
22
|
+
if (!env[p.apiKeyEnv]?.trim())
|
|
23
|
+
return { ready: false, reason: missingKeyMessage(p), label };
|
|
24
|
+
return { ready: true, name: FALLBACK_PROFILE, label: p.label };
|
|
25
|
+
}
|
|
26
|
+
/** Modelo padrão do subagente `explore` no Claude: rápido e barato. */
|
|
27
|
+
export const EXPLORE_DEFAULT = MODEL_ALIASES.haiku;
|
|
28
|
+
/**
|
|
29
|
+
* Qual motor atende o modelo: perfil de endpoint → loop próprio (modo API), sem olhar a Anthropic nem o claude CLI.
|
|
30
|
+
* Claude → a regra de sempre: com ANTHROPIC_API_KEY, API direta; sem ela, o `claude` oficial (assinatura).
|
|
31
|
+
*/
|
|
32
|
+
export function pickEngine(model, env, hasCli) {
|
|
33
|
+
const r = resolveModel(model);
|
|
34
|
+
const name = model.trim().toLowerCase();
|
|
35
|
+
if (r.kind === "anthropic" && loadProfiles().rejected.includes(name)) {
|
|
36
|
+
const why = loadProfiles().errors.find((e) => e.toLowerCase().includes(`"${name}"`)) ?? "";
|
|
37
|
+
return { kind: "error", message: `o perfil "${name}" em ~/.brito/config.json está inválido (${why}); corrija antes de usar` };
|
|
38
|
+
}
|
|
39
|
+
if (r.kind === "openai")
|
|
40
|
+
return env[r.profile.apiKeyEnv]?.trim() ? { kind: "api" } : { kind: "error", message: missingKeyMessage(r.profile) };
|
|
41
|
+
if (env.ANTHROPIC_API_KEY)
|
|
42
|
+
return { kind: "api" };
|
|
43
|
+
if (hasCli())
|
|
44
|
+
return { kind: "claude" };
|
|
45
|
+
return { kind: "error", message: "Defina ANTHROPIC_API_KEY ou instale o Claude Code e rode `claude` → /login." };
|
|
46
|
+
}
|
|
47
|
+
/** Motivo do fallback quando a sessão já abre no qwen. */
|
|
48
|
+
export const NO_ANTHROPIC = "sem conta da Anthropic";
|
|
49
|
+
/**
|
|
50
|
+
* Motor da abertura: o de `pickEngine`, salvo quando um Claude não tem conta (sem ANTHROPIC_API_KEY e sem o `claude`)
|
|
51
|
+
* e o fallback está pronto — aí abre direto no qwen. Sem fallback pronto, o erro de sempre leva o motivo dele.
|
|
52
|
+
* Perfil inválido nunca vira fallback: continua o erro de perfil.
|
|
53
|
+
*/
|
|
54
|
+
export function openEngine(model, env, hasCli) {
|
|
55
|
+
const e = pickEngine(model, env, hasCli);
|
|
56
|
+
if (e.kind !== "error" || resolveModel(model).kind !== "anthropic" || loadProfiles().rejected.includes(model.trim().toLowerCase()))
|
|
57
|
+
return e;
|
|
58
|
+
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}.` };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Modelo de um subagente. BRITO_EXPLORE_MODEL manda no `explore`. Com o agente principal num endpoint, subagente
|
|
63
|
+
* que pediria um Claude fica no modelo do principal (senão a conta da Anthropic voltaria a ser usada em silêncio).
|
|
64
|
+
*/
|
|
65
|
+
export function subagentModel(def, parent, env) {
|
|
66
|
+
if (def.name === "explore" && env.BRITO_EXPLORE_MODEL)
|
|
67
|
+
return env.BRITO_EXPLORE_MODEL;
|
|
68
|
+
const wanted = def.model ?? (def.name === "explore" ? EXPLORE_DEFAULT : undefined);
|
|
69
|
+
if (resolveModel(parent).kind === "openai" && (!wanted || resolveModel(wanted).kind === "anthropic"))
|
|
70
|
+
return parent;
|
|
71
|
+
return wanted ?? parent;
|
|
72
|
+
}
|
|
73
|
+
/** Modelo da reflexão/consolidação de skills: BRITO_LEARN_MODEL, salvo se ele levaria a conta à Anthropic com um perfil ativo. */
|
|
74
|
+
export function learnerModel(live, env) {
|
|
75
|
+
const wanted = env.BRITO_LEARN_MODEL;
|
|
76
|
+
if (!wanted)
|
|
77
|
+
return live;
|
|
78
|
+
return resolveModel(live).kind === "openai" && resolveModel(wanted).kind === "anthropic" ? live : wanted;
|
|
79
|
+
}
|
package/dist/graph/store.js
CHANGED
|
@@ -209,3 +209,18 @@ export class GraphStore {
|
|
|
209
209
|
await this.release();
|
|
210
210
|
}
|
|
211
211
|
}
|
|
212
|
+
/**
|
|
213
|
+
* Abre o grafo no processo do Brito depois de um turno do claude: o servidor MCP dele solta o lock ao morrer, mas pode
|
|
214
|
+
* levar um instante além do `close` do filho. Uma tentativa extra curta cobre isso.
|
|
215
|
+
*/
|
|
216
|
+
export async function openGraphAfterClaude(root) {
|
|
217
|
+
try {
|
|
218
|
+
return await GraphStore.open(root);
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
if (!(e instanceof GraphLockedError))
|
|
222
|
+
throw e;
|
|
223
|
+
await sleep(500);
|
|
224
|
+
return GraphStore.open(root);
|
|
225
|
+
}
|
|
226
|
+
}
|
package/dist/model.js
CHANGED
|
@@ -132,5 +132,51 @@ export function parseModelArgs(raw) {
|
|
|
132
132
|
const scope = flags.has("once") ? "once" : flags.has("global") && !flags.has("session") ? "global" : "session";
|
|
133
133
|
return { model: model ? resolveModelAlias(model) : "", scope, errors };
|
|
134
134
|
}
|
|
135
|
-
/** Texto de ajuda mostrado por `/model` sem argumentos. */
|
|
136
|
-
export
|
|
135
|
+
/** Texto de ajuda mostrado por `/model` sem argumentos (perfis de endpoint entram junto dos aliases). */
|
|
136
|
+
export function modelHelp() {
|
|
137
|
+
const profiles = Object.keys(loadProfiles().profiles);
|
|
138
|
+
return "uso: /model <nome|alias> [--global|--session|--once] (aliases: " + Object.keys(MODEL_ALIASES).join(", ") + ")" +
|
|
139
|
+
(profiles.length ? ` (endpoints: ${profiles.join(", ")})` : "");
|
|
140
|
+
}
|
|
141
|
+
const isText = (v) => typeof v === "string" && v.trim() !== "";
|
|
142
|
+
/**
|
|
143
|
+
* Perfis de endpoint compatível com OpenAI (`providers` no config). Relido a cada chamada: é barato e o config muda
|
|
144
|
+
* durante a sessão. Perfil inválido não derruba o Brito: fica de fora e o motivo vai em `errors`.
|
|
145
|
+
*/
|
|
146
|
+
export function loadProfiles() {
|
|
147
|
+
let raw;
|
|
148
|
+
try {
|
|
149
|
+
raw = JSON.parse(readFileSync(configPath(), "utf8"))?.providers;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return { profiles: {}, errors: [], rejected: [] };
|
|
153
|
+
}
|
|
154
|
+
const profiles = {};
|
|
155
|
+
const errors = [];
|
|
156
|
+
/** nomes de perfis inválidos: escolher um deles é erro, nunca cair no Claude */
|
|
157
|
+
const rejected = [];
|
|
158
|
+
if (!raw || typeof raw !== "object")
|
|
159
|
+
return { profiles, errors, rejected };
|
|
160
|
+
for (const [key, v] of Object.entries(raw)) {
|
|
161
|
+
const name = key.trim().toLowerCase();
|
|
162
|
+
const missing = ["baseURL", "model", "apiKeyEnv"].filter((f) => !isText(v?.[f]));
|
|
163
|
+
if (v?.type !== "openai") {
|
|
164
|
+
errors.push(`perfil "${key}" ignorado: type precisa ser "openai"`);
|
|
165
|
+
rejected.push(name);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (missing.length) {
|
|
169
|
+
errors.push(`perfil "${key}" ignorado: faltam ${missing.join(", ")}`);
|
|
170
|
+
rejected.push(name);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
profiles[name] = {
|
|
174
|
+
name, type: "openai", label: isText(v.label) ? v.label.trim() : name,
|
|
175
|
+
baseURL: v.baseURL.trim(), model: v.model.trim(), apiKeyEnv: v.apiKeyEnv.trim(),
|
|
176
|
+
contextWindow: Number(v.contextWindow) > 0 ? Number(v.contextWindow) : 128_000,
|
|
177
|
+
vision: v.vision === true,
|
|
178
|
+
thinkingToggle: v.thinkingToggle === true,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return { profiles, errors, rejected };
|
|
182
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
import { resolveModel } from "./resolve.js";
|
|
3
|
+
/** API da Anthropic, exatamente como o Brito sempre chamou (system em cache, streaming quando há quem leia o texto). */
|
|
4
|
+
export class AnthropicProvider {
|
|
5
|
+
kind = "anthropic";
|
|
6
|
+
client = new Anthropic();
|
|
7
|
+
async send(req, hooks = {}) {
|
|
8
|
+
const params = {
|
|
9
|
+
model: resolveModel(req.model).wireModel, // aliases (sonnet, opus…) viram o id real
|
|
10
|
+
max_tokens: req.maxTokens,
|
|
11
|
+
system: [{ type: "text", text: req.system, cache_control: { type: "ephemeral" } }],
|
|
12
|
+
...(req.tools?.length ? { tools: req.tools } : {}),
|
|
13
|
+
messages: req.messages,
|
|
14
|
+
};
|
|
15
|
+
let msg;
|
|
16
|
+
if (hooks.onText) {
|
|
17
|
+
const stream = this.client.messages.stream(params, { signal: hooks.signal });
|
|
18
|
+
stream.on("text", (t) => hooks.onText(t));
|
|
19
|
+
msg = await stream.finalMessage();
|
|
20
|
+
}
|
|
21
|
+
else
|
|
22
|
+
msg = await this.client.messages.create(params, { signal: hooks.signal });
|
|
23
|
+
const u = msg.usage;
|
|
24
|
+
const content = msg.content.flatMap((b) => b.type === "text" ? [{ type: "text", text: b.text }] : b.type === "tool_use" ? [{ type: "tool_use", id: b.id, name: b.name, input: b.input }] : []);
|
|
25
|
+
const stopReason = msg.stop_reason === "end_turn" || msg.stop_reason === "tool_use" || msg.stop_reason === "max_tokens" ? msg.stop_reason : "other";
|
|
26
|
+
return {
|
|
27
|
+
content, stopReason,
|
|
28
|
+
usage: { input: u.input_tokens + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0), output: u.output_tokens, cacheRead: u.cache_read_input_tokens ?? 0 },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
import { FALLBACK_PROFILE } from "../engine.js";
|
|
3
|
+
export const triggersFallback = (k) => k !== "other";
|
|
4
|
+
/**
|
|
5
|
+
* Classifica um erro da API da Anthropic (já depois dos retries do SDK). Na dúvida, `other`: melhor não trocar do que
|
|
6
|
+
* esconder um bug atrás do qwen.
|
|
7
|
+
*/
|
|
8
|
+
export function classifyAnthropicError(e) {
|
|
9
|
+
if (e instanceof Anthropic.APIUserAbortError || e?.name === "AbortError")
|
|
10
|
+
return "other";
|
|
11
|
+
if (e instanceof Anthropic.APIConnectionError)
|
|
12
|
+
return "network"; // inclui APIConnectionTimeoutError
|
|
13
|
+
if (!(e instanceof Anthropic.APIError))
|
|
14
|
+
return "other";
|
|
15
|
+
const status = e.status ?? 0;
|
|
16
|
+
const type = e.type ?? "";
|
|
17
|
+
if (status === 429 || type === "rate_limit_error")
|
|
18
|
+
return "rate_limit";
|
|
19
|
+
if (status === 402 || type === "billing_error" || (status === 400 && /credit balance is too low/i.test(e.message)))
|
|
20
|
+
return "quota";
|
|
21
|
+
if (status === 401 || status === 403 || type === "authentication_error" || type === "permission_error")
|
|
22
|
+
return "auth";
|
|
23
|
+
if (type === "timeout_error")
|
|
24
|
+
return "network";
|
|
25
|
+
if (status === 529 || status >= 500 || type === "overloaded_error")
|
|
26
|
+
return "overloaded";
|
|
27
|
+
return "other";
|
|
28
|
+
}
|
|
29
|
+
/** Acumula os sinais de falha de um evento do stream-json. Só a thread principal: subagente que falha não derruba o turno. */
|
|
30
|
+
export function collectClaudeFailure(f, ev) {
|
|
31
|
+
if (ev?.parent_tool_use_id)
|
|
32
|
+
return;
|
|
33
|
+
if (ev?.type === "assistant" && typeof ev.error === "string") {
|
|
34
|
+
f.error = ev.error;
|
|
35
|
+
if (typeof ev.api_error_status === "number")
|
|
36
|
+
f.status = ev.api_error_status;
|
|
37
|
+
}
|
|
38
|
+
else if (ev?.type === "result" && ev.is_error) {
|
|
39
|
+
f.result = String(ev.result ?? "");
|
|
40
|
+
if (typeof ev.api_error_status === "number")
|
|
41
|
+
f.status = ev.api_error_status;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const CLI_AUTH_ERRORS = new Set(["authentication_failed", "oauth_org_not_allowed", "account_on_hold", "verification_required", "cloud_credential_error"]);
|
|
45
|
+
/** Erros do CLI que são de conteúdo/bug: não troca, mesmo que o texto fale em "limit". */
|
|
46
|
+
const CLI_OTHER_ERRORS = new Set(["invalid_request", "model_not_found", "max_output_tokens"]);
|
|
47
|
+
/** "limit reached" que não é de conta: contexto, orçamento de agentes, concorrência. */
|
|
48
|
+
const NOT_ACCOUNT_LIMIT = /context limit|budget limit|subagent limit|turn budget|export limit|device limit/i;
|
|
49
|
+
const SUBSCRIPTION_LIMIT = /usage limit reached|limit reached|hit your (?:\w+ )?limit|resets \d|out of usage credits|spend limit|credit balance/i;
|
|
50
|
+
/**
|
|
51
|
+
* Classifica a falha de um turno do `claude` CLI: primeiro pelos campos estruturados do stream-json, depois pelo texto
|
|
52
|
+
* (as frases mudam entre versões). Na dúvida, `other`. Interrupção nunca chega aqui (a sessão filtra antes).
|
|
53
|
+
*/
|
|
54
|
+
export function classifyClaudeCliFailure(f) {
|
|
55
|
+
if (f.spawnError)
|
|
56
|
+
return /ENOENT|EACCES|not found/i.test(f.spawnError) ? "auth" : "other";
|
|
57
|
+
const text = `${f.result ?? ""}\n${f.stderr}`;
|
|
58
|
+
const accountLimit = SUBSCRIPTION_LIMIT.test(text) && !NOT_ACCOUNT_LIMIT.test(text);
|
|
59
|
+
const e = f.error ?? "";
|
|
60
|
+
if (CLI_OTHER_ERRORS.has(e))
|
|
61
|
+
return "other";
|
|
62
|
+
if (CLI_AUTH_ERRORS.has(e))
|
|
63
|
+
return "auth";
|
|
64
|
+
if (e === "billing_error")
|
|
65
|
+
return "quota";
|
|
66
|
+
if (e === "rate_limit")
|
|
67
|
+
return accountLimit ? "quota" : "rate_limit";
|
|
68
|
+
if (e === "overloaded" || e === "server_error")
|
|
69
|
+
return "overloaded";
|
|
70
|
+
const s = f.status ?? 0;
|
|
71
|
+
if (s === 429)
|
|
72
|
+
return accountLimit ? "quota" : "rate_limit";
|
|
73
|
+
if (s === 402)
|
|
74
|
+
return "quota";
|
|
75
|
+
if (s === 401 || s === 403)
|
|
76
|
+
return "auth";
|
|
77
|
+
if (s === 529 || s >= 500)
|
|
78
|
+
return "overloaded";
|
|
79
|
+
if (NOT_ACCOUNT_LIMIT.test(text))
|
|
80
|
+
return "other";
|
|
81
|
+
if (accountLimit)
|
|
82
|
+
return "quota";
|
|
83
|
+
if (/rate.?limit/i.test(text))
|
|
84
|
+
return "rate_limit";
|
|
85
|
+
if (/invalid api key|\/login|not logged in|oauth token|failed to authenticate|log in again/i.test(text))
|
|
86
|
+
return "auth";
|
|
87
|
+
if (/overloaded|\b529\b/i.test(text))
|
|
88
|
+
return "overloaded";
|
|
89
|
+
return "other";
|
|
90
|
+
}
|
|
91
|
+
/** Primeira linha do erro, curta, para o aviso e o `/fallback`. */
|
|
92
|
+
export const shortError = (e, max = 120) => {
|
|
93
|
+
const line = String(e?.message ?? e).split("\n")[0].trim();
|
|
94
|
+
return line.length > max ? line.slice(0, max - 1) + "…" : line;
|
|
95
|
+
};
|
|
96
|
+
export const newFallbackState = (primary) => ({ primary, active: false });
|
|
97
|
+
/** `/model <x>` é escolha explícita: vira o principal e desfaz o fallback (inclusive `/model qwen`). */
|
|
98
|
+
export function chooseModel(s, model) {
|
|
99
|
+
s.primary = model;
|
|
100
|
+
s.active = false;
|
|
101
|
+
s.reason = undefined;
|
|
102
|
+
s.at = undefined;
|
|
103
|
+
}
|
|
104
|
+
/** A troca automática aconteceu. */
|
|
105
|
+
export function activateFallback(s, reason, at = Date.now()) {
|
|
106
|
+
s.active = true;
|
|
107
|
+
s.reason = reason;
|
|
108
|
+
s.at = at;
|
|
109
|
+
}
|
|
110
|
+
/** Aviso impresso na troca (modo API e claude CLI): a troca nunca é silenciosa. */
|
|
111
|
+
export const fallbackWarning = (kind, why, label) => `⚠ Claude falhou (${kind}: ${why}) — seguindo com ${label}. /fallback para detalhes, /model <claude> para voltar.`;
|
|
112
|
+
/**
|
|
113
|
+
* O turno do claude CLI que falhou deve ser refeito no qwen? Só com falha de conta/capacidade, fallback pronto e
|
|
114
|
+
* ainda não usado na sessão. Interrupção não deixa `failure` (a sessão filtra), mas o `aborted` fica de garantia.
|
|
115
|
+
*/
|
|
116
|
+
export function cliShouldFallBack(failure, state, status, aborted = false) {
|
|
117
|
+
return !!failure && triggersFallback(failure.kind) && status.ready && !state.active && !aborted;
|
|
118
|
+
}
|
|
119
|
+
/** Nota que precede o pedido refeito no qwen depois da troca de motor (spec §5.5). */
|
|
120
|
+
export const RETRY_NOTE = "[O modelo anterior (Claude) falhou no meio deste pedido. O que já foi feito está no histórico acima; continue de onde parou sem refazer ações já concluídas.]";
|
|
121
|
+
/** Sessão retomada (app reaberto): o que veio antes da reabertura ficou no claude CLI e não está no histórico. */
|
|
122
|
+
export const RESUMED_NOTE = "[A conversa anterior à reabertura da sessão não foi herdada: só o que veio depois dela está no histórico. Se faltar contexto, pergunte ao usuário em vez de supor.]";
|
|
123
|
+
export const retryPrompt = (text, resumed = false) => `${RETRY_NOTE}${resumed ? `\n${RESUMED_NOTE}` : ""}\n\n${text}`;
|
|
124
|
+
/**
|
|
125
|
+
* Modelo a devolver depois de um turno com `/model --once`. O `--once` zera o fallback, então `active` no fim do turno
|
|
126
|
+
* quer dizer que a troca aconteceu durante ele: aí o `restore` é descartado (voltaria ao Claude que acabou de cair).
|
|
127
|
+
*/
|
|
128
|
+
export const onceRestore = (restore, fallbackActive) => (fallbackActive ? null : restore);
|
|
129
|
+
const hhmm = (ms) => { const d = new Date(ms); return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; };
|
|
130
|
+
/**
|
|
131
|
+
* Linhas do `/fallback`. Principal = o modelo vivo, salvo com o fallback ativo (aí é o principal original e o qwen
|
|
132
|
+
* aparece em uso). `status` vem de `fallbackStatus(state.primary, env)`; `engineLabel` é o motor do principal.
|
|
133
|
+
*/
|
|
134
|
+
export function renderFallback(state, liveModel, engineLabel, status) {
|
|
135
|
+
const primary = state.active ? state.primary : liveModel;
|
|
136
|
+
const name = status.label ?? "—";
|
|
137
|
+
const self = !state.active && primary.trim().toLowerCase() === FALLBACK_PROFILE;
|
|
138
|
+
const fb = state.active ? `${name} qwen · ~/.brito/config.json ● em uso`
|
|
139
|
+
: status.ready ? `${name} qwen · ~/.brito/config.json ✓ pronto`
|
|
140
|
+
: self ? `${name} — o modelo principal já é o qwen`
|
|
141
|
+
: `${name} ✗ ${status.reason}`;
|
|
142
|
+
const estado = state.active ? `ATIVO desde ${hhmm(state.at ?? Date.now())} — Claude falhou: ${state.reason ?? "?"}. /model <claude> para voltar`
|
|
143
|
+
: status.ready ? "inativo — troca automática se o Claude falhar por limite, crédito, auth ou sobrecarga"
|
|
144
|
+
: self ? "não se aplica"
|
|
145
|
+
: "indisponível — sem troca automática nesta sessão";
|
|
146
|
+
return [` principal ${primary} (${engineLabel})`, ` fallback ${fb}`, ` estado ${estado}`];
|
|
147
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { AnthropicProvider } from "./anthropic.js";
|
|
2
|
+
import { OpenAICompatProvider } from "./openai-compat.js";
|
|
3
|
+
import { resolveModel } from "./resolve.js";
|
|
4
|
+
let factory = null;
|
|
5
|
+
/** Só para testes: troca a criação de provedores. `null` volta ao normal. */
|
|
6
|
+
export function setProviderFactory(f) { factory = f; }
|
|
7
|
+
let anthropic;
|
|
8
|
+
const openai = new Map();
|
|
9
|
+
/**
|
|
10
|
+
* Provedor de um modelo pelo nome que o usuário usa. O cliente da Anthropic só nasce quando um modelo Claude é usado:
|
|
11
|
+
* com um perfil de endpoint escolhido, nada da Anthropic é instanciado.
|
|
12
|
+
*/
|
|
13
|
+
export function providerFor(name) {
|
|
14
|
+
const r = resolveModel(name);
|
|
15
|
+
if (factory)
|
|
16
|
+
return factory(r);
|
|
17
|
+
if (r.kind === "anthropic")
|
|
18
|
+
return (anthropic ??= new AnthropicProvider());
|
|
19
|
+
// O config pode mudar na sessão: refaz o provedor quando o perfil muda.
|
|
20
|
+
const key = JSON.stringify(r.profile);
|
|
21
|
+
const hit = openai.get(r.name);
|
|
22
|
+
if (hit?.key === key)
|
|
23
|
+
return hit.provider;
|
|
24
|
+
const provider = new OpenAICompatProvider(r.profile);
|
|
25
|
+
openai.set(r.name, { key, provider });
|
|
26
|
+
return provider;
|
|
27
|
+
}
|