brito-cli 0.7.5 → 0.9.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/README.md +54 -4
- package/README.pt-BR.md +54 -4
- package/dist/agent/approval.js +19 -2
- package/dist/agent/claude-cli.js +10 -4
- package/dist/agent/loop.js +38 -9
- package/dist/agent/prompt.js +27 -1
- package/dist/agent/sessions.js +98 -0
- package/dist/cli.js +296 -26
- package/dist/desktop/panels.js +93 -0
- package/dist/desktop/protocol.js +5 -0
- package/dist/desktop/serve.js +191 -23
- package/dist/engine.js +28 -4
- package/dist/local/digest.js +202 -0
- package/dist/local/slm.js +218 -0
- package/dist/mcp/server.js +36 -3
- package/dist/model.js +21 -0
- package/dist/orchestrator.js +131 -0
- package/dist/permissions.js +4 -0
- package/dist/provider/anthropic-params.js +32 -0
- package/dist/provider/anthropic.js +13 -9
- package/dist/provider/fallback.js +67 -8
- package/dist/provider/index.js +4 -0
- package/dist/provider/local.js +88 -0
- package/dist/provider/resolve.js +8 -0
- package/dist/repl.js +49 -6
- package/dist/rules.js +102 -0
- package/dist/skills/builtin.js +89 -0
- package/dist/skills/describe.js +3 -2
- package/dist/skills/learn.js +2 -2
- package/dist/skills/store.js +64 -8
- package/dist/skills/trial.js +3 -3
- package/dist/tools/delegate.js +31 -22
- package/dist/tools/digest.js +29 -0
- package/dist/tools/fs.js +51 -8
- package/dist/tools/implement.js +50 -0
- package/dist/tools/registry.js +10 -1
- package/dist/tools/skills.js +10 -6
- package/dist/tools/spec.js +47 -0
- package/dist/tools/todo.js +22 -0
- package/dist/ui/ui.js +12 -0
- package/dist/util/todos.js +4 -0
- package/package.json +10 -6
- package/dist/setup.js +0 -110
package/dist/desktop/serve.js
CHANGED
|
@@ -2,29 +2,38 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import readline from "node:readline";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { PROTOCOL } from "./protocol.js";
|
|
7
|
+
import { Panels, graphRootFor } from "./panels.js";
|
|
5
8
|
import { randomUUID } from "node:crypto";
|
|
6
9
|
import "../tools/fs.js";
|
|
7
10
|
import "../tools/bash.js";
|
|
8
11
|
import "../tools/graph.js";
|
|
9
12
|
import "../tools/delegate.js";
|
|
10
13
|
import "../tools/plan.js";
|
|
14
|
+
import "../tools/implement.js";
|
|
15
|
+
import "../tools/spec.js";
|
|
11
16
|
import "../tools/skills.js";
|
|
17
|
+
import "../tools/digest.js";
|
|
18
|
+
import "../tools/todo.js";
|
|
12
19
|
import { Agent } from "../agent/loop.js";
|
|
20
|
+
import { loadSession, repairHistory, saveSession, titleOf } from "../agent/sessions.js";
|
|
13
21
|
import { ClaudeSession, hasClaudeCli, historyForFallback } from "../agent/claude-cli.js";
|
|
14
22
|
import { startApprovalServer } from "../agent/approval.js";
|
|
15
23
|
import { loadProjectAgents } from "../agent/agents.js";
|
|
16
|
-
import { buildSystemPrompt, graphStatsLine } from "../agent/prompt.js";
|
|
24
|
+
import { buildSystemPrompt, graphStatsLine, memoSystem, systemKey } from "../agent/prompt.js";
|
|
17
25
|
import { openGraphAfterClaude } from "../graph/store.js";
|
|
18
26
|
import { indexProject } from "../graph/indexer.js";
|
|
19
27
|
import { initialEffort, initialModel, isEffort, loadProfiles, MODEL_ALIASES } from "../model.js";
|
|
20
|
-
import {
|
|
21
|
-
import { activateFallback, chooseModel, cliShouldFallBack, fallbackWarning, newFallbackState, renderFallback, retryPrompt } from "../provider/fallback.js";
|
|
28
|
+
import { NO_ANTHROPIC, fallbackStatus, openEngine } from "../engine.js";
|
|
29
|
+
import { engineChip, activateFallback, chooseModel, cliShouldFallBack, fallbackWarning, newFallbackState, renderFallback, retryPrompt } from "../provider/fallback.js";
|
|
22
30
|
import { contextWindowFor, resolveModel } from "../provider/resolve.js";
|
|
23
31
|
import { PLAN_NOTE, perms } from "../permissions.js";
|
|
24
|
-
import {
|
|
32
|
+
import { ORCH_NOTE, implStatus, mainTools, orch, orchGuard, restoreOrch } from "../orchestrator.js";
|
|
33
|
+
import { getSkill, listBuiltins, listSkills, skillsIndex } from "../skills/store.js";
|
|
25
34
|
/**
|
|
26
35
|
* Backend do app desktop, controlado por JSON em linhas.
|
|
27
|
-
* stdin → {t:"send",text} {t:"abort"} {t:"approve",id,allow} {t:"config",model?,effort?,mode?} {t:"reset"} {t:"quit"}
|
|
36
|
+
* stdin → {t:"send",text} {t:"abort"} {t:"approve",id,allow,scope?:"session"|"project"} {t:"config",model?,effort?,mode?,orch?} {t:"reset"} {t:"query"} {t:"action"} {t:"quit"}
|
|
28
37
|
* stdout → linhas "@@{json}" (qualquer outra linha, como logs perdidos, a janela ignora).
|
|
29
38
|
* Motor: perfil de endpoint (ex.: Qwen) ou ANTHROPIC_API_KEY usam o loop próprio (como o `brito` no terminal); sem
|
|
30
39
|
* nada disso, o `claude` CLI (assinatura). Se o Claude cai por conta/capacidade (ou nem há conta), segue no qwen.
|
|
@@ -32,6 +41,66 @@ import { skillsIndex } from "../skills/store.js";
|
|
|
32
41
|
*/
|
|
33
42
|
const MARK = "@@";
|
|
34
43
|
const emit = (ev) => process.stdout.write(MARK + JSON.stringify(ev) + "\n");
|
|
44
|
+
/** Selo do motor (☁ nuvem / 🌐 endpoint / 💻 local, ou ⚠ FALLBACK), igual ao da barra de status da CLI. */
|
|
45
|
+
const chipOf = (model, fb) => { try {
|
|
46
|
+
return engineChip(model, fb).text;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return model;
|
|
50
|
+
} };
|
|
51
|
+
const BUILTIN_COMMANDS = [
|
|
52
|
+
{ name: "clear", description: "limpa a conversa", kind: "builtin" },
|
|
53
|
+
{ name: "fallback", description: "estado do fallback Claude → modelo local", kind: "builtin" },
|
|
54
|
+
{ name: "help", description: "lista os comandos", kind: "builtin" },
|
|
55
|
+
];
|
|
56
|
+
/** Arquivos do projeto para o `@`: git ls-files (respeita .gitignore); sem git, uma varredura rasa. */
|
|
57
|
+
let fileCache = null;
|
|
58
|
+
function projectFiles(root) {
|
|
59
|
+
if (fileCache && fileCache.root === root && Date.now() - fileCache.at < 15000)
|
|
60
|
+
return fileCache.files;
|
|
61
|
+
let files = [];
|
|
62
|
+
try {
|
|
63
|
+
files = execFileSync("git", ["ls-files", "--cached", "--others", "--exclude-standard"], { cwd: root, encoding: "utf8", maxBuffer: 16 << 20, timeout: 5000 }).split("\n").filter(Boolean);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
const walk = (dir, depth) => {
|
|
67
|
+
if (depth > 4 || files.length > 5000)
|
|
68
|
+
return;
|
|
69
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
70
|
+
if (e.name.startsWith(".") || e.name === "node_modules")
|
|
71
|
+
continue;
|
|
72
|
+
const rel = path.relative(root, path.join(dir, e.name)).split(path.sep).join("/");
|
|
73
|
+
e.isDirectory() ? walk(path.join(dir, e.name), depth + 1) : files.push(rel);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
try {
|
|
77
|
+
walk(root, 0);
|
|
78
|
+
}
|
|
79
|
+
catch { /* pasta ilegível */ }
|
|
80
|
+
}
|
|
81
|
+
fileCache = { root, at: Date.now(), files };
|
|
82
|
+
return files;
|
|
83
|
+
}
|
|
84
|
+
/** "Permitir sempre neste projeto": tipos de ação liberados, guardados por pasta em ~/.brito/desktop-allow.json. */
|
|
85
|
+
const allowFile = () => path.join(os.homedir(), ".brito", "desktop-allow.json");
|
|
86
|
+
function loadAllow(root) {
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(fs.readFileSync(allowFile(), "utf8"))[path.resolve(root)] ?? [];
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function saveAllow(root, kinds) {
|
|
95
|
+
let all = {};
|
|
96
|
+
try {
|
|
97
|
+
all = JSON.parse(fs.readFileSync(allowFile(), "utf8"));
|
|
98
|
+
}
|
|
99
|
+
catch { /* primeiro uso */ }
|
|
100
|
+
all[path.resolve(root)] = kinds;
|
|
101
|
+
fs.mkdirSync(path.dirname(allowFile()), { recursive: true });
|
|
102
|
+
fs.writeFileSync(allowFile(), JSON.stringify(all, null, 1));
|
|
103
|
+
}
|
|
35
104
|
const sink = {
|
|
36
105
|
turnStart: () => emit({ t: "turnStart" }),
|
|
37
106
|
turnEnd: (ok = true) => emit({ t: "turnEnd", ok }),
|
|
@@ -39,7 +108,7 @@ const sink = {
|
|
|
39
108
|
textEnd: () => emit({ t: "textEnd" }),
|
|
40
109
|
thinking: (text) => emit({ t: "thinking", text }),
|
|
41
110
|
toolStart: (id, name, input) => emit({ t: "toolStart", id, name, input }),
|
|
42
|
-
toolEnd: (id, isError, output) => emit({ t: "toolEnd", id, isError, output: (output ?? "").slice(0,
|
|
111
|
+
toolEnd: (id, isError, output) => emit({ t: "toolEnd", id, isError, output: (output ?? "").slice(0, 20000) }),
|
|
43
112
|
info: (msg) => emit({ t: "info", msg }),
|
|
44
113
|
warn: (msg) => emit({ t: "warn", msg }),
|
|
45
114
|
error: (msg) => emit({ t: "error", msg }),
|
|
@@ -47,10 +116,15 @@ const sink = {
|
|
|
47
116
|
const apiLabel = (m) => (resolveModel(m).kind === "openai" ? "endpoint" : "API Anthropic");
|
|
48
117
|
export async function serveDesktop(root, resumeId) {
|
|
49
118
|
const pending = new Map();
|
|
50
|
-
|
|
119
|
+
/** Tipos de pedido liberados com "permitir nesta sessão" (nunca vale para pedidos `force`, como aprovar plano ou skill). */
|
|
120
|
+
const projectAllow = new Set(loadAllow(root));
|
|
121
|
+
const sessionAllow = new Set(projectAllow);
|
|
122
|
+
emit({ t: "hello", protocol: PROTOCOL });
|
|
123
|
+
const kindOf = (text) => text.split(/[:\s]/, 1)[0].toLowerCase();
|
|
124
|
+
const ask = (text, opts) => (perms.mode === "bypass" || sessionAllow.has(kindOf(text))) && !opts?.force ? Promise.resolve(true) : new Promise((resolve) => {
|
|
51
125
|
const id = randomUUID();
|
|
52
|
-
pending.set(id, resolve);
|
|
53
|
-
emit({ t: "approval", id, text, force: !!opts?.force });
|
|
126
|
+
pending.set(id, { resolve, kind: opts?.force ? "" : kindOf(text) });
|
|
127
|
+
emit({ t: "approval", id, text, force: !!opts?.force, kind: kindOf(text) });
|
|
54
128
|
});
|
|
55
129
|
// Na pasta pessoal (projeto "Home") indexar tudo seria enorme e inútil: sem índice ali.
|
|
56
130
|
const isHome = path.resolve(root) === path.resolve(os.homedir());
|
|
@@ -89,13 +163,29 @@ export async function serveDesktop(root, resumeId) {
|
|
|
89
163
|
catch { /* sem notas */ }
|
|
90
164
|
const ctx = { root, graph, ask, sync, dirty: false, loadedSkills: o.loadedSkills ?? new Set(), model: () => agent.model };
|
|
91
165
|
const fallback = o.fallback ?? newFallbackState(model);
|
|
92
|
-
const
|
|
166
|
+
const system = memoSystem(() => systemKey(root, perms.mode === "plan", syms + notes === 0) + `|${orch.on}`, () => buildSystemPrompt(root, graphStatsLine(syms, notes, "now"), skillsIndex(root)) + (perms.mode === "plan" ? PLAN_NOTE : "") + (orch.on ? ORCH_NOTE : ""));
|
|
167
|
+
const agent = new Agent(system, ctx, { model, sink, fallback, tools: mainTools });
|
|
93
168
|
if (o.messages)
|
|
94
169
|
agent.messages = o.messages;
|
|
95
170
|
if (o.effort)
|
|
96
171
|
agent.effort = o.effort;
|
|
172
|
+
if (o.session?.messages)
|
|
173
|
+
agent.messages = repairHistory(o.session.messages);
|
|
174
|
+
if (o.session?.todos)
|
|
175
|
+
ctx.todos = o.session.todos;
|
|
97
176
|
let ac = null;
|
|
98
|
-
const id = randomUUID();
|
|
177
|
+
const id = o.session?.id ?? randomUUID();
|
|
178
|
+
// Depois de uma troca a partir do claude CLI quem guarda a conversa é ele (o id é o dele): nada a salvar aqui.
|
|
179
|
+
const persist = () => {
|
|
180
|
+
if (o.back || !agent.messages.length)
|
|
181
|
+
return;
|
|
182
|
+
try {
|
|
183
|
+
saveSession(root, { id, engine: "api", model: agent.model, title: titleOf(agent.messages), messages: agent.messages, todos: ctx.todos });
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
sink.warn(`não consegui salvar a sessão: ${e?.message ?? e}`);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
99
189
|
return {
|
|
100
190
|
kind: "api", effort: "", fallback,
|
|
101
191
|
// Depois da troca o id segue o do claude: a janela o guarda para retomar a conversa ao reabrir.
|
|
@@ -125,6 +215,7 @@ export async function serveDesktop(root, resumeId) {
|
|
|
125
215
|
async send(text) {
|
|
126
216
|
ac = new AbortController();
|
|
127
217
|
sink.turnStart();
|
|
218
|
+
orchGuard(agent.model, sink.warn); // troca para o qwen (fallback ou modelo escolhido): não há mais a quem delegar
|
|
128
219
|
let ok = true;
|
|
129
220
|
try {
|
|
130
221
|
await agent.send(text, ac.signal);
|
|
@@ -136,6 +227,7 @@ export async function serveDesktop(root, resumeId) {
|
|
|
136
227
|
finally {
|
|
137
228
|
sink.turnEnd(ok);
|
|
138
229
|
ac = null;
|
|
230
|
+
persist();
|
|
139
231
|
}
|
|
140
232
|
},
|
|
141
233
|
abort: () => ac?.abort(),
|
|
@@ -152,20 +244,23 @@ export async function serveDesktop(root, resumeId) {
|
|
|
152
244
|
};
|
|
153
245
|
};
|
|
154
246
|
if (opening.kind !== "claude") {
|
|
155
|
-
|
|
156
|
-
|
|
247
|
+
// O id que a janela guarda é o da sessão salva em .brito/sessions: reabrir retoma o histórico de verdade.
|
|
248
|
+
const saved = resumeId ? loadSession(root, resumeId) : null;
|
|
249
|
+
const session = saved?.engine === "api" ? saved : undefined;
|
|
250
|
+
if (resumeId && !session)
|
|
251
|
+
emit({ t: "info", msg: "Esta conversa não foi salva no modo API (é de uma versão antiga ou do claude CLI): a tela mostra o que já foi dito, mas o modelo recomeça." });
|
|
157
252
|
if (opening.kind === "fallback") {
|
|
158
253
|
// Claude sem conta nenhuma, com o fallback pronto: a sessão já nasce no qwen (com aviso, nunca em silêncio).
|
|
159
254
|
emit({ t: "warn", msg: `⚠ ${NO_ANTHROPIC} (sem ANTHROPIC_API_KEY e sem o claude CLI) — abrindo no ${opening.label}. Envie /fallback para detalhes.` });
|
|
160
255
|
const fallback = newFallbackState(startModel);
|
|
161
|
-
activateFallback(fallback, NO_ANTHROPIC);
|
|
162
|
-
engine = await buildApiEngine(
|
|
256
|
+
activateFallback(fallback, NO_ANTHROPIC, Date.now(), undefined, opening.name);
|
|
257
|
+
engine = await buildApiEngine(opening.name, { fallback, engineLabel: () => NO_ANTHROPIC, session });
|
|
163
258
|
}
|
|
164
259
|
else
|
|
165
|
-
engine = await buildApiEngine(startModel);
|
|
260
|
+
engine = await buildApiEngine(startModel, { session });
|
|
166
261
|
}
|
|
167
262
|
else {
|
|
168
|
-
const approval = startApprovalServer({ ask });
|
|
263
|
+
const approval = startApprovalServer({ ask }, root);
|
|
169
264
|
closeApproval = approval.close;
|
|
170
265
|
// O banco aceita um único processo com escrita: indexa, lê as stats e solta o banco para o servidor MCP.
|
|
171
266
|
if (!isHome)
|
|
@@ -228,7 +323,7 @@ export async function serveDesktop(root, resumeId) {
|
|
|
228
323
|
sink.error(`não consegui abrir o ${status.label}: ${e?.message ?? e}`);
|
|
229
324
|
return;
|
|
230
325
|
}
|
|
231
|
-
activateFallback(fallback, `${f.kind} (${f.message})
|
|
326
|
+
activateFallback(fallback, `${f.kind} (${f.message})`, Date.now(), undefined, status.name);
|
|
232
327
|
engine = api;
|
|
233
328
|
ready();
|
|
234
329
|
await api.send(retryPrompt(text, resumed));
|
|
@@ -241,10 +336,13 @@ export async function serveDesktop(root, resumeId) {
|
|
|
241
336
|
engine = claude;
|
|
242
337
|
}
|
|
243
338
|
let busy = false;
|
|
244
|
-
|
|
339
|
+
restoreOrch(engine.model, (m) => emit({ t: "warn", msg: m }), (m) => emit({ t: "info", msg: m }));
|
|
340
|
+
const panels = new Panels(root, graphRootFor(root), isHome);
|
|
341
|
+
const ready = () => emit({ t: "ready", engine: engine.kind, sessionId: engine.sessionId, model: engine.model, effort: engine.effort, mode: perms.mode, orch: orch.on, symbols: syms, notes, root, fallback: engine.fallback.active, chip: chipOf(engine.model, engine.fallback) });
|
|
245
342
|
ready();
|
|
246
343
|
const rl = readline.createInterface({ input: process.stdin });
|
|
247
344
|
const quit = async () => { closeApproval(); try {
|
|
345
|
+
await panels.close();
|
|
248
346
|
await engine.close();
|
|
249
347
|
}
|
|
250
348
|
catch { /* saindo */ } process.exit(0); };
|
|
@@ -271,23 +369,83 @@ export async function serveDesktop(root, resumeId) {
|
|
|
271
369
|
sink.turnEnd(true);
|
|
272
370
|
return;
|
|
273
371
|
}
|
|
372
|
+
// /nome de uma skill: mesma invocação do REPL (skill_load + contexto).
|
|
373
|
+
let text = String(m.text ?? "");
|
|
374
|
+
const [cmd, ...args] = text.trim().split(/\s+/);
|
|
375
|
+
const sk = cmd.startsWith("/") && !cmd.slice(1).includes("/") ? getSkill(root, cmd.slice(1).toLowerCase()) : null;
|
|
376
|
+
if (sk) {
|
|
377
|
+
const ctxText = args.join(" ");
|
|
378
|
+
text = `Invoquei a skill ${sk.name}. Chame skill_load("${sk.name}") agora e siga-a.${ctxText ? `\n\nContexto: ${ctxText}` : "\n\n(Sem contexto extra: comece perguntando sobre o que quero trabalhar.)"}`;
|
|
379
|
+
}
|
|
274
380
|
busy = true;
|
|
275
381
|
try {
|
|
276
|
-
await engine.send(
|
|
382
|
+
await engine.send(text);
|
|
277
383
|
}
|
|
278
384
|
finally {
|
|
279
385
|
busy = false;
|
|
280
|
-
emit({ t: "usage", ...engine.stats(), model: engine.model, sessionId: engine.sessionId, fallback: engine.fallback.active });
|
|
386
|
+
emit({ t: "usage", ...engine.stats(), model: engine.model, sessionId: engine.sessionId, fallback: engine.fallback.active, chip: chipOf(engine.model, engine.fallback) });
|
|
281
387
|
}
|
|
282
388
|
break;
|
|
283
389
|
}
|
|
284
390
|
case "abort":
|
|
285
391
|
engine.abort();
|
|
286
392
|
break;
|
|
287
|
-
case "
|
|
288
|
-
|
|
393
|
+
case "query": {
|
|
394
|
+
let data = [];
|
|
395
|
+
try {
|
|
396
|
+
const arg = String(m.arg ?? "").toLowerCase();
|
|
397
|
+
if (["skills", "skill", "agents", "symbols", "symbol", "notes"].includes(m.q)) {
|
|
398
|
+
const raw = String(m.arg ?? "");
|
|
399
|
+
data = m.q === "skills" ? panels.skills() : m.q === "skill" ? panels.skill(raw) : m.q === "agents" ? panels.agents()
|
|
400
|
+
: m.q === "symbols" ? await panels.symbols(raw) : m.q === "symbol" ? await panels.symbol(raw) : await panels.notes(raw);
|
|
401
|
+
emit({ t: "result", id: m.id, data });
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
if (m.q === "files")
|
|
405
|
+
data = projectFiles(root).filter((f) => !f.startsWith(".brito/") && (!arg || f.toLowerCase().includes(arg))).sort((a, b) => a.length - b.length).slice(0, 30);
|
|
406
|
+
else if (m.q === "commands") {
|
|
407
|
+
const skills = [...listBuiltins(root), ...listSkills(root).filter((s) => s.invoke === "manual")].map((s) => ({ name: s.name, description: s.description, kind: "skill" }));
|
|
408
|
+
data = [...BUILTIN_COMMANDS, ...skills].filter((c) => !arg || c.name.includes(arg));
|
|
409
|
+
}
|
|
410
|
+
else if (m.q === "models")
|
|
411
|
+
data = { aliases: Object.keys(MODEL_ALIASES), profiles: Object.keys(loadProfiles().profiles) };
|
|
412
|
+
}
|
|
413
|
+
catch (e) {
|
|
414
|
+
sink.warn(`consulta ${m.q} falhou: ${e?.message ?? e}`);
|
|
415
|
+
}
|
|
416
|
+
emit({ t: "result", id: m.id, data });
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
case "action": {
|
|
420
|
+
try {
|
|
421
|
+
const arg = String(m.arg ?? "");
|
|
422
|
+
const msg = m.op === "graph.reindex" ? `grafo reindexado (${(await panels.reindex()).symbols} símbolos)`
|
|
423
|
+
: m.op === "note.delete" ? await panels.deleteNote(arg)
|
|
424
|
+
: panels.skillAction(String(m.op).replace(/^skill\./, ""), arg);
|
|
425
|
+
emit({ t: "result", id: m.id, data: { ok: true, msg } });
|
|
426
|
+
}
|
|
427
|
+
catch (e) {
|
|
428
|
+
emit({ t: "result", id: m.id, data: { ok: false, msg: e?.message ?? String(e) } });
|
|
429
|
+
}
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
case "approve": {
|
|
433
|
+
const p = pending.get(m.id);
|
|
434
|
+
if (p && m.allow && (m.scope === "session" || m.scope === "project") && p.kind) {
|
|
435
|
+
sessionAllow.add(p.kind);
|
|
436
|
+
if (m.scope === "project")
|
|
437
|
+
try {
|
|
438
|
+
projectAllow.add(p.kind);
|
|
439
|
+
saveAllow(root, [...projectAllow]);
|
|
440
|
+
}
|
|
441
|
+
catch (e) {
|
|
442
|
+
sink.warn(`não consegui salvar a permissão: ${e?.message ?? e}`);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
p?.resolve(!!m.allow);
|
|
289
446
|
pending.delete(m.id);
|
|
290
447
|
break;
|
|
448
|
+
}
|
|
291
449
|
case "config": {
|
|
292
450
|
if (typeof m.model === "string")
|
|
293
451
|
engine.setModel(m.model ? MODEL_ALIASES[m.model] ?? m.model : ""); // vazio = modelo/perfil salvo
|
|
@@ -295,6 +453,16 @@ export async function serveDesktop(root, resumeId) {
|
|
|
295
453
|
engine.effort = isEffort(m.effort) ? m.effort : "";
|
|
296
454
|
if (["manual", "auto", "plan", "bypass"].includes(m.mode))
|
|
297
455
|
perms.mode = m.mode;
|
|
456
|
+
// "on" liga o modo orquestrador (se o implementador estiver pronto); "" desliga.
|
|
457
|
+
if (m.orch === "")
|
|
458
|
+
orch.on = false;
|
|
459
|
+
else if (m.orch === "on") {
|
|
460
|
+
const s = implStatus(engine.model, orch.model, process.env);
|
|
461
|
+
if (s.ready)
|
|
462
|
+
orch.on = true;
|
|
463
|
+
else
|
|
464
|
+
sink.warn(`modo orquestrador não ligou: ${s.reason}`);
|
|
465
|
+
}
|
|
298
466
|
ready();
|
|
299
467
|
break;
|
|
300
468
|
}
|
package/dist/engine.js
CHANGED
|
@@ -1,13 +1,35 @@
|
|
|
1
1
|
import { MODEL_ALIASES, loadProfiles } from "./model.js";
|
|
2
2
|
import { missingKeyMessage } from "./provider/openai-compat.js";
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import { slmReadySync } from "./local/slm.js";
|
|
4
|
+
import { SLM_MODEL, resolveModel } from "./provider/resolve.js";
|
|
5
|
+
/** O primeiro fallback é sempre este perfil de `providers` (não configurável). */
|
|
5
6
|
export const FALLBACK_PROFILE = "qwen";
|
|
7
|
+
/** Cadeia: Claude → qwen → SLM local (último recurso, sem rede nem conta). */
|
|
8
|
+
export const FALLBACK_CHAIN = [FALLBACK_PROFILE, SLM_MODEL];
|
|
6
9
|
/**
|
|
7
10
|
* Pronto só se o perfil existe, é válido, tem a chave no ambiente e o principal não é ele mesmo. Relê o config a
|
|
8
11
|
* cada chamada, como os perfis.
|
|
9
12
|
*/
|
|
10
|
-
export function fallbackStatus(primary, env) {
|
|
13
|
+
export function fallbackStatus(primary, env, skip = []) {
|
|
14
|
+
const q = skip.includes(FALLBACK_PROFILE) ? { ready: false, reason: "o qwen já foi usado nesta troca" } : qwenStatus(primary, env);
|
|
15
|
+
if (q.ready)
|
|
16
|
+
return q;
|
|
17
|
+
const slm = slmFallbackStatus(primary, env, skip);
|
|
18
|
+
return slm.ready ? slm : q;
|
|
19
|
+
}
|
|
20
|
+
/** O SLM local como fallback: baixado (`brito slm pull`), habilitado e não é o próprio principal. */
|
|
21
|
+
function slmFallbackStatus(primary, env, skip) {
|
|
22
|
+
if (skip.includes(SLM_MODEL))
|
|
23
|
+
return { ready: false, reason: "o SLM já foi usado nesta troca" };
|
|
24
|
+
if (primary.trim().toLowerCase() === SLM_MODEL)
|
|
25
|
+
return { ready: false, reason: "o modelo principal já é o SLM" };
|
|
26
|
+
if (env.BRITO_SLM_FALLBACK === "off")
|
|
27
|
+
return { ready: false, reason: "BRITO_SLM_FALLBACK=off" };
|
|
28
|
+
if (!slmReadySync())
|
|
29
|
+
return { ready: false, reason: "SLM local indisponível (rode `brito slm pull`)" };
|
|
30
|
+
return { ready: true, name: SLM_MODEL, label: resolveModel(SLM_MODEL).label };
|
|
31
|
+
}
|
|
32
|
+
function qwenStatus(primary, env) {
|
|
11
33
|
const { profiles, errors, rejected } = loadProfiles();
|
|
12
34
|
const p = profiles[FALLBACK_PROFILE];
|
|
13
35
|
const label = p?.label;
|
|
@@ -36,6 +58,8 @@ export function pickEngine(model, env, hasCli) {
|
|
|
36
58
|
const why = loadProfiles().errors.find((e) => e.toLowerCase().includes(`"${name}"`)) ?? "";
|
|
37
59
|
return { kind: "error", message: `o perfil "${name}" em ~/.brito/config.json está inválido (${why}); corrija antes de usar` };
|
|
38
60
|
}
|
|
61
|
+
if (r.local)
|
|
62
|
+
return slmReadySync() ? { kind: "api" } : { kind: "error", message: "o SLM local não está pronto: rode `brito slm pull`" };
|
|
39
63
|
if (r.kind === "openai")
|
|
40
64
|
return env[r.profile.apiKeyEnv]?.trim() ? { kind: "api" } : { kind: "error", message: missingKeyMessage(r.profile) };
|
|
41
65
|
if (env.ANTHROPIC_API_KEY)
|
|
@@ -56,7 +80,7 @@ export function openEngine(model, env, hasCli) {
|
|
|
56
80
|
if (e.kind !== "error" || resolveModel(model).kind !== "anthropic" || loadProfiles().rejected.includes(model.trim().toLowerCase()))
|
|
57
81
|
return e;
|
|
58
82
|
const s = fallbackStatus(model, env);
|
|
59
|
-
return s.ready ? { kind: "fallback", label: s.label } : { kind: "error", message: `${e.message}\nFallback (qwen) indisponível: ${s.reason}.` };
|
|
83
|
+
return s.ready ? { kind: "fallback", label: s.label, name: s.name } : { kind: "error", message: `${e.message}\nFallback (qwen) indisponível: ${s.reason}.` };
|
|
60
84
|
}
|
|
61
85
|
/**
|
|
62
86
|
* Modelo de um subagente. BRITO_EXPLORE_MODEL manda no `explore`. Com o agente principal num endpoint, subagente
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { words } from "../skills/store.js";
|
|
2
|
+
import { slmGenerate } from "./slm.js";
|
|
3
|
+
const PAGE_RE = /^=== PÁGINA (\d+) ===\s*$/m;
|
|
4
|
+
/** Texto comparável: sem hífen de quebra de linha, sem caixa/acentos, espaços colapsados. */
|
|
5
|
+
export const norm = (s) => s.replace(/\s*/g, "").replace(/-\s*\n\s*/g, "").toLowerCase().normalize("NFD").replace(/\p{M}/gu, "").replace(/[^\p{L}\p{N}]+/gu, " ").trim();
|
|
6
|
+
/** Comparação tolerante a OCR: só letras e dígitos (espaços no meio de palavras, hifenização e pontuação não contam). */
|
|
7
|
+
export const squash = (s) => norm(s).replace(/ /g, "");
|
|
8
|
+
const META_RE = /sum[áa]rio|[íi]ndice (remissivo|geral)|todos os direitos|catalog[aã]{1,2}o|isbn|copyright|ficha t[ée]cnica|dados internacionais|agradecimentos|impresso no brasil/i;
|
|
9
|
+
/**
|
|
10
|
+
* Tira páginas que não são conteúdo: capa, orelha e elogios (as primeiras páginas de um livro longo) e páginas de
|
|
11
|
+
* metadados (sumário, ficha catalográfica, direitos). Só age em textos com marcas de página e com 60 páginas ou mais:
|
|
12
|
+
* num capítulo curto, a primeira página É conteúdo.
|
|
13
|
+
*/
|
|
14
|
+
export function stripFrontMatter(text) {
|
|
15
|
+
const parts = text.split(/(?=^=== PÁGINA \d+ ===\s*$)/m);
|
|
16
|
+
const marked = parts.filter((p) => PAGE_RE.test(p));
|
|
17
|
+
if (marked.length < 60)
|
|
18
|
+
return { text, removed: 0 };
|
|
19
|
+
const head = Math.min(8, Math.ceil(marked.length * 0.02));
|
|
20
|
+
let removed = 0;
|
|
21
|
+
const kept = parts.filter((p) => {
|
|
22
|
+
const n = Number(PAGE_RE.exec(p)?.[1] ?? NaN);
|
|
23
|
+
if (!n)
|
|
24
|
+
return true;
|
|
25
|
+
// cue de metadados só vale perto das pontas, para não apagar um capítulo que cita "índice" ou "direitos"
|
|
26
|
+
const edge = n <= head + 12 || n > marked.length - 6;
|
|
27
|
+
const drop = n <= head || (edge && p.length < 4000 && META_RE.test(p));
|
|
28
|
+
if (drop)
|
|
29
|
+
removed++;
|
|
30
|
+
return !drop;
|
|
31
|
+
});
|
|
32
|
+
return { text: kept.join(""), removed };
|
|
33
|
+
}
|
|
34
|
+
/** Divide em blocos de ~chunkChars, respeitando as marcas "=== PÁGINA N ===" quando existem. */
|
|
35
|
+
export function chunkText(text, chunkChars = 5000) {
|
|
36
|
+
const parts = text.split(/(?=^=== PÁGINA \d+ ===\s*$)/m);
|
|
37
|
+
const pages = parts.map((p) => ({ page: Number(PAGE_RE.exec(p)?.[1] ?? NaN) || null, text: p }));
|
|
38
|
+
const out = [];
|
|
39
|
+
let cur = [];
|
|
40
|
+
let size = 0;
|
|
41
|
+
const flush = () => { if (cur.length)
|
|
42
|
+
out.push({ text: cur.map((p) => p.text).join("\n"), pages: cur }); cur = []; size = 0; };
|
|
43
|
+
for (const pg of pages) {
|
|
44
|
+
// página gigante (ou texto sem marcas): fatia por tamanho
|
|
45
|
+
if (pg.text.length > chunkChars * 1.5) {
|
|
46
|
+
flush();
|
|
47
|
+
for (let i = 0; i < pg.text.length; i += chunkChars) {
|
|
48
|
+
const t = pg.text.slice(i, i + chunkChars);
|
|
49
|
+
out.push({ text: t, pages: [{ page: pg.page, text: t }] });
|
|
50
|
+
}
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (size + pg.text.length > chunkChars)
|
|
54
|
+
flush();
|
|
55
|
+
cur.push(pg);
|
|
56
|
+
size += pg.text.length;
|
|
57
|
+
}
|
|
58
|
+
flush();
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
/** Afinidade lexical (barata, sem modelo) entre o objetivo e cada bloco: escolhe onde vale gastar o SLM. */
|
|
62
|
+
export function rankChunks(chunks, objective) {
|
|
63
|
+
const q = [...new Set(words(objective))];
|
|
64
|
+
return chunks.map((c, index) => {
|
|
65
|
+
const t = norm(c.text);
|
|
66
|
+
let score = 0;
|
|
67
|
+
for (const w of q) {
|
|
68
|
+
const m = t.split(w).length - 1;
|
|
69
|
+
score += m ? 1 + Math.log(m) : 0;
|
|
70
|
+
}
|
|
71
|
+
return { ...c, score, index };
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/** Página onde a citação aparece (a primeira página do bloco em que ela cabe inteira; senão a primeira em que começa). */
|
|
75
|
+
function pageOf(c, quoteSq) {
|
|
76
|
+
for (const p of c.pages)
|
|
77
|
+
if (squash(p.text).includes(quoteSq))
|
|
78
|
+
return p.page;
|
|
79
|
+
const head = quoteSq.slice(0, 40);
|
|
80
|
+
for (const p of c.pages)
|
|
81
|
+
if (squash(p.text).includes(head))
|
|
82
|
+
return p.page;
|
|
83
|
+
return c.pages[0]?.page ?? null;
|
|
84
|
+
}
|
|
85
|
+
/** Confere as citações propostas contra o texto do bloco. Só passa o que existe literalmente (após normalização). */
|
|
86
|
+
export function verifyExcerpts(c, proposed) {
|
|
87
|
+
const body = squash(c.text);
|
|
88
|
+
const kept = [];
|
|
89
|
+
let discarded = 0;
|
|
90
|
+
for (const p of proposed) {
|
|
91
|
+
const quote = typeof p?.quote === "string" ? p.quote.trim() : "";
|
|
92
|
+
const qn = squash(quote);
|
|
93
|
+
if (qn.length < 25 || !body.includes(qn)) {
|
|
94
|
+
discarded++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
kept.push({ quote, topic: typeof p.topic === "string" && p.topic.trim() ? p.topic.trim().slice(0, 80) : "Geral", page: pageOf(c, qn), chunk: c.index });
|
|
98
|
+
}
|
|
99
|
+
return { kept, discarded };
|
|
100
|
+
}
|
|
101
|
+
const EXTRACT_SCHEMA = {
|
|
102
|
+
type: "object",
|
|
103
|
+
properties: { excerpts: { type: "array", maxItems: 4, items: { type: "object", properties: { quote: { type: "string" }, topic: { type: "string" } }, required: ["quote", "topic"] } } },
|
|
104
|
+
required: ["excerpts"],
|
|
105
|
+
};
|
|
106
|
+
const OUTLINE_SCHEMA = {
|
|
107
|
+
type: "object",
|
|
108
|
+
properties: { sections: { type: "array", maxItems: 8, items: { type: "object", properties: { heading: { type: "string" }, quotes: { type: "array", items: { type: "integer" } } }, required: ["heading", "quotes"] } } },
|
|
109
|
+
required: ["sections"],
|
|
110
|
+
};
|
|
111
|
+
const parse = (s) => { try {
|
|
112
|
+
return JSON.parse(s);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return null;
|
|
116
|
+
} };
|
|
117
|
+
export async function digest(o) {
|
|
118
|
+
const gen = o.gen ?? slmGenerate;
|
|
119
|
+
const front = stripFrontMatter(o.text);
|
|
120
|
+
const ranked = rankChunks(chunkText(front.text, o.chunkChars), o.objective);
|
|
121
|
+
const chosen = [...ranked].filter((c) => c.score > 0).sort((a, b) => b.score - a.score).slice(0, o.maxChunks ?? 8).sort((a, b) => a.index - b.index);
|
|
122
|
+
const stats = { chunks: ranked.length, analyzed: chosen.length, proposed: 0, verified: 0, discarded: 0, skippedPages: front.removed };
|
|
123
|
+
const system = `Você extrai trechos de um texto longo para um objetivo. Responda SOMENTE com JSON {"excerpts":[{"quote","topic"}]}.
|
|
124
|
+
Regras: (1) "quote" é uma cópia EXATA e contínua de 1 a 3 frases do texto dado; nunca parafraseie, resuma nem corrija. (2) Escolha só trechos que ajudam o objetivo; prefira incluir um trecho duvidoso a perder um bom. (3) O texto pode ter erros de OCR (espaços no meio de palavras, hifenização): copie as PALAVRAS exatamente, corrigindo só esses espaços. (4) "topic" é um rótulo curto (2 a 5 palavras) do assunto do trecho. (5) Se nada serve, responda {"excerpts":[]}.`;
|
|
125
|
+
const found = [];
|
|
126
|
+
const seen = new Set();
|
|
127
|
+
let n = 0;
|
|
128
|
+
for (const c of chosen) {
|
|
129
|
+
n++;
|
|
130
|
+
o.onProgress?.(`analisando bloco ${n}/${chosen.length}`);
|
|
131
|
+
let data = null;
|
|
132
|
+
try {
|
|
133
|
+
data = parse(await gen({ system, user: `Objetivo: ${o.objective}\n\nTexto:\n${c.text}`, schema: EXTRACT_SCHEMA, maxTokens: 900, temperature: 0.1, signal: o.signal }));
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
if (o.signal?.aborted)
|
|
137
|
+
throw e;
|
|
138
|
+
}
|
|
139
|
+
const proposed = Array.isArray(data?.excerpts) ? data.excerpts : [];
|
|
140
|
+
stats.proposed += proposed.length;
|
|
141
|
+
const { kept, discarded } = verifyExcerpts(c, proposed);
|
|
142
|
+
stats.discarded += discarded;
|
|
143
|
+
for (const k of kept) {
|
|
144
|
+
const key = squash(k.quote);
|
|
145
|
+
if (!seen.has(key)) {
|
|
146
|
+
seen.add(key);
|
|
147
|
+
found.push(k);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
stats.verified = found.length;
|
|
152
|
+
// Plano: o SLM agrupa por número; índices inválidos são ignorados, e o que sobrar cai em "Outros".
|
|
153
|
+
let sections = [];
|
|
154
|
+
if (found.length > 1) {
|
|
155
|
+
o.onProgress?.("montando o plano");
|
|
156
|
+
const list = found.map((f, i) => `${i}. [${f.topic}] ${f.quote.replace(/\s+/g, " ").slice(0, 160)}`).join("\n");
|
|
157
|
+
try {
|
|
158
|
+
const plan = parse(await gen({ system: `Você organiza trechos num plano para o objetivo dado. Responda SOMENTE com JSON {"sections":[{"heading","quotes":[números]}]}. Cada número de trecho aparece em UMA seção; os títulos seguem a ordem lógica do argumento (máx. 8).`, user: `Objetivo: ${o.objective}\n\nTrechos:\n${list}`, schema: OUTLINE_SCHEMA, maxTokens: 700, temperature: 0.1, signal: o.signal }));
|
|
159
|
+
const used = new Set();
|
|
160
|
+
for (const s of Array.isArray(plan?.sections) ? plan.sections : []) {
|
|
161
|
+
const items = (Array.isArray(s?.quotes) ? s.quotes : []).filter((i) => Number.isInteger(i) && i >= 0 && i < found.length && !used.has(i));
|
|
162
|
+
items.forEach((i) => used.add(i));
|
|
163
|
+
if (items.length && typeof s.heading === "string")
|
|
164
|
+
sections.push({ heading: s.heading.trim().slice(0, 100), items });
|
|
165
|
+
}
|
|
166
|
+
const rest = found.map((_, i) => i).filter((i) => !used.has(i));
|
|
167
|
+
if (rest.length)
|
|
168
|
+
sections.push({ heading: "Outros trechos", items: rest });
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
if (o.signal?.aborted)
|
|
172
|
+
throw e;
|
|
173
|
+
sections = [];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (!sections.length) {
|
|
177
|
+
const byTopic = new Map();
|
|
178
|
+
found.forEach((f, i) => byTopic.set(f.topic, [...(byTopic.get(f.topic) ?? []), i]));
|
|
179
|
+
sections = [...byTopic].map(([heading, items]) => ({ heading, items }));
|
|
180
|
+
}
|
|
181
|
+
return { markdown: render(o, found, sections, stats), stats };
|
|
182
|
+
}
|
|
183
|
+
function render(o, found, sections, s) {
|
|
184
|
+
const cite = (e) => (e.page ? ` (p. ${e.page})` : "");
|
|
185
|
+
const body = sections.map((sec) => `### ${sec.heading}\n${sec.items.map((i) => `- “${found[i].quote.replace(/\s+/g, " ")}”${cite(found[i])}`).join("\n")}`).join("\n\n");
|
|
186
|
+
return `---
|
|
187
|
+
tipo: digest
|
|
188
|
+
fonte: ${o.source}
|
|
189
|
+
objetivo: ${o.objective.replace(/\n/g, " ")}
|
|
190
|
+
gerado-por: SLM local
|
|
191
|
+
---
|
|
192
|
+
# Digest: ${o.objective.replace(/\n/g, " ")}
|
|
193
|
+
|
|
194
|
+
> Rascunho de um modelo pequeno. Os trechos entre aspas foram CONFERIDOS no texto-fonte (mesmas palavras; só espaços e hifenização de OCR foram normalizados; páginas conforme as marcas do arquivo). A SELEÇÃO e o agrupamento podem ser incompletos: foram analisados ${s.analyzed} de ${s.chunks} blocos${s.skippedPages ? ` (${s.skippedPages} página(s) de capa/orelha/metadados ignoradas)` : ""} (os de maior afinidade com o objetivo); ${s.discarded} citação(ões) proposta(s) foram descartadas por não existirem no texto.
|
|
195
|
+
|
|
196
|
+
## Plano
|
|
197
|
+
${sections.map((sec, i) => `${i + 1}. ${sec.heading} (${sec.items.length})`).join("\n") || "(nenhum trecho verificado)"}
|
|
198
|
+
|
|
199
|
+
## Trechos
|
|
200
|
+
${body || "(nenhum trecho verificado)"}
|
|
201
|
+
`;
|
|
202
|
+
}
|