brito-cli 0.8.0 → 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/.brito/agents/reviewer.md +7 -7
- package/LICENSE +75 -75
- package/README.md +309 -298
- package/README.pt-BR.md +312 -301
- package/dist/agent/claude-cli.js +9 -3
- package/dist/agent/loop.js +8 -2
- package/dist/agent/prompt.js +25 -25
- package/dist/cli.js +81 -5
- package/dist/desktop/panels.js +93 -0
- package/dist/desktop/protocol.js +5 -0
- package/dist/desktop/serve.js +159 -14
- package/dist/graph/migrate.js +6 -6
- package/dist/mcp/server.js +35 -3
- package/dist/model.js +21 -0
- package/dist/orchestrator.js +131 -0
- package/dist/permissions.js +7 -3
- package/dist/repl.js +5 -1
- package/dist/skills/builtin.js +63 -63
- package/dist/skills/consolidate.js +7 -7
- package/dist/skills/learn.js +21 -21
- package/dist/tools/delegate.js +30 -22
- package/dist/tools/implement.js +50 -0
- package/dist/tools/spec.js +47 -0
- package/package.json +81 -80
package/dist/desktop/serve.js
CHANGED
|
@@ -2,12 +2,17 @@ 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";
|
|
12
17
|
import "../tools/digest.js";
|
|
13
18
|
import "../tools/todo.js";
|
|
@@ -21,13 +26,14 @@ import { openGraphAfterClaude } from "../graph/store.js";
|
|
|
21
26
|
import { indexProject } from "../graph/indexer.js";
|
|
22
27
|
import { initialEffort, initialModel, isEffort, loadProfiles, MODEL_ALIASES } from "../model.js";
|
|
23
28
|
import { NO_ANTHROPIC, fallbackStatus, openEngine } from "../engine.js";
|
|
24
|
-
import { activateFallback, chooseModel, cliShouldFallBack, fallbackWarning, newFallbackState, renderFallback, retryPrompt } from "../provider/fallback.js";
|
|
29
|
+
import { engineChip, activateFallback, chooseModel, cliShouldFallBack, fallbackWarning, newFallbackState, renderFallback, retryPrompt } from "../provider/fallback.js";
|
|
25
30
|
import { contextWindowFor, resolveModel } from "../provider/resolve.js";
|
|
26
31
|
import { PLAN_NOTE, perms } from "../permissions.js";
|
|
27
|
-
import {
|
|
32
|
+
import { ORCH_NOTE, implStatus, mainTools, orch, orchGuard, restoreOrch } from "../orchestrator.js";
|
|
33
|
+
import { getSkill, listBuiltins, listSkills, skillsIndex } from "../skills/store.js";
|
|
28
34
|
/**
|
|
29
35
|
* Backend do app desktop, controlado por JSON em linhas.
|
|
30
|
-
* 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"}
|
|
31
37
|
* stdout → linhas "@@{json}" (qualquer outra linha, como logs perdidos, a janela ignora).
|
|
32
38
|
* Motor: perfil de endpoint (ex.: Qwen) ou ANTHROPIC_API_KEY usam o loop próprio (como o `brito` no terminal); sem
|
|
33
39
|
* nada disso, o `claude` CLI (assinatura). Se o Claude cai por conta/capacidade (ou nem há conta), segue no qwen.
|
|
@@ -35,6 +41,66 @@ import { skillsIndex } from "../skills/store.js";
|
|
|
35
41
|
*/
|
|
36
42
|
const MARK = "@@";
|
|
37
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
|
+
}
|
|
38
104
|
const sink = {
|
|
39
105
|
turnStart: () => emit({ t: "turnStart" }),
|
|
40
106
|
turnEnd: (ok = true) => emit({ t: "turnEnd", ok }),
|
|
@@ -42,7 +108,7 @@ const sink = {
|
|
|
42
108
|
textEnd: () => emit({ t: "textEnd" }),
|
|
43
109
|
thinking: (text) => emit({ t: "thinking", text }),
|
|
44
110
|
toolStart: (id, name, input) => emit({ t: "toolStart", id, name, input }),
|
|
45
|
-
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) }),
|
|
46
112
|
info: (msg) => emit({ t: "info", msg }),
|
|
47
113
|
warn: (msg) => emit({ t: "warn", msg }),
|
|
48
114
|
error: (msg) => emit({ t: "error", msg }),
|
|
@@ -50,10 +116,15 @@ const sink = {
|
|
|
50
116
|
const apiLabel = (m) => (resolveModel(m).kind === "openai" ? "endpoint" : "API Anthropic");
|
|
51
117
|
export async function serveDesktop(root, resumeId) {
|
|
52
118
|
const pending = new Map();
|
|
53
|
-
|
|
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) => {
|
|
54
125
|
const id = randomUUID();
|
|
55
|
-
pending.set(id, resolve);
|
|
56
|
-
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) });
|
|
57
128
|
});
|
|
58
129
|
// Na pasta pessoal (projeto "Home") indexar tudo seria enorme e inútil: sem índice ali.
|
|
59
130
|
const isHome = path.resolve(root) === path.resolve(os.homedir());
|
|
@@ -92,8 +163,8 @@ export async function serveDesktop(root, resumeId) {
|
|
|
92
163
|
catch { /* sem notas */ }
|
|
93
164
|
const ctx = { root, graph, ask, sync, dirty: false, loadedSkills: o.loadedSkills ?? new Set(), model: () => agent.model };
|
|
94
165
|
const fallback = o.fallback ?? newFallbackState(model);
|
|
95
|
-
const system = memoSystem(() => systemKey(root, perms.mode === "plan", syms + notes === 0)
|
|
96
|
-
const agent = new Agent(system, ctx, { model, sink, fallback });
|
|
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 });
|
|
97
168
|
if (o.messages)
|
|
98
169
|
agent.messages = o.messages;
|
|
99
170
|
if (o.effort)
|
|
@@ -144,6 +215,7 @@ export async function serveDesktop(root, resumeId) {
|
|
|
144
215
|
async send(text) {
|
|
145
216
|
ac = new AbortController();
|
|
146
217
|
sink.turnStart();
|
|
218
|
+
orchGuard(agent.model, sink.warn); // troca para o qwen (fallback ou modelo escolhido): não há mais a quem delegar
|
|
147
219
|
let ok = true;
|
|
148
220
|
try {
|
|
149
221
|
await agent.send(text, ac.signal);
|
|
@@ -264,10 +336,13 @@ export async function serveDesktop(root, resumeId) {
|
|
|
264
336
|
engine = claude;
|
|
265
337
|
}
|
|
266
338
|
let busy = false;
|
|
267
|
-
|
|
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) });
|
|
268
342
|
ready();
|
|
269
343
|
const rl = readline.createInterface({ input: process.stdin });
|
|
270
344
|
const quit = async () => { closeApproval(); try {
|
|
345
|
+
await panels.close();
|
|
271
346
|
await engine.close();
|
|
272
347
|
}
|
|
273
348
|
catch { /* saindo */ } process.exit(0); };
|
|
@@ -294,23 +369,83 @@ export async function serveDesktop(root, resumeId) {
|
|
|
294
369
|
sink.turnEnd(true);
|
|
295
370
|
return;
|
|
296
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
|
+
}
|
|
297
380
|
busy = true;
|
|
298
381
|
try {
|
|
299
|
-
await engine.send(
|
|
382
|
+
await engine.send(text);
|
|
300
383
|
}
|
|
301
384
|
finally {
|
|
302
385
|
busy = false;
|
|
303
|
-
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) });
|
|
304
387
|
}
|
|
305
388
|
break;
|
|
306
389
|
}
|
|
307
390
|
case "abort":
|
|
308
391
|
engine.abort();
|
|
309
392
|
break;
|
|
310
|
-
case "
|
|
311
|
-
|
|
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);
|
|
312
446
|
pending.delete(m.id);
|
|
313
447
|
break;
|
|
448
|
+
}
|
|
314
449
|
case "config": {
|
|
315
450
|
if (typeof m.model === "string")
|
|
316
451
|
engine.setModel(m.model ? MODEL_ALIASES[m.model] ?? m.model : ""); // vazio = modelo/perfil salvo
|
|
@@ -318,6 +453,16 @@ export async function serveDesktop(root, resumeId) {
|
|
|
318
453
|
engine.effort = isEffort(m.effort) ? m.effort : "";
|
|
319
454
|
if (["manual", "auto", "plan", "bypass"].includes(m.mode))
|
|
320
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
|
+
}
|
|
321
466
|
ready();
|
|
322
467
|
break;
|
|
323
468
|
}
|
package/dist/graph/migrate.js
CHANGED
|
@@ -3,12 +3,12 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
5
|
import { GraphStore } from "./store.js";
|
|
6
|
-
const READER = `const k = require("kuzu");
|
|
7
|
-
const db = new k.Database(process.argv[2], 0, true, true);
|
|
8
|
-
const c = new k.Connection(db);
|
|
9
|
-
c.query("MATCH (n:Note) RETURN n.id AS id, n.kind AS kind, n.text AS text, n.refs AS refs, n.ts AS ts").then((r) => r.getAll()).then((rows) => {
|
|
10
|
-
process.stdout.write(JSON.stringify(rows, (_, v) => (typeof v === "bigint" ? Number(v) : v)));
|
|
11
|
-
process.exit(0);
|
|
6
|
+
const READER = `const k = require("kuzu");
|
|
7
|
+
const db = new k.Database(process.argv[2], 0, true, true);
|
|
8
|
+
const c = new k.Connection(db);
|
|
9
|
+
c.query("MATCH (n:Note) RETURN n.id AS id, n.kind AS kind, n.text AS text, n.refs AS refs, n.ts AS ts").then((r) => r.getAll()).then((rows) => {
|
|
10
|
+
process.stdout.write(JSON.stringify(rows, (_, v) => (typeof v === "bigint" ? Number(v) : v)));
|
|
11
|
+
process.exit(0);
|
|
12
12
|
});`;
|
|
13
13
|
/** Copia as notas (memória) do grafo antigo (.brito/graph.kuzu) para o novo (.brito/graph.lbug).
|
|
14
14
|
* O Kùzu só é instalado numa pasta temporária, durante esta chamada: o pacote principal não depende mais dele. */
|
package/dist/mcp/server.js
CHANGED
|
@@ -5,6 +5,12 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
5
5
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
6
6
|
import "../tools/graph.js";
|
|
7
7
|
import "../tools/skills.js";
|
|
8
|
+
// O implementador do modo orquestrador roda aqui e edita/roda comandos; fs e bash só existem para ele (não são expostos).
|
|
9
|
+
import "../tools/fs.js";
|
|
10
|
+
import "../tools/bash.js";
|
|
11
|
+
import { implementIo } from "../tools/implement.js";
|
|
12
|
+
import "../tools/spec.js";
|
|
13
|
+
import { ORCH_ONLY, orch } from "../orchestrator.js";
|
|
8
14
|
import "../tools/digest.js";
|
|
9
15
|
import { allTools, execTool } from "../tools/registry.js";
|
|
10
16
|
import { GraphStore } from "../graph/store.js";
|
|
@@ -28,13 +34,16 @@ export const MCP_INSTRUCTIONS = [
|
|
|
28
34
|
* tools de consulta, que são baratas e de uso frequente. As de escrita e a de aprovação (interna) continuam adiadas.
|
|
29
35
|
*/
|
|
30
36
|
export const ALWAYS_LOAD_META = "anthropic/alwaysLoad";
|
|
31
|
-
export const alwaysLoaded = (tool) => /^(graph_|recall$|remember$|skill_load$|digest$)/.test(tool);
|
|
37
|
+
export const alwaysLoaded = (tool) => /^(graph_|recall$|remember$|skill_load$|digest$|implement$)/.test(tool);
|
|
32
38
|
/** Prefixo com que o claude CLI enxerga as tools deste servidor. */
|
|
33
39
|
export const MCP_PREFIX = `mcp__${MCP_NAME}__`;
|
|
34
40
|
/** Tool usada como --permission-prompt-tool: encaminha o pedido ao REPL do Brito. */
|
|
35
41
|
export const APPROVE_TOOL = "approve";
|
|
36
42
|
// fs/bash/delegate o próprio claude já tem (Read/Edit/Bash/Task); aqui vai só o que é do Brito.
|
|
37
|
-
|
|
43
|
+
/** Tools que o claude enxerga; `implement` e `spec_save` só com o modo orquestrador ligado (BRITO_ORCH=1). */
|
|
44
|
+
export const exposed = () => allTools().filter((t) => /^(graph_|skill_)/.test(t.name) || t.name === "remember" || t.name === "recall" || t.name === "digest" || (ORCH_ONLY.includes(t.name) && orch.on));
|
|
45
|
+
/** Nome do pedido de aprovação de uma ação do implementador (o terminal mostra só a pergunta). */
|
|
46
|
+
export const IMPL_ASK = "BritoImplementer";
|
|
38
47
|
/** Pergunta ao REPL (socket unix) se o claude pode usar a tool; sem socket, nega. */
|
|
39
48
|
function askRepl(toolName, input) {
|
|
40
49
|
const sock = process.env.BRITO_APPROVE_SOCK;
|
|
@@ -78,7 +87,28 @@ export async function serveMcp(root) {
|
|
|
78
87
|
...(alwaysLoaded(t.name) ? { _meta: { [ALWAYS_LOAD_META]: true } } : {}),
|
|
79
88
|
}))],
|
|
80
89
|
}));
|
|
81
|
-
|
|
90
|
+
/**
|
|
91
|
+
* O claude aprovou a chamada de `implement`, não cada edição dentro dela: as do implementador pedem aprovação ao
|
|
92
|
+
* terminal pelo mesmo socket, conforme o modo. O progresso vai como notificação MCP (o stdout daqui é o protocolo).
|
|
93
|
+
*/
|
|
94
|
+
const runImplement = async (args, extra, token) => {
|
|
95
|
+
let step = 0;
|
|
96
|
+
implementIo.log = (msg) => {
|
|
97
|
+
if (token === undefined) {
|
|
98
|
+
process.stderr.write(msg + "\n");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
extra.sendNotification({ method: "notifications/progress", params: { progressToken: token, progress: ++step, message: msg } }).catch(() => { });
|
|
102
|
+
};
|
|
103
|
+
const warn = (msg) => implementIo.log(msg);
|
|
104
|
+
implementIo.sink = { turnStart() { }, turnEnd() { }, text() { }, textEnd() { }, thinking() { }, toolStart() { }, toolEnd() { }, info: warn, warn, error: warn };
|
|
105
|
+
// Quem chama este servidor é sempre o claude CLI: o principal é um Claude, não o modelo salvo no config.
|
|
106
|
+
const implCtx = { ...ctx, ask: async (q) => (await askRepl(IMPL_ASK, { question: q })).allow, model: () => "claude" };
|
|
107
|
+
const r = await execTool("implement", args, implCtx);
|
|
108
|
+
ctx.dirty = true; // o implementador editou arquivos: a próxima consulta ao grafo reindexa
|
|
109
|
+
return r.isError ? `[ERRO] ${r.text}` : r.text;
|
|
110
|
+
};
|
|
111
|
+
server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
|
|
82
112
|
if (req.params.name === APPROVE_TOOL) {
|
|
83
113
|
const a = (req.params.arguments ?? {});
|
|
84
114
|
const r = await askRepl(String(a.tool_name ?? "?"), a.input ?? {});
|
|
@@ -88,6 +118,8 @@ export async function serveMcp(root) {
|
|
|
88
118
|
if (!exposed().some((t) => t.name === req.params.name)) {
|
|
89
119
|
return { content: [{ type: "text", text: `Tool desconhecida: ${req.params.name}` }], isError: true };
|
|
90
120
|
}
|
|
121
|
+
if (req.params.name === "implement")
|
|
122
|
+
return { content: [{ type: "text", text: await runImplement(req.params.arguments ?? {}, extra, req.params._meta?.progressToken) }] };
|
|
91
123
|
const r = await execTool(req.params.name, req.params.arguments ?? {}, ctx);
|
|
92
124
|
return { content: [{ type: "text", text: r.text }], isError: r.isError };
|
|
93
125
|
});
|
package/dist/model.js
CHANGED
|
@@ -30,6 +30,27 @@ function saveConfig(patch) {
|
|
|
30
30
|
writeFileSync(configPath(), JSON.stringify({ ...cfg, ...patch }, null, 2) + "\n");
|
|
31
31
|
}
|
|
32
32
|
export const saveModel = (model) => saveConfig({ model });
|
|
33
|
+
/** Perfil que implementa as tasks no modo orquestrador (`implModel` no config). */
|
|
34
|
+
export function loadSavedImplModel() {
|
|
35
|
+
try {
|
|
36
|
+
const m = JSON.parse(readFileSync(configPath(), "utf8"))?.implModel;
|
|
37
|
+
return typeof m === "string" && m.trim() ? m.trim() : undefined;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export const saveImplModel = (implModel) => saveConfig({ implModel });
|
|
44
|
+
/** Modo orquestrador ligado ao abrir o Brito (`orchestrator: true` no config, gravado por `/orquestrar on --global`). */
|
|
45
|
+
export function loadSavedOrch() {
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(readFileSync(configPath(), "utf8"))?.orchestrator === true;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export const saveOrch = (orchestrator) => saveConfig({ orchestrator });
|
|
33
54
|
/**
|
|
34
55
|
* Escada de esforço do Hermes (EFFORT_LADDER, do mais fraco ao mais forte). `none` desliga o raciocínio;
|
|
35
56
|
* `minimal` e `ultra` são internos: o claude CLI não os aceita, então `clampEffort` os mapeia.
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { allTools } from "./tools/registry.js";
|
|
2
|
+
import { loadProfiles, loadSavedImplModel, loadSavedOrch } from "./model.js";
|
|
3
|
+
import { missingKeyMessage } from "./provider/openai-compat.js";
|
|
4
|
+
import { resolveModel } from "./provider/resolve.js";
|
|
5
|
+
// Modo orquestrador: o modelo principal (Claude) planeja e revisa; um perfil de endpoint (o qwen) implementa cada task
|
|
6
|
+
// pela tool `implement`. Estado global da sessão, como o `perms`.
|
|
7
|
+
/** Prioridade: BRITO_IMPL_MODEL > config global (`implModel`) > qwen. */
|
|
8
|
+
export const initialImplModel = () => process.env.BRITO_IMPL_MODEL?.trim() || loadSavedImplModel() || "qwen";
|
|
9
|
+
/** No servidor MCP (outro processo) o modo vem ligado pelo env; no REPL e no app começa desligado. */
|
|
10
|
+
export const orch = { on: process.env.BRITO_ORCH === "1", model: initialImplModel() };
|
|
11
|
+
/** Edição de arquivo fica só com o implementador; o principal lê, delega e verifica (bash continua para os testes). */
|
|
12
|
+
export const ORCH_BLOCKED = ["write_file", "edit_file"];
|
|
13
|
+
/** Tools que só existem com o modo ligado: a delegação e a gravação da spec/tasks em spec/. */
|
|
14
|
+
export const ORCH_ONLY = ["implement", "spec_save"];
|
|
15
|
+
/**
|
|
16
|
+
* Tools do agente principal. Desligado: todas menos as do modo (`implement`, `spec_save`). Ligado: sem as de edição. Função: o modo muda no meio
|
|
17
|
+
* da sessão e o loop relê a lista a cada passo.
|
|
18
|
+
*/
|
|
19
|
+
export function mainTools() {
|
|
20
|
+
const hidden = orch.on ? ORCH_BLOCKED : ORCH_ONLY;
|
|
21
|
+
return new Set(allTools().map((t) => t.name).filter((n) => !hidden.includes(n)));
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* O implementador está pronto? Só um perfil de endpoint válido, com a chave no ambiente, e diferente do principal
|
|
25
|
+
* (o qwen orquestrando o qwen não ganha nada). Relê o config a cada chamada, como os perfis.
|
|
26
|
+
*/
|
|
27
|
+
export function implStatus(main, impl, env) {
|
|
28
|
+
const name = impl.trim().toLowerCase();
|
|
29
|
+
if (main.trim().toLowerCase() === name)
|
|
30
|
+
return { ready: false, reason: `o modelo principal já é o ${impl}` };
|
|
31
|
+
const { profiles, errors, rejected } = loadProfiles();
|
|
32
|
+
if (rejected.includes(name)) {
|
|
33
|
+
const why = errors.find((e) => e.toLowerCase().includes(`"${name}"`)) ?? "";
|
|
34
|
+
return { ready: false, reason: `o perfil "${name}" em ~/.brito/config.json está inválido (${why})` };
|
|
35
|
+
}
|
|
36
|
+
const p = profiles[name];
|
|
37
|
+
if (!p || resolveModel(name).kind !== "openai")
|
|
38
|
+
return { ready: false, reason: `sem perfil "${name}" em providers no ~/.brito/config.json` };
|
|
39
|
+
if (!env[p.apiKeyEnv]?.trim())
|
|
40
|
+
return { ready: false, reason: missingKeyMessage(p) };
|
|
41
|
+
return { ready: true, label: p.label };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Desliga o modo quando o implementador deixa de estar pronto para o principal `main` (fallback ou `/model` para o
|
|
45
|
+
* próprio qwen, chave removida…). Nunca em silêncio: o motivo vai para `warn`.
|
|
46
|
+
*/
|
|
47
|
+
export function orchGuard(main, warn) {
|
|
48
|
+
if (!orch.on)
|
|
49
|
+
return;
|
|
50
|
+
const s = implStatus(main, orch.model, process.env);
|
|
51
|
+
if (s.ready)
|
|
52
|
+
return;
|
|
53
|
+
orch.on = false;
|
|
54
|
+
warn(`modo orquestrador desligado: ${s.reason}`);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Ao abrir o REPL ou o app: liga o modo se ele foi salvo com `/orquestrar on --global`. Se o implementador não estiver
|
|
58
|
+
* pronto para o principal `main`, fica desligado e o motivo vai para `warn`. O servidor MCP não usa: lá o modo vem do env.
|
|
59
|
+
*/
|
|
60
|
+
export function restoreOrch(main, warn, info) {
|
|
61
|
+
if (orch.on || !loadSavedOrch())
|
|
62
|
+
return;
|
|
63
|
+
const s = implStatus(main, orch.model, process.env);
|
|
64
|
+
if (!s.ready) {
|
|
65
|
+
warn(`modo orquestrador salvo como ligado, mas não ligou: ${s.reason}`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
orch.on = true;
|
|
69
|
+
info(`modo orquestrador ligado (salvo): ${main} planeja e revisa, ${s.label} implementa`);
|
|
70
|
+
}
|
|
71
|
+
/** O servidor MCP roda em outro processo (subido pelo claude CLI a cada turno): o modo e o perfil chegam pelo env. */
|
|
72
|
+
export const ORCH_ENV = "BRITO_ORCH";
|
|
73
|
+
/** Env do servidor MCP: liga a tool `implement` lá e diz qual perfil implementa. */
|
|
74
|
+
export const orchEnv = () => (orch.on ? { [ORCH_ENV]: "1", BRITO_IMPL_MODEL: orch.model } : {});
|
|
75
|
+
/** Uma task do implementador leva minutos: o timeout padrão do claude CLI para tools MCP cortaria no meio (30 min). */
|
|
76
|
+
export const ORCH_MCP_TIMEOUT = "1800000";
|
|
77
|
+
/** Tools nativas do claude CLI que editam arquivos: bloqueadas com o modo ligado. */
|
|
78
|
+
export const CLAUDE_EDIT_TOOLS = ["Edit", "Write", "MultiEdit", "NotebookEdit"];
|
|
79
|
+
/** Acrescentado ao system prompt do agente principal enquanto o modo está ligado. */
|
|
80
|
+
export const ORCH_NOTE = `
|
|
81
|
+
|
|
82
|
+
# MODO ORQUESTRADOR ATIVO
|
|
83
|
+
Você planeja e revisa; quem implementa é outro modelo, pela tool implement. Você não tem write_file nem edit_file: sua única escrita é spec_save, que grava em spec/<feature>/.
|
|
84
|
+
- Divida o pedido em tasks pequenas e independentes e mande UMA por chamada de implement, em ordem.
|
|
85
|
+
- Antes da primeira chamada de implement, grave com spec_save (feature = slug curto do pedido):
|
|
86
|
+
- spec.md: objetivo, contexto, requisitos, contratos que não podem mudar, fora de escopo e riscos;
|
|
87
|
+
- tasks.md: lista numerada de tasks como checklist (\`- [ ] 1. título\`), cada uma com objetivo, arquivos, critério de pronto e comando de verificação.
|
|
88
|
+
Depois mostre ao usuário o caminho dos dois arquivos e um resumo da lista, e encerre a resposta pedindo aprovação. Só delegue depois que ele aprovar, seguindo o tasks.md aprovado.
|
|
89
|
+
- Se o usuário pedir mudanças, ou se for preciso mudar a lista no meio do caminho (task nova, ordem diferente), atualize spec.md/tasks.md com spec_save e peça aprovação de novo antes de seguir. Não precisa pedir de novo se o usuário acabou de aprovar um plano com essas mesmas tasks (modo plano).
|
|
90
|
+
- Ao mandar uma task ao implement, cite o caminho da spec (spec/<feature>/spec.md) para ele consultar, mas mantenha a task autocontida.
|
|
91
|
+
- Quando uma task passar na sua revisão, marque-a como feita no tasks.md (\`- [x]\`) com spec_save; se ficar pendente, anote o motivo embaixo dela.
|
|
92
|
+
- O implementador não vê esta conversa: cada task precisa trazer objetivo, arquivos e símbolos envolvidos, contratos que não podem mudar, critério de pronto e o comando de verificação (\`verify\`). Passe \`focus\` com os símbolos centrais.
|
|
93
|
+
- O resultado traz o relato do implementador, o git status/diff e a saída da verificação, rodada pela própria tool. Julgue pelo diff e pela verificação, não pelo relato: leia os trechos alterados quando precisar.
|
|
94
|
+
- Se a task ficou errada ou incompleta, chame implement de novo com o que está errado e o que fazer. No máximo 2 reenvios por task; depois disso, explique ao usuário o que falta.
|
|
95
|
+
- Ao final, resuma o que foi feito, o que a verificação provou e o que ficou pendente, e aponte o tasks.md atualizado.`;
|
|
96
|
+
/** Complemento do ORCH_NOTE no claude CLI, onde os nomes das tools são outros. */
|
|
97
|
+
export const ORCH_NOTE_CLAUDE = `
|
|
98
|
+
- Nesta sessão as tools chegam como mcp__brito__implement e mcp__brito__spec_save; Edit, Write, MultiEdit e NotebookEdit estão bloqueadas. Não use Bash para escrever arquivos: isso também é trabalho do implementador.`;
|
|
99
|
+
/** Prompt do subagente que implementa: escopo fechado, verificação obrigatória e uma resposta que dá para conferir. */
|
|
100
|
+
const IMPLEMENTER_PROMPT = `Você implementa UMA task, descrita no pedido. Você não vê a conversa do orquestrador: tudo que precisa está no pedido.
|
|
101
|
+
|
|
102
|
+
# Escopo
|
|
103
|
+
- Faça só o que a task pede. Não refatore, não renomeie e não "melhore" código fora dela.
|
|
104
|
+
- Não invente arquivo, classe, API ou requisito. Se algo do pedido não existir no projeto, pare e diga isso em PENDENTE.
|
|
105
|
+
- Não mude contratos existentes (assinaturas, rotas, formato de resposta) sem que o pedido mande.
|
|
106
|
+
- Não apague nem enfraqueça testes para a verificação passar.
|
|
107
|
+
|
|
108
|
+
# Como trabalhar
|
|
109
|
+
- Comece pelo grafo (graph_context / graph_callers) dos símbolos citados; leia só as linhas apontadas.
|
|
110
|
+
- Antes de criar um arquivo novo, abra um arquivo existente do mesmo tipo e siga a estrutura dele.
|
|
111
|
+
- Comportamento novo ganha teste, no estilo dos testes que já existem.
|
|
112
|
+
|
|
113
|
+
# Verificação
|
|
114
|
+
- Rode o comando de verificação do pedido (ou o de testes do projeto). Pronto é o que ele prova.
|
|
115
|
+
- Se falhar por causa do código: leia só a parte relevante do erro, corrija e rode de novo. No máximo 3 tentativas.
|
|
116
|
+
- Se falhar por ambiente (serviço fora do ar, porta ocupada), não tente consertar: registre e siga.
|
|
117
|
+
|
|
118
|
+
# Resposta final
|
|
119
|
+
Responda só com:
|
|
120
|
+
FEITO: arquivos criados ou alterados, um por linha
|
|
121
|
+
VERIFICAÇÃO: comando rodado e resultado (passou / falhou / não rodou e por quê)
|
|
122
|
+
PENDENTE: o que faltou, ou "nada"`;
|
|
123
|
+
/** Fora do registro de agentes: não aparece no `delegate` nem vira subagente do claude CLI; só a tool `implement` o usa. */
|
|
124
|
+
export const IMPLEMENTER = {
|
|
125
|
+
name: "implementer",
|
|
126
|
+
description: "Implementa uma task bem especificada (edita arquivos e roda a verificação).",
|
|
127
|
+
tools: "all",
|
|
128
|
+
get model() { return orch.model; },
|
|
129
|
+
prompt: IMPLEMENTER_PROMPT,
|
|
130
|
+
source: "builtin",
|
|
131
|
+
};
|
package/dist/permissions.js
CHANGED
|
@@ -19,9 +19,9 @@ export const MODE_LABEL = {
|
|
|
19
19
|
bypass: { icon: "⚡", name: "tudo liberado", hint: "executa tudo sem perguntar" },
|
|
20
20
|
};
|
|
21
21
|
/** Acrescentado ao system prompt do backend de API enquanto o modo plano está ativo. */
|
|
22
|
-
export const PLAN_NOTE = `
|
|
23
|
-
|
|
24
|
-
# MODO PLANO ATIVO
|
|
22
|
+
export const PLAN_NOTE = `
|
|
23
|
+
|
|
24
|
+
# MODO PLANO ATIVO
|
|
25
25
|
Você só pode ler, pesquisar e consultar o grafo. Edições e comandos shell estão bloqueados. Investigue, monte um plano concreto (arquivos, mudanças, riscos, como verificar) e chame exit_plan com ele. Só execute depois que o usuário aprovar.`;
|
|
26
26
|
/** Texto do pedido de permissão para uma tool do claude CLI (Bash, Edit, Write…). Primeira linha = resumo. */
|
|
27
27
|
export function describeClaudeTool(name, input) {
|
|
@@ -34,6 +34,10 @@ export function describeClaudeTool(name, input) {
|
|
|
34
34
|
case "MultiEdit": return `editar ${i.file_path} (${(i.edits ?? []).length} trocas)`;
|
|
35
35
|
case "NotebookEdit": return `editar notebook ${i.notebook_path}`;
|
|
36
36
|
case "ExitPlanMode": return `plano proposto — aprovar e sair do modo plano?\n${String(i.plan ?? "")}`;
|
|
37
|
+
// Ação do implementador do modo orquestrador (servidor MCP): a pergunta já vem montada pelo Brito.
|
|
38
|
+
case "BritoImplementer": return `implementador · ${String(i.question ?? "")}`;
|
|
39
|
+
case "mcp__brito__spec_save": return `gravar spec/${String(i.feature ?? "?")}/${String(i.file ?? "?")} (${String(i.content ?? "").length} chars)`;
|
|
40
|
+
case "mcp__brito__implement": return `delegar ao implementador: ${one(i.task, 300)}${i.verify ? `\n verificação: ${one(i.verify)}` : ""}`;
|
|
37
41
|
default: return `${name}: ${one(JSON.stringify(i), 200)}`;
|
|
38
42
|
}
|
|
39
43
|
}
|
package/dist/repl.js
CHANGED
|
@@ -20,6 +20,7 @@ const userName = () => {
|
|
|
20
20
|
import { listPending, listSkills, listBuiltins, getSkill, suggestSkills, deleteSkill, promoteSkill, globalHome } from "./skills/store.js";
|
|
21
21
|
import { listAgents } from "./agent/agents.js";
|
|
22
22
|
import { perms, cycleMode, MODE_LABEL } from "./permissions.js";
|
|
23
|
+
import { orch } from "./orchestrator.js";
|
|
23
24
|
/**
|
|
24
25
|
* Backend com o motor substituível no meio da sessão (claude CLI → qwen): o REPL monta o menu uma vez, então os
|
|
25
26
|
* comandos ficam estáveis e o que o motor atual não tem responde "indisponível". `usage`/`cost` somam todos os
|
|
@@ -93,7 +94,10 @@ export async function runRepl(b, o) {
|
|
|
93
94
|
...Object.entries(COMMON).map(([name, desc]) => ({ name, desc })),
|
|
94
95
|
].sort((x, y) => x.name.localeCompare(y.name));
|
|
95
96
|
const modeColor = { manual: c.muted, auto: c.ok, plan: c.warn, bypass: c.err };
|
|
96
|
-
const modeChip = () => {
|
|
97
|
+
const modeChip = () => {
|
|
98
|
+
const m = MODE_LABEL[perms.mode];
|
|
99
|
+
return modeColor[perms.mode](`${m.icon} ${m.name}`) + (orch.on ? c.ok(` ⚙ →${orch.model}`) : "");
|
|
100
|
+
};
|
|
97
101
|
const status = () => ui.statusLine({
|
|
98
102
|
project: path.basename(o.root),
|
|
99
103
|
mode: modeChip(),
|