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.
@@ -0,0 +1,151 @@
1
+ import { buildChatBody, mapFinish, mapUsage, toBlocks } from "./openai-wire.js";
2
+ import { parseSSE } from "./sse.js";
3
+ const RETRIES = 2;
4
+ export const missingKeyMessage = (p) => `defina a variável de ambiente ${p.apiKeyEnv} com a chave do endpoint "${p.name}"`;
5
+ /**
6
+ * Junta os pedaços do streaming. O raciocínio é guardado e sai de uma vez (a UI desenha uma caixa por chamada), no
7
+ * primeiro pedaço de resposta ou de tool; o texto sai sem os espaços que o vLLM põe antes da resposta.
8
+ */
9
+ export class ChatStreamAccumulator {
10
+ reasoning = "";
11
+ reasoningOut = false;
12
+ text = "";
13
+ emitted = 0;
14
+ calls = [];
15
+ finish = null;
16
+ usage;
17
+ push(chunk) {
18
+ const out = {};
19
+ // O vLLM manda falhas depois que o stream começou como `data: {"error": …}`.
20
+ if (chunk?.error)
21
+ throw new Error(`o endpoint falhou no meio da resposta: ${chunk.error.message ?? JSON.stringify(chunk.error)}`);
22
+ if (chunk?.usage)
23
+ this.usage = chunk.usage;
24
+ const choice = chunk?.choices?.[0];
25
+ if (!choice)
26
+ return out;
27
+ const d = choice.delta ?? {};
28
+ if (typeof d.reasoning_content === "string")
29
+ this.reasoning += d.reasoning_content;
30
+ if (typeof d.reasoning === "string")
31
+ this.reasoning += d.reasoning;
32
+ for (const tc of d.tool_calls ?? []) {
33
+ const i = Number(tc.index ?? 0);
34
+ const call = (this.calls[i] ??= { id: "", name: "", arguments: "" });
35
+ if (tc.id)
36
+ call.id = String(tc.id);
37
+ if (tc.function?.name)
38
+ call.name = String(tc.function.name);
39
+ if (tc.function?.arguments)
40
+ call.arguments += String(tc.function.arguments);
41
+ }
42
+ if (typeof d.content === "string")
43
+ this.text += d.content;
44
+ if (choice.finish_reason)
45
+ this.finish = choice.finish_reason;
46
+ const visible = this.text.trimStart();
47
+ const answering = visible.length > this.emitted || this.calls.length > 0;
48
+ if (answering)
49
+ Object.assign(out, this.flushThinking());
50
+ if (visible.length > this.emitted) {
51
+ out.text = visible.slice(this.emitted);
52
+ this.emitted = visible.length;
53
+ }
54
+ return out;
55
+ }
56
+ /** O servidor disse que terminou? Sem isso, a conexão caiu no meio. */
57
+ get finished() { return this.finish !== null; }
58
+ /** Raciocínio ainda não entregue (uma vez só). */
59
+ flushThinking() {
60
+ if (this.reasoningOut || !this.reasoning.trim())
61
+ return {};
62
+ this.reasoningOut = true;
63
+ return { thinking: this.reasoning.trim() };
64
+ }
65
+ finishMessage() {
66
+ const calls = this.calls.filter(Boolean).map((c, i) => ({ ...c, id: c.id || `call_${i}` }));
67
+ return { content: toBlocks(this.text, calls), stopReason: mapFinish(this.finish), usage: mapUsage(this.usage), reasoning: this.reasoning };
68
+ }
69
+ }
70
+ /** Motivo legível de um erro do endpoint: o OCI embrulha o erro do vLLM em JSON dentro de JSON. */
71
+ function errorReason(body) {
72
+ let v = body.trim();
73
+ for (let i = 0; i < 3 && typeof v === "string"; i++) {
74
+ try {
75
+ v = JSON.parse(v);
76
+ }
77
+ catch {
78
+ break;
79
+ }
80
+ v = v?.error?.message ?? v?.message ?? v?.error ?? v;
81
+ }
82
+ const s = (typeof v === "string" ? v : JSON.stringify(v ?? "")).trim();
83
+ return s.length > 300 ? s.slice(0, 300) + "…" : s;
84
+ }
85
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
86
+ const retryable = (status) => status === 429 || status >= 500;
87
+ /** Endpoint compatível com a API Chat Completions da OpenAI (ex.: Qwen num cluster dedicado do OCI). Sempre em streaming. */
88
+ export class OpenAICompatProvider {
89
+ profile;
90
+ fetchImpl;
91
+ env;
92
+ retryDelayMs;
93
+ kind = "openai";
94
+ constructor(profile, fetchImpl = fetch, env = process.env, retryDelayMs = 500) {
95
+ this.profile = profile;
96
+ this.fetchImpl = fetchImpl;
97
+ this.env = env;
98
+ this.retryDelayMs = retryDelayMs;
99
+ }
100
+ async send(req, hooks = {}) {
101
+ const p = this.profile;
102
+ const key = this.env[p.apiKeyEnv]?.trim();
103
+ if (!key)
104
+ throw new Error(missingKeyMessage(p));
105
+ const url = `${p.baseURL.replace(/\/+$/, "")}/chat/completions`;
106
+ const init = {
107
+ method: "POST",
108
+ headers: { "content-type": "application/json", accept: "text/event-stream", authorization: `Bearer ${key}` },
109
+ body: JSON.stringify(buildChatBody(req, p, true)),
110
+ signal: hooks.signal,
111
+ };
112
+ let res;
113
+ for (let attempt = 0;; attempt++) {
114
+ try {
115
+ res = await this.fetchImpl(url, init);
116
+ }
117
+ catch (e) {
118
+ if (hooks.signal?.aborted || attempt >= RETRIES)
119
+ throw new Error(`não consegui falar com o endpoint ${p.label}: ${e?.message ?? e}`);
120
+ await sleep(this.retryDelayMs * 2 ** attempt);
121
+ continue;
122
+ }
123
+ if (res.ok || !retryable(res.status) || attempt >= RETRIES || hooks.signal?.aborted)
124
+ break;
125
+ await res.body?.cancel().catch(() => { });
126
+ await sleep(this.retryDelayMs * 2 ** attempt);
127
+ }
128
+ if (!res.ok) {
129
+ const reason = errorReason(await res.text().catch(() => ""));
130
+ const hint = res.status === 401 || res.status === 403 ? ` — confira a chave em ${p.apiKeyEnv}` : "";
131
+ throw new Error(`endpoint ${p.label} respondeu HTTP ${res.status}${reason ? `: ${reason}` : ""} (opc-request-id: ${res.headers.get("opc-request-id") ?? "?"})${hint}`);
132
+ }
133
+ if (!res.body)
134
+ throw new Error(`endpoint ${p.label} respondeu sem corpo`);
135
+ const acc = new ChatStreamAccumulator();
136
+ for await (const chunk of parseSSE(res.body)) {
137
+ const out = acc.push(chunk);
138
+ if (out.thinking)
139
+ hooks.onThinking?.(out.thinking);
140
+ if (out.text)
141
+ hooks.onText?.(out.text);
142
+ }
143
+ if (!acc.finished)
144
+ throw new Error(`a conexão com o endpoint ${p.label} caiu antes do fim da resposta`);
145
+ const last = acc.flushThinking();
146
+ if (last.thinking)
147
+ hooks.onThinking?.(last.thinking);
148
+ const { reasoning: _r, ...msg } = acc.finishMessage();
149
+ return msg;
150
+ }
151
+ }
@@ -0,0 +1,89 @@
1
+ /** Texto de um tool_result (string ou lista de blocos; imagens dentro do resultado não têm equivalente e caem). */
2
+ const resultText = (c) => typeof c === "string" ? c : (c ?? []).map((b) => (b.type === "text" ? b.text : "")).join("");
3
+ export function toChatMessages(system, msgs, vision) {
4
+ const out = [{ role: "system", content: system }];
5
+ let droppedImages = 0;
6
+ for (const m of msgs) {
7
+ if (typeof m.content === "string") {
8
+ out.push(m.role === "user" ? { role: "user", content: m.content } : { role: "assistant", content: m.content });
9
+ continue;
10
+ }
11
+ if (m.role === "assistant") {
12
+ const text = m.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
13
+ const calls = m.content.flatMap((b) => b.type === "tool_use" ? [{ id: b.id, type: "function", function: { name: b.name, arguments: JSON.stringify(b.input ?? {}) } }] : []);
14
+ out.push(calls.length ? { role: "assistant", content: text || null, tool_calls: calls } : { role: "assistant", content: text });
15
+ continue;
16
+ }
17
+ // Usuário: resultados de tool viram mensagens `tool` (logo após o assistant que pediu); o resto vira uma mensagem `user`.
18
+ const parts = [];
19
+ for (const b of m.content) {
20
+ if (b.type === "tool_result")
21
+ out.push({ role: "tool", tool_call_id: b.tool_use_id, content: (b.is_error ? "ERRO: " : "") + resultText(b.content) });
22
+ else if (b.type === "text")
23
+ parts.push({ type: "text", text: b.text });
24
+ else if (b.type === "image" && b.source.type === "base64") {
25
+ if (vision)
26
+ parts.push({ type: "image_url", image_url: { url: `data:${b.source.media_type};base64,${b.source.data}` } });
27
+ // Sem visão a imagem vira um marcador: o modelo sabe que havia uma (ex.: histórico herdado do Claude no fallback).
28
+ else {
29
+ droppedImages++;
30
+ parts.push({ type: "text", text: "[imagem omitida]" });
31
+ }
32
+ }
33
+ }
34
+ if (!parts.length)
35
+ continue;
36
+ const onlyText = parts.every((p) => p.type === "text");
37
+ out.push({ role: "user", content: onlyText ? parts.map((p) => p.text).join("\n") : parts });
38
+ }
39
+ return { messages: out, droppedImages };
40
+ }
41
+ export const toChatTools = (tools) => tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.input_schema } }));
42
+ export function buildChatBody(req, p, stream) {
43
+ const body = {
44
+ model: p.model,
45
+ messages: toChatMessages(req.system, req.messages, p.vision).messages,
46
+ max_tokens: req.maxTokens,
47
+ stream,
48
+ };
49
+ // Qwen/vLLM: liga/desliga o raciocínio pelo template do chat. Só com `thinkingToggle`: a OpenAI e outros servidores
50
+ // recusam parâmetro desconhecido.
51
+ if (p.thinkingToggle)
52
+ body.chat_template_kwargs = { enable_thinking: req.thinking !== false };
53
+ if (stream)
54
+ body.stream_options = { include_usage: true };
55
+ if (req.tools?.length)
56
+ body.tools = toChatTools(req.tools);
57
+ return body;
58
+ }
59
+ export function mapFinish(reason) {
60
+ return reason === "tool_calls" ? "tool_use" : reason === "stop" ? "end_turn" : reason === "length" ? "max_tokens" : "other";
61
+ }
62
+ /** JSON inválido vira um input que o schema da tool rejeita: o modelo recebe "Input inválido" e tenta de novo. */
63
+ export function parseToolArgs(raw) {
64
+ if (!raw.trim())
65
+ return {};
66
+ try {
67
+ return JSON.parse(raw);
68
+ }
69
+ catch {
70
+ return { _argumentosInvalidos: raw };
71
+ }
72
+ }
73
+ export const mapUsage = (u) => ({ input: u?.prompt_tokens ?? 0, output: u?.completion_tokens ?? 0, cacheRead: u?.prompt_tokens_details?.cached_tokens ?? 0 });
74
+ /** Junta texto e chamadas de tool na ordem do formato interno (texto primeiro). O vLLM põe "\n\n" antes da resposta. */
75
+ export function toBlocks(text, calls) {
76
+ const blocks = [];
77
+ const t = text.trimStart();
78
+ if (t)
79
+ blocks.push({ type: "text", text: t });
80
+ for (const c of calls)
81
+ blocks.push({ type: "tool_use", id: c.id, name: c.name, input: parseToolArgs(c.arguments) });
82
+ return blocks;
83
+ }
84
+ export function fromChatCompletion(json) {
85
+ const choice = json?.choices?.[0];
86
+ const msg = choice?.message ?? {};
87
+ const calls = (msg.tool_calls ?? []).map((c) => ({ id: String(c.id), name: String(c.function?.name ?? ""), arguments: String(c.function?.arguments ?? "") }));
88
+ return { content: toBlocks(msg.content ?? "", calls), stopReason: mapFinish(choice?.finish_reason), usage: mapUsage(json?.usage), reasoning: msg.reasoning_content ?? "" };
89
+ }
@@ -0,0 +1,27 @@
1
+ import { MODEL_ALIASES, loadProfiles } from "../model.js";
2
+ /** Janela dos modelos Claude quando nada informa outra. */
3
+ const CLAUDE_WINDOW = 200_000;
4
+ const COMPACT_DEFAULT = 120_000;
5
+ /** Nome → provedor: perfil configurado (sem diferenciar maiúsculas) > alias do Claude > id cru do Claude. */
6
+ export function resolveModel(name) {
7
+ const key = name.trim().toLowerCase();
8
+ const profile = loadProfiles().profiles[key];
9
+ if (profile)
10
+ return { kind: "openai", name: key, wireModel: profile.model, label: profile.label, contextWindow: profile.contextWindow, profile };
11
+ const wire = MODEL_ALIASES[key] ?? name.trim();
12
+ return { kind: "anthropic", name: wire, wireModel: wire, label: wire, contextWindow: CLAUDE_WINDOW };
13
+ }
14
+ /** Janela mostrada na barra/`/context`: BRITO_CONTEXT_WINDOW > perfil > padrão do Claude. */
15
+ export function contextWindowFor(name) {
16
+ const env = Number(process.env.BRITO_CONTEXT_WINDOW);
17
+ return env > 0 ? env : resolveModel(name).contextWindow;
18
+ }
19
+ /**
20
+ * Quando compactar: BRITO_COMPACT_TOKENS (ou 120k). Em perfis de endpoint, nunca acima de 60% da janela (janelas
21
+ * pequenas estourariam antes); no Claude fica como sempre foi.
22
+ */
23
+ export function compactAtFor(name) {
24
+ const env = Number(process.env.BRITO_COMPACT_TOKENS);
25
+ const base = env > 0 ? env : COMPACT_DEFAULT;
26
+ return resolveModel(name).kind === "openai" ? Math.min(base, Math.floor(0.6 * contextWindowFor(name))) : base;
27
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Lê um corpo `text/event-stream` e devolve o JSON de cada linha `data:`. Linhas podem chegar partidas entre pedaços
3
+ * da rede e com `\r\n`; `data: [DONE]` encerra. Linha que não é JSON é ignorada (comentários/keep-alive).
4
+ */
5
+ export async function* parseSSE(body) {
6
+ const decoder = new TextDecoder();
7
+ let buf = "";
8
+ const reader = body.getReader();
9
+ try {
10
+ for (;;) {
11
+ const { done, value } = await reader.read();
12
+ buf += done ? decoder.decode() : decoder.decode(value, { stream: true });
13
+ const lines = buf.split(/\r?\n/);
14
+ buf = done ? "" : lines.pop();
15
+ for (const line of lines) {
16
+ if (!line.startsWith("data:"))
17
+ continue;
18
+ const data = line.slice(5).trim();
19
+ if (data === "[DONE]")
20
+ return;
21
+ try {
22
+ yield JSON.parse(data);
23
+ }
24
+ catch { /* não é JSON */ }
25
+ }
26
+ if (done)
27
+ return;
28
+ }
29
+ }
30
+ finally {
31
+ reader.releaseLock();
32
+ }
33
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/repl.js CHANGED
@@ -19,6 +19,56 @@ const userName = () => {
19
19
  import { listPending, listSkills, deleteSkill, promoteSkill, globalHome } from "./skills/store.js";
20
20
  import { listAgents } from "./agent/agents.js";
21
21
  import { perms, cycleMode, MODE_LABEL } from "./permissions.js";
22
+ /**
23
+ * Backend com o motor substituível no meio da sessão (claude CLI → qwen): o REPL monta o menu uma vez, então os
24
+ * comandos ficam estáveis e o que o motor atual não tem responde "indisponível". `usage`/`cost` somam todos os
25
+ * motores já usados (cada um uma vez, mesmo que volte a ser o atual).
26
+ */
27
+ export function switchableBackend(initial) {
28
+ let cur = initial;
29
+ const seen = new Set([initial]);
30
+ const commands = {};
31
+ const delegate = (name, desc) => {
32
+ commands[name] = {
33
+ desc,
34
+ run: async (args) => {
35
+ const cmd = cur.commands[name];
36
+ if (cmd)
37
+ await cmd.run(args);
38
+ else
39
+ ui.warn(`${name} não está disponível no ${cur.model()}`);
40
+ },
41
+ };
42
+ };
43
+ const adopt = (b) => { for (const [name, v] of Object.entries(b.commands))
44
+ if (!commands[name])
45
+ delegate(name, v.desc); };
46
+ adopt(initial);
47
+ return {
48
+ model: () => cur.model(),
49
+ contextTokens: () => cur.contextTokens(),
50
+ usage: () => {
51
+ const all = [...seen].map((b) => b.usage());
52
+ return { input: all.reduce((s, u) => s + u.input, 0), output: all.reduce((s, u) => s + u.output, 0), cacheRead: all.reduce((s, u) => s + (u.cacheRead ?? 0), 0) };
53
+ },
54
+ get ctxWindow() { return cur.ctxWindow; },
55
+ cost: () => {
56
+ const all = [...seen].map((b) => b.cost?.()).filter((x) => x !== undefined);
57
+ return all.length ? all.reduce((s, x) => s + x, 0) : undefined;
58
+ },
59
+ turn: (text, signal) => cur.turn(text, signal),
60
+ abort: () => cur.abort?.(),
61
+ // Todos os motores: o claude CLI fora de uso guarda a sessão (--resume) e os turnos que o fallback herdaria.
62
+ reset: () => { for (const b of seen)
63
+ b.reset(); },
64
+ afterTurn: async () => { await cur.afterTurn?.(); },
65
+ onShell: () => cur.onShell?.(),
66
+ commands,
67
+ close: async () => { await cur.close(); },
68
+ swap: (next) => { cur = next; seen.add(next); adopt(next); },
69
+ current: () => cur,
70
+ };
71
+ }
22
72
  const COMMON = {
23
73
  "/help": "esta ajuda",
24
74
  "/agents": "subagentes disponíveis",
@@ -1,4 +1,4 @@
1
- import Anthropic from "@anthropic-ai/sdk";
1
+ import { providerFor } from "../provider/index.js";
2
2
  import { z } from "zod";
3
3
  import { execTool, toApiTools } from "../tools/registry.js";
4
4
  import { perms } from "../permissions.js";
@@ -99,7 +99,8 @@ export function recordOutcomes(turn, ctx) {
99
99
  return [...(ctx.loadedSkills ?? [])].flatMap((n) => recordOutcome(ctx.root, n, verdict === "win") ?? []);
100
100
  }
101
101
  export const defaultComplete = async ({ model, system, user, tools, maxTokens }) => {
102
- const res = await new Anthropic().messages.create({ model, max_tokens: maxTokens, system, tools, messages: [{ role: "user", content: user }] });
102
+ // Raciocínio desligado: o julgamento cabe no orçamento e a resposta útil é a chamada de skill_save.
103
+ const res = await providerFor(model).send({ model, maxTokens, system, tools, messages: [{ role: "user", content: user }], thinking: false });
103
104
  return res.content;
104
105
  };
105
106
  /**
@@ -3,6 +3,8 @@ import { defineTool, allTools, execTool } from "./registry.js";
3
3
  import { Agent } from "../agent/loop.js";
4
4
  import { getAgent, listAgents } from "../agent/agents.js";
5
5
  import { buildSubagentPrompt } from "../agent/prompt.js";
6
+ import { subagentModel } from "../engine.js";
7
+ import { initialModel } from "../model.js";
6
8
  import { ui } from "../ui/ui.js";
7
9
  let seq = 0;
8
10
  /** Conjunto de tools de um agente; `delegate` nunca é repassada (sem recursão). */
@@ -47,7 +49,7 @@ defineTool({
47
49
  }
48
50
  log(`iniciado: ${task.slice(0, 100).replace(/\s+/g, " ")}`);
49
51
  const sub = new Agent(buildSubagentPrompt(ctx.root, def), ctx, {
50
- model: def.model,
52
+ model: subagentModel(def, ctx.model?.() ?? initialModel(), process.env),
51
53
  maxSteps: 25,
52
54
  quiet: true,
53
55
  tools: toolSet(def),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brito-cli",
3
- "version": "0.6.4",
3
+ "version": "0.7.1",
4
4
  "description": "Coding-agent CLI with a code knowledge graph, memory and reviewable skills / CLI de agente de código com grafo de conhecimento, memória e skills revisáveis",
5
5
  "type": "module",
6
6
  "bin": {