brito-cli 0.0.0-stage → 0.3.2
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 -0
- package/LICENSE +75 -0
- package/README.md +166 -2
- package/README.pt-BR.md +167 -0
- package/dist/agent/agents.js +65 -0
- package/dist/agent/claude-cli.js +216 -0
- package/dist/agent/context.js +40 -0
- package/dist/agent/loop.js +125 -0
- package/dist/agent/prompt.js +54 -0
- package/dist/cli.js +568 -0
- package/dist/graph/aliases.js +196 -0
- package/dist/graph/indexer.js +438 -0
- package/dist/graph/langs.js +526 -0
- package/dist/graph/sfc.js +52 -0
- package/dist/graph/store.js +96 -0
- package/dist/mcp/server.js +98 -0
- package/dist/model.js +136 -0
- package/dist/permissions.js +39 -0
- package/dist/repl.js +297 -0
- package/dist/skills/consolidate.js +76 -0
- package/dist/skills/describe.js +14 -0
- package/dist/skills/journal.js +72 -0
- package/dist/skills/learn-cli.js +55 -0
- package/dist/skills/learn.js +217 -0
- package/dist/skills/store.js +234 -0
- package/dist/tools/bash.js +17 -0
- package/dist/tools/delegate.js +61 -0
- package/dist/tools/fs.js +91 -0
- package/dist/tools/graph.js +117 -0
- package/dist/tools/plan.js +17 -0
- package/dist/tools/registry.js +56 -0
- package/dist/tools/skills.js +58 -0
- package/dist/ui/banner.js +138 -0
- package/dist/ui/dock.js +616 -0
- package/dist/ui/theme.js +63 -0
- package/dist/ui/ui.js +426 -0
- package/dist/ui/web-art.js +131 -0
- package/dist/util/diff.js +36 -0
- package/dist/util/frontmatter.js +16 -0
- package/package.json +66 -4
package/dist/ui/ui.js
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { BOLD, CODE, ITALIC, OFF, c, cols, palette, useColor } from "./theme.js";
|
|
2
|
+
import { renderBanner } from "./banner.js";
|
|
3
|
+
import stringWidth from "string-width";
|
|
4
|
+
export const NAME = "Brito";
|
|
5
|
+
const isTTY = !!process.stdout.isTTY;
|
|
6
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
7
|
+
const FACES = ["(。•́︿•̀。)", "(⊙_⊙)", "(•̀ᴗ•́)و", "(¬‿¬)", "ʕ•ᴥ•ʔ", "(◕‿◕)", "(・・?)", "(`・ω・´)"];
|
|
8
|
+
const VERBS = ["pensando", "ponderando", "investigando", "mapeando o grafo", "conectando os pontos", "analisando", "rastreando chamadas", "ruminando", "fazendo hipóteses"];
|
|
9
|
+
const DONE = ["(•̀ᴗ•́)و", "ヽ(´▽`)/", "(◕‿◕)"];
|
|
10
|
+
const EMOJI = {
|
|
11
|
+
read_file: "📄", write_file: "📝", edit_file: "📝", glob: "📁", grep: "🔍", bash: "💻",
|
|
12
|
+
remember: "🧠", recall: "🧠", delegate: "🤖", skill_load: "🎓", skill_save: "🎓", skill_delete: "🎓",
|
|
13
|
+
};
|
|
14
|
+
const emojiOf = (name) => EMOJI[name] ?? (name.startsWith("graph_") ? "🔗" : "🔧");
|
|
15
|
+
const fmtMs = (ms) => (ms < 1000 ? `${(ms / 1000).toFixed(1)}s` : ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s`);
|
|
16
|
+
/** Duração acumulada: 45.2s · 12m03s · 1h02m. */
|
|
17
|
+
export const fmtDur = (ms) => {
|
|
18
|
+
const s = Math.round(ms / 1000);
|
|
19
|
+
if (ms < 60_000)
|
|
20
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
21
|
+
if (s < 3600)
|
|
22
|
+
return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
|
|
23
|
+
return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, "0")}m`;
|
|
24
|
+
};
|
|
25
|
+
export const fmtTokens = (n) => (n >= 1_000_000 ? `${(n / 1e6).toFixed(1).replace(/\.0$/, "")}M` : n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, "")}K` : String(n));
|
|
26
|
+
/** Primeiro argumento "legível" da chamada, para a linha da tool. */
|
|
27
|
+
function preview(input) {
|
|
28
|
+
if (!input || typeof input !== "object")
|
|
29
|
+
return "";
|
|
30
|
+
const v = Object.values(input).find((x) => typeof x === "string" && x.length > 0);
|
|
31
|
+
return v ? v.replace(/\s+/g, " ").slice(0, 60) : "";
|
|
32
|
+
}
|
|
33
|
+
const skillName = (input) => {
|
|
34
|
+
const n = input?.name;
|
|
35
|
+
return typeof n === "string" ? n : "";
|
|
36
|
+
};
|
|
37
|
+
/** Quebra uma linha por palavra em no máximo `w` colunas (palavra maior que `w` fica inteira). */
|
|
38
|
+
function wrapLine(line, w) {
|
|
39
|
+
if (stringWidth(line) <= w)
|
|
40
|
+
return [line];
|
|
41
|
+
const out = [];
|
|
42
|
+
let cur = "";
|
|
43
|
+
for (const word of line.split(/\s+/).filter(Boolean)) {
|
|
44
|
+
if (cur && stringWidth(cur) + 1 + stringWidth(word) > w) {
|
|
45
|
+
out.push(cur);
|
|
46
|
+
cur = word;
|
|
47
|
+
}
|
|
48
|
+
else
|
|
49
|
+
cur = cur ? `${cur} ${word}` : word;
|
|
50
|
+
}
|
|
51
|
+
if (cur)
|
|
52
|
+
out.push(cur);
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
class UI {
|
|
56
|
+
busy = false;
|
|
57
|
+
paused = false;
|
|
58
|
+
visible = false; // spinner desenhado agora
|
|
59
|
+
timer;
|
|
60
|
+
t0 = 0;
|
|
61
|
+
frame = 0;
|
|
62
|
+
running = new Map();
|
|
63
|
+
textOpen = false;
|
|
64
|
+
lineStart = true;
|
|
65
|
+
seed = Math.floor(Math.random() * FACES.length);
|
|
66
|
+
dock = null;
|
|
67
|
+
/** soma do tempo de todos os turnos da sessão */
|
|
68
|
+
totalMs = 0;
|
|
69
|
+
/** skill carregada no turno atual (aparece na barra de status) */
|
|
70
|
+
activeSkill = "";
|
|
71
|
+
/** `/reasoning show|hide|full|clamp` */
|
|
72
|
+
reasoning = { show: true, full: false };
|
|
73
|
+
/** Com dock, toda saída vai para cima dele e o spinner mora na linha própria do dock. */
|
|
74
|
+
attachDock(d) { this.dock = d; }
|
|
75
|
+
/** Menu com setas + Enter (Esc cancela). Sem dock devolve null. */
|
|
76
|
+
select(title, options, current = 0) {
|
|
77
|
+
return this.dock ? this.dock.select(title, options, current) : Promise.resolve(null);
|
|
78
|
+
}
|
|
79
|
+
get canSelect() { return this.dock !== null; }
|
|
80
|
+
out(s) { if (this.dock)
|
|
81
|
+
this.dock.write(s);
|
|
82
|
+
else
|
|
83
|
+
process.stdout.write(s); }
|
|
84
|
+
// ---------- baixo nível ----------
|
|
85
|
+
clear() {
|
|
86
|
+
if (this.dock) {
|
|
87
|
+
this.dock.setSpinner(null);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (this.visible && isTTY) {
|
|
91
|
+
process.stdout.write("\r\x1b[2K");
|
|
92
|
+
this.visible = false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
spinnerLabel() {
|
|
96
|
+
const secs = (Date.now() - this.t0) / 1000;
|
|
97
|
+
const f = c.light(FRAMES[this.frame % FRAMES.length]);
|
|
98
|
+
if (this.running.size > 0) {
|
|
99
|
+
const names = [...new Set([...this.running.values()].map((r) => (r.name === "skill_load" && skillName(r.input) ? `skill ${skillName(r.input)}` : r.name)))];
|
|
100
|
+
const what = this.running.size === 1 ? names[0] : `${this.running.size} ferramentas`;
|
|
101
|
+
return `${f} ${c.muted(`executando ${what}…`)} ${c.dim(`(${secs.toFixed(1)}s)`)}`;
|
|
102
|
+
}
|
|
103
|
+
const face = FACES[(this.seed + Math.floor(secs / 2)) % FACES.length];
|
|
104
|
+
const verb = VERBS[(this.seed + Math.floor(secs / 3)) % VERBS.length];
|
|
105
|
+
return `${f} ${c.soft(face)} ${c.muted(`${verb}…`)} ${c.dim(`(${secs.toFixed(1)}s)`)}`;
|
|
106
|
+
}
|
|
107
|
+
draw() {
|
|
108
|
+
if (!isTTY)
|
|
109
|
+
return;
|
|
110
|
+
const show = this.busy && !this.paused && !this.textOpen;
|
|
111
|
+
if (this.dock) {
|
|
112
|
+
this.dock.setSpinner(show ? this.spinnerLabel() : null);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (!show)
|
|
116
|
+
return;
|
|
117
|
+
process.stdout.write(`\r\x1b[2K${this.spinnerLabel()}`);
|
|
118
|
+
this.visible = true;
|
|
119
|
+
}
|
|
120
|
+
/** Imprime uma linha sem quebrar o spinner. */
|
|
121
|
+
print(line = "") {
|
|
122
|
+
this.clear();
|
|
123
|
+
this.out(line + "\n");
|
|
124
|
+
this.draw();
|
|
125
|
+
}
|
|
126
|
+
// ---------- ciclo do turno ----------
|
|
127
|
+
turnStart() {
|
|
128
|
+
this.busy = true;
|
|
129
|
+
this.t0 = Date.now();
|
|
130
|
+
this.frame = 0;
|
|
131
|
+
if (isTTY)
|
|
132
|
+
this.timer = setInterval(() => { this.frame++; this.draw(); }, 90);
|
|
133
|
+
this.draw();
|
|
134
|
+
}
|
|
135
|
+
turnEnd(ok = true) {
|
|
136
|
+
this.textEnd();
|
|
137
|
+
this.busy = false;
|
|
138
|
+
this.running.clear();
|
|
139
|
+
this.activeSkill = "";
|
|
140
|
+
if (this.timer)
|
|
141
|
+
clearInterval(this.timer);
|
|
142
|
+
this.clear();
|
|
143
|
+
const ms = this.t0 ? Date.now() - this.t0 : 0;
|
|
144
|
+
this.t0 = 0;
|
|
145
|
+
this.totalMs += ms;
|
|
146
|
+
if (ok && ms)
|
|
147
|
+
this.print(c.dim(`${DONE[this.seed % DONE.length]} pronto! (${(ms / 1000).toFixed(1)}s · total ${fmtDur(this.totalMs)})`));
|
|
148
|
+
}
|
|
149
|
+
/** Libera o terminal para um prompt interativo (permissão). */
|
|
150
|
+
suspend() { this.paused = true; this.clear(); }
|
|
151
|
+
resume() { this.paused = false; this.draw(); }
|
|
152
|
+
// ---------- resposta do modelo ----------
|
|
153
|
+
lineW = 0;
|
|
154
|
+
word = "";
|
|
155
|
+
// markdown em streaming: b=**negrito**, i=*itálico*, k=`código`, fence=bloco ```
|
|
156
|
+
st = { b: false, i: false, k: false, fence: false };
|
|
157
|
+
wordStart = ""; // estilo ativo quando a palavra em andamento começou
|
|
158
|
+
run = { ch: "", n: 0, bol: false }; // sequência pendente de * ou ` (decidida pelo próximo caractere)
|
|
159
|
+
skip = false; // descarta o resto da linha do ``` (nome da linguagem)
|
|
160
|
+
bol = true; // só espaços até agora nesta linha
|
|
161
|
+
prev = " "; // último caractere resolvido
|
|
162
|
+
seq() {
|
|
163
|
+
const { b, i, k, fence } = this.st;
|
|
164
|
+
return k || fence ? CODE : (i ? ITALIC : "") + (b ? BOLD : "");
|
|
165
|
+
}
|
|
166
|
+
active() { const s = this.st; return s.b || s.i || s.k || s.fence; }
|
|
167
|
+
/** Aplica a mudança de estilo: no meio da palavra vira código ANSI; entre palavras vira o estilo inicial da próxima. */
|
|
168
|
+
restyle() {
|
|
169
|
+
if (this.word)
|
|
170
|
+
this.word += OFF + this.seq();
|
|
171
|
+
else
|
|
172
|
+
this.wordStart = this.seq();
|
|
173
|
+
}
|
|
174
|
+
resetStyle() {
|
|
175
|
+
this.st = { b: false, i: false, k: false, fence: false };
|
|
176
|
+
this.run = { ch: "", n: 0, bol: false };
|
|
177
|
+
this.wordStart = "";
|
|
178
|
+
this.skip = false;
|
|
179
|
+
this.bol = true;
|
|
180
|
+
this.prev = " ";
|
|
181
|
+
}
|
|
182
|
+
/** Descarrega a palavra em andamento, quebrando a linha antes dela se não couber. */
|
|
183
|
+
emitWord() {
|
|
184
|
+
if (!this.word)
|
|
185
|
+
return;
|
|
186
|
+
const w = stringWidth(this.word);
|
|
187
|
+
const room = cols() - 2;
|
|
188
|
+
if (this.lineW > 0 && this.lineW + w > room) {
|
|
189
|
+
this.out("\n");
|
|
190
|
+
this.lineStart = true;
|
|
191
|
+
this.lineW = 0;
|
|
192
|
+
}
|
|
193
|
+
if (this.lineStart) {
|
|
194
|
+
this.out(c.accent("│ "));
|
|
195
|
+
this.lineStart = false;
|
|
196
|
+
}
|
|
197
|
+
// o estilo é reaplicado por palavra: a borda e os espaços não herdam a cor
|
|
198
|
+
this.out(this.wordStart + this.word + (this.active() ? OFF : ""));
|
|
199
|
+
this.lineW += w;
|
|
200
|
+
this.word = "";
|
|
201
|
+
this.wordStart = this.seq();
|
|
202
|
+
}
|
|
203
|
+
/** Decide o que fazer com a sequência pendente de `*`/`` ` `` olhando o próximo caractere. */
|
|
204
|
+
resolve(next) {
|
|
205
|
+
const { ch, n, bol } = this.run;
|
|
206
|
+
this.run = { ch: "", n: 0, bol: false };
|
|
207
|
+
const s = this.st;
|
|
208
|
+
const lit = () => { for (let j = 0; j < n; j++)
|
|
209
|
+
this.put(ch); };
|
|
210
|
+
if (ch === "`") {
|
|
211
|
+
if (n >= 3 && bol) {
|
|
212
|
+
s.fence = !s.fence;
|
|
213
|
+
this.restyle();
|
|
214
|
+
this.skip = true;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (n <= 2 && !s.fence) {
|
|
218
|
+
s.k = !s.k;
|
|
219
|
+
this.restyle();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
return lit();
|
|
223
|
+
}
|
|
224
|
+
if (n > 3)
|
|
225
|
+
return lit();
|
|
226
|
+
const keys = n === 1 ? ["i"] : n === 2 ? ["b"] : ["b", "i"];
|
|
227
|
+
const nextWs = /\s/.test(next), prevWs = /\s/.test(this.prev);
|
|
228
|
+
if (keys.every((x) => s[x]) && !prevWs) {
|
|
229
|
+
keys.forEach((x) => (s[x] = false));
|
|
230
|
+
this.restyle();
|
|
231
|
+
}
|
|
232
|
+
else if (keys.every((x) => !s[x]) && !nextWs && !/[\p{L}\p{N}]/u.test(this.prev)) {
|
|
233
|
+
keys.forEach((x) => (s[x] = true));
|
|
234
|
+
this.restyle();
|
|
235
|
+
}
|
|
236
|
+
else
|
|
237
|
+
lit(); // `2 ** 3`, `a*b`, marcador solto: fica literal
|
|
238
|
+
}
|
|
239
|
+
feed(ch) {
|
|
240
|
+
if (ch === this.run.ch) {
|
|
241
|
+
this.run.n++;
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (this.run.n)
|
|
245
|
+
this.resolve(ch);
|
|
246
|
+
if (this.skip) {
|
|
247
|
+
if (ch === "\n")
|
|
248
|
+
this.skip = false;
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const s = this.st;
|
|
252
|
+
const tick = ch === "`" && (!s.fence || this.bol);
|
|
253
|
+
const star = ch === "*" && !s.k && !s.fence;
|
|
254
|
+
if (tick || star) {
|
|
255
|
+
this.run = { ch, n: 1, bol: this.bol };
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
this.put(ch);
|
|
259
|
+
}
|
|
260
|
+
put(ch) {
|
|
261
|
+
const blank = this.prev === "\n";
|
|
262
|
+
this.prev = ch;
|
|
263
|
+
if (ch === "\n") {
|
|
264
|
+
this.emitWord();
|
|
265
|
+
this.bol = true;
|
|
266
|
+
this.st.k = false; // código inline não atravessa linhas
|
|
267
|
+
if (blank && !this.st.fence) {
|
|
268
|
+
this.st.b = false;
|
|
269
|
+
this.st.i = false;
|
|
270
|
+
} // linha em branco encerra ênfase aberta
|
|
271
|
+
this.wordStart = this.seq();
|
|
272
|
+
this.out((this.lineStart ? c.accent("│") : "") + "\n");
|
|
273
|
+
this.lineStart = true;
|
|
274
|
+
this.lineW = 0;
|
|
275
|
+
}
|
|
276
|
+
else if (ch === " ") {
|
|
277
|
+
this.emitWord();
|
|
278
|
+
if (this.lineStart) {
|
|
279
|
+
this.out(c.accent("│ "));
|
|
280
|
+
this.lineStart = false;
|
|
281
|
+
}
|
|
282
|
+
if (this.lineW + 1 > cols() - 2) {
|
|
283
|
+
this.out("\n");
|
|
284
|
+
this.lineStart = true;
|
|
285
|
+
this.lineW = 0;
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
this.out(" ");
|
|
289
|
+
this.lineW++;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
this.word += ch;
|
|
294
|
+
this.bol = false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/** Texto do modelo em streaming, dentro da caixa, com quebra por palavra e borda em todas as linhas. */
|
|
298
|
+
text(chunk) {
|
|
299
|
+
if (!this.textOpen) {
|
|
300
|
+
this.clear();
|
|
301
|
+
const label = ` ${NAME} `;
|
|
302
|
+
this.out(c.accent("╭─") + c.bold(c.light(label)) + c.accent("─".repeat(Math.max(2, cols() - label.length - 2))) + "\n");
|
|
303
|
+
this.textOpen = true;
|
|
304
|
+
this.lineStart = true;
|
|
305
|
+
this.lineW = 0;
|
|
306
|
+
this.word = "";
|
|
307
|
+
this.resetStyle();
|
|
308
|
+
}
|
|
309
|
+
for (const ch of chunk)
|
|
310
|
+
this.feed(ch);
|
|
311
|
+
}
|
|
312
|
+
textEnd() {
|
|
313
|
+
if (!this.textOpen)
|
|
314
|
+
return;
|
|
315
|
+
if (this.run.n)
|
|
316
|
+
this.resolve("\n"); // marcador no fim da resposta: fecha se havia abertura, senão fica literal
|
|
317
|
+
this.emitWord();
|
|
318
|
+
this.resetStyle();
|
|
319
|
+
if (!this.lineStart)
|
|
320
|
+
this.out("\n");
|
|
321
|
+
this.out(c.accent("╰" + "─".repeat(cols() - 1)) + "\n");
|
|
322
|
+
this.textOpen = false;
|
|
323
|
+
this.draw();
|
|
324
|
+
}
|
|
325
|
+
// ---------- ferramentas ----------
|
|
326
|
+
toolStart(id, name, input) {
|
|
327
|
+
this.running.set(id, { name, input, t0: Date.now() });
|
|
328
|
+
if (name === "skill_load" && skillName(input))
|
|
329
|
+
this.activeSkill = skillName(input);
|
|
330
|
+
this.draw();
|
|
331
|
+
}
|
|
332
|
+
toolEnd(id, isError) {
|
|
333
|
+
const r = this.running.get(id);
|
|
334
|
+
if (!r)
|
|
335
|
+
return;
|
|
336
|
+
this.running.delete(id);
|
|
337
|
+
this.textEnd();
|
|
338
|
+
this.print(this.toolLine(r.name, r.input, Date.now() - r.t0, isError));
|
|
339
|
+
}
|
|
340
|
+
/** Só o prefixo + emoji + nome, sem argumento nem tempo. */
|
|
341
|
+
toolLabel(name) { return `${c.accent("┊")} ${emojiOf(name)} ${c.soft(name)}`; }
|
|
342
|
+
toolLine(name, input, ms, isError, indent = "") {
|
|
343
|
+
const arg = preview(input);
|
|
344
|
+
const mark = isError ? c.err("✗") : "";
|
|
345
|
+
return `${indent}${c.accent("┊")} ${emojiOf(name)} ${isError ? c.err(name) : c.soft(name)}${arg ? " " + c.muted(arg) : ""} ${c.dim(`(${fmtMs(ms)})`)} ${mark}`.trimEnd();
|
|
346
|
+
}
|
|
347
|
+
/** Linha de progresso de um subagente (indentada, com o rótulo dele). */
|
|
348
|
+
sub(label, text) { this.textEnd(); this.print(` ${c.accent("┊")} ${c.light(`[${label}]`)} ${text}`); }
|
|
349
|
+
/** Raciocínio do modelo (`/reasoning show`): caixa tênue; recorta em 10 linhas, salvo `/reasoning full`. */
|
|
350
|
+
thinking(text) {
|
|
351
|
+
if (!this.reasoning.show)
|
|
352
|
+
return;
|
|
353
|
+
const body = text.trim();
|
|
354
|
+
if (!body)
|
|
355
|
+
return;
|
|
356
|
+
this.textEnd();
|
|
357
|
+
const w = cols();
|
|
358
|
+
const wrapped = body.split("\n").flatMap((l) => wrapLine(l.trimEnd(), w - 4));
|
|
359
|
+
const MAX = 10;
|
|
360
|
+
const cut = !this.reasoning.full && wrapped.length > MAX;
|
|
361
|
+
const lines = cut ? wrapped.slice(0, MAX) : wrapped;
|
|
362
|
+
const label = " raciocínio ";
|
|
363
|
+
this.print(c.dim(`┌─${label}${"─".repeat(Math.max(0, w - 3 - label.length))}┐`));
|
|
364
|
+
for (const l of lines)
|
|
365
|
+
this.print(c.dim(`│ ${l}`));
|
|
366
|
+
if (cut)
|
|
367
|
+
this.print(c.dim(`│ … +${wrapped.length - MAX} linhas (/reasoning full mostra tudo)`));
|
|
368
|
+
this.print(c.dim(`└${"─".repeat(w - 2)}┘`));
|
|
369
|
+
}
|
|
370
|
+
info(msg) { this.textEnd(); this.print(c.muted(msg)); }
|
|
371
|
+
warn(msg) { this.textEnd(); this.print(c.rgb(palette.warn)(msg)); }
|
|
372
|
+
error(msg) { this.textEnd(); this.print(c.err(msg)); }
|
|
373
|
+
// ---------- estrutura fixa ----------
|
|
374
|
+
rule() { return c.accent("─".repeat(cols())); }
|
|
375
|
+
/**
|
|
376
|
+
* Barra de status: cada indicador tem um rótulo para quem não conhece os números.
|
|
377
|
+
* Se não couber na largura do terminal, descarta primeiro os de menor prioridade (tempo, extra, tokens…).
|
|
378
|
+
*/
|
|
379
|
+
statusLine(s) {
|
|
380
|
+
const pct = Math.min(100, Math.round((s.ctxTokens / s.ctxWindow) * 100));
|
|
381
|
+
const slots = 8, filled = Math.round((pct / 100) * slots);
|
|
382
|
+
const colour = pct >= 95 ? c.err : pct >= 80 ? c.rgb(palette.orange) : pct >= 50 ? c.warn : c.ok;
|
|
383
|
+
const bar = colour("█".repeat(filled)) + c.dim("░".repeat(slots - filled));
|
|
384
|
+
const mins = Math.floor((Date.now() - s.startedAt) / 60_000);
|
|
385
|
+
const active = this.totalMs + (this.busy && this.t0 ? Date.now() - this.t0 : 0);
|
|
386
|
+
const label = (t) => c.dim(t + " ");
|
|
387
|
+
// Taxa de acerto do cache de prompt (quanto mais alta, mais barato): só aparece quando há dado, como no Hermes.
|
|
388
|
+
const hit = s.cacheRead && s.input > 0 ? Math.min(100, (s.cacheRead / s.input) * 100) : undefined;
|
|
389
|
+
const hitColour = hit === undefined ? c.muted : hit >= 70 ? c.ok : hit >= 40 ? c.warn : c.err;
|
|
390
|
+
// [prioridade (maior = descarta primeiro), texto]
|
|
391
|
+
const segs = [
|
|
392
|
+
...(s.mode ? [[0, s.mode]] : []),
|
|
393
|
+
[0, c.light("⬢ ") + c.bold(c.soft(s.model))],
|
|
394
|
+
[0, label("contexto") + c.muted(`${fmtTokens(s.ctxTokens)}/${fmtTokens(s.ctxWindow)}`) + ` [${bar}] ${colour(`${pct}%`)}`],
|
|
395
|
+
...(hit !== undefined ? [[2, label("cache") + hitColour(`◎ ${Math.round(hit)}%`)]] : []),
|
|
396
|
+
[3, label("tokens") + c.muted(`↑${fmtTokens(s.input)} ↓${fmtTokens(s.output)}`)],
|
|
397
|
+
...(s.cost !== undefined ? [[1, label("custo") + c.muted(`$${s.cost.toFixed(2)}`)]] : []),
|
|
398
|
+
...(this.activeSkill ? [[1, c.light("🎓 ") + label("skill") + c.bold(c.soft(this.activeSkill))]] : []),
|
|
399
|
+
[4, label("tempo") + c.muted(mins < 1 ? "<1m" : `${mins}m`) + (active >= 1000 ? c.dim(` (ativo ${fmtDur(active)})`) : "")],
|
|
400
|
+
...(s.extra ? [[5, c.muted(s.extra)]] : []),
|
|
401
|
+
];
|
|
402
|
+
const sep = c.accent(" │ ");
|
|
403
|
+
const render = () => segs.map(([, t]) => t).join(sep);
|
|
404
|
+
while (segs.length > 2 && stringWidth(render()) > cols()) {
|
|
405
|
+
const worst = segs.reduce((w, x, i) => (x[0] > segs[w][0] ? i : w), 0);
|
|
406
|
+
if (segs[worst][0] === 0)
|
|
407
|
+
break;
|
|
408
|
+
segs.splice(worst, 1);
|
|
409
|
+
}
|
|
410
|
+
return render();
|
|
411
|
+
}
|
|
412
|
+
/** Legenda dos indicadores (comando /indicadores). */
|
|
413
|
+
statusLegend() {
|
|
414
|
+
return [
|
|
415
|
+
["contexto", "tokens que o modelo está lendo agora / tamanho máximo da janela. Perto de 100% a conversa precisa ser compactada."],
|
|
416
|
+
["cache", "% do prompt reaproveitado do cache (◎). Alto (verde) = respostas mais rápidas e baratas. Some enquanto não há dado."],
|
|
417
|
+
["tokens", "total da sessão: ↑ enviados ao modelo (inclui cache) · ↓ gerados por ele."],
|
|
418
|
+
["custo", "gasto estimado da sessão em dólares."],
|
|
419
|
+
["tempo", "há quanto tempo a sessão está aberta · (ativo) soma do tempo gasto em todos os turnos."],
|
|
420
|
+
["skill", "skill carregada (skill_load) no turno em andamento."],
|
|
421
|
+
].map(([k, v]) => ` ${c.soft(k.padEnd(9))} ${c.muted(v)}`);
|
|
422
|
+
}
|
|
423
|
+
banner(info) { this.out(renderBanner(info) + "\n"); }
|
|
424
|
+
}
|
|
425
|
+
export const ui = new UI();
|
|
426
|
+
export { c, useColor };
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { RESET, c, mix, palette } from "./theme.js";
|
|
2
|
+
/**
|
|
3
|
+
* A identidade visual do Brito: uma teia (raios + anéis em polígono, com nós nos cruzamentos), meio grafo, com o nome
|
|
4
|
+
* por cima. É gerada em vez de desenhada à mão para servir a qualquer tamanho de janela e os traços ficarem alinhados.
|
|
5
|
+
* Só usa tons derivados do roxo da marca (BRITO_COLOR): claro no centro, escurecendo até a borda.
|
|
6
|
+
*/
|
|
7
|
+
export const LOGO = [
|
|
8
|
+
"████ ████ █████ █████ ███ ",
|
|
9
|
+
"█ █ █ █ █ █ █ █",
|
|
10
|
+
"████ ████ █ █ █ █",
|
|
11
|
+
"█ █ █ █ █ █ █ █",
|
|
12
|
+
"████ █ █ █████ █ ███ ",
|
|
13
|
+
];
|
|
14
|
+
/** Tamanho da teia do banner compacto. */
|
|
15
|
+
export const WEB_W = 33;
|
|
16
|
+
export const WEB_H = 13;
|
|
17
|
+
const blank = () => ({ ch: " ", kind: "empty" });
|
|
18
|
+
const paintOf = (col) => c.rgb(col);
|
|
19
|
+
/** Caractere de um segmento inteiro, pelo ângulo visual (a célula tem ~2x mais altura que largura): evita ─╱╲ misturados. */
|
|
20
|
+
function strand(x0, y0, x1, y1) {
|
|
21
|
+
const dx = Math.abs(x1 - x0), dy = Math.abs(y1 - y0) * 2;
|
|
22
|
+
const angle = (Math.atan2(dy, dx) * 180) / Math.PI;
|
|
23
|
+
if (angle < 25)
|
|
24
|
+
return "─";
|
|
25
|
+
if (angle > 65)
|
|
26
|
+
return "│";
|
|
27
|
+
return (x1 - x0) * (y1 - y0) > 0 ? "╲" : "╱";
|
|
28
|
+
}
|
|
29
|
+
/** Traço de a até b (Bresenham) com um caractere só; só preenche célula vazia, então o que veio antes prevalece. */
|
|
30
|
+
function line(grid, x0, y0, x1, y1, ch, paintAt) {
|
|
31
|
+
const dx = Math.abs(x1 - x0), dy = Math.abs(y1 - y0);
|
|
32
|
+
const sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1;
|
|
33
|
+
let err = dx - dy, x = x0, y = y0;
|
|
34
|
+
while (x !== x1 || y !== y1) {
|
|
35
|
+
const e2 = 2 * err;
|
|
36
|
+
if (e2 > -dy) {
|
|
37
|
+
err -= dy;
|
|
38
|
+
x += sx;
|
|
39
|
+
}
|
|
40
|
+
if (e2 < dx) {
|
|
41
|
+
err += dx;
|
|
42
|
+
y += sy;
|
|
43
|
+
}
|
|
44
|
+
if (grid[y]?.[x]?.kind === "empty")
|
|
45
|
+
grid[y][x] = { ch, kind: "strand", paint: paintAt(x, y) };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** A teia crua de w x h células, centrada: anéis sólidos, raios pontilhados e nós nos cruzamentos. */
|
|
49
|
+
export function webCanvas(w, h, { rings, spokes }) {
|
|
50
|
+
const grid = Array.from({ length: h }, () => Array.from({ length: w }, blank));
|
|
51
|
+
const cx = (w - 1) / 2, cy = (h - 1) / 2;
|
|
52
|
+
const vertex = (k, f) => {
|
|
53
|
+
const a = (2 * Math.PI * k) / spokes;
|
|
54
|
+
return [Math.round(cx + cx * f * Math.cos(a)), Math.round(cy + cy * f * Math.sin(a))];
|
|
55
|
+
};
|
|
56
|
+
/** 0 no centro, 1 na borda. */
|
|
57
|
+
const dist = (x, y) => Math.min(1, Math.hypot((x - cx) / Math.max(1, cx), (y - cy) / Math.max(1, cy)));
|
|
58
|
+
const ringPaint = (x, y) => paintOf(mix(palette.light, palette.accent, Math.pow(dist(x, y), 0.8)));
|
|
59
|
+
const spokePaint = (x, y) => paintOf(mix(palette.soft, palette.accent, 0.35 + 0.65 * dist(x, y)));
|
|
60
|
+
for (let i = 1; i <= rings; i++) {
|
|
61
|
+
const f = i / rings;
|
|
62
|
+
for (let k = 0; k < spokes; k++) {
|
|
63
|
+
const [x0, y0] = vertex(k, f), [x1, y1] = vertex((k + 1) % spokes, f);
|
|
64
|
+
line(grid, x0, y0, x1, y1, strand(x0, y0, x1, y1), ringPaint);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (let k = 0; k < spokes; k++) {
|
|
68
|
+
const [x, y] = vertex(k, 1);
|
|
69
|
+
line(grid, Math.round(cx), Math.round(cy), x, y, "·", spokePaint);
|
|
70
|
+
}
|
|
71
|
+
for (let i = 1; i <= rings; i++) {
|
|
72
|
+
for (let k = 0; k < spokes; k++) {
|
|
73
|
+
const [x, y] = vertex(k, i / rings);
|
|
74
|
+
grid[y][x] = { ch: i === rings ? "◉" : "○", kind: "node", paint: paintOf(mix(palette.soft, palette.accent, 0.15 + 0.7 * dist(x, y))) };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
grid[Math.round(cy)][Math.round(cx)] = { ch: "◉", kind: "node", paint: paintOf(palette.soft) };
|
|
78
|
+
return grid;
|
|
79
|
+
}
|
|
80
|
+
/** O nome por cima da teia. Na faixa das letras os traços somem (só os nós aparecem pelos vãos): o nome fica nítido. */
|
|
81
|
+
export function stampLogo(grid, left, top) {
|
|
82
|
+
LOGO.forEach((l, r) => {
|
|
83
|
+
const row = grid[top + r];
|
|
84
|
+
if (!row)
|
|
85
|
+
return;
|
|
86
|
+
const first = l.search(/\S/), last = l.search(/\s*$/) - 1;
|
|
87
|
+
for (let x = first - 1; x <= last + 1; x++)
|
|
88
|
+
if (row[left + x]?.kind === "strand")
|
|
89
|
+
row[left + x] = blank();
|
|
90
|
+
for (let x = 0; x < l.length; x++) {
|
|
91
|
+
if (l[x] !== " " && row[left + x])
|
|
92
|
+
row[left + x] = { ch: l[x], kind: "text", paint: paintOf(mix(palette.accent, palette.soft, r / (LOGO.length - 1))) };
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/** Uma linha já colorida (ANSI) vira células: o que a cor não alcança fica sem pintura. */
|
|
97
|
+
function ansiCells(s) {
|
|
98
|
+
const out = [];
|
|
99
|
+
let prefix = "";
|
|
100
|
+
for (const part of s.split(/(\x1b\[[0-9;]*m)/)) {
|
|
101
|
+
if (!part)
|
|
102
|
+
continue;
|
|
103
|
+
if (part.startsWith("\x1b[")) {
|
|
104
|
+
prefix = part === RESET ? "" : prefix + part;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const p = prefix;
|
|
108
|
+
for (const ch of part)
|
|
109
|
+
out.push({ ch, kind: "text", paint: p ? (t) => p + t + RESET : undefined });
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
/** Escreve texto sobre a teia; a faixa da linha (mais 1 coluna de cada lado) é limpa para o texto ler bem. */
|
|
114
|
+
export function overlayText(grid, x, y, ansi) {
|
|
115
|
+
const row = grid[y];
|
|
116
|
+
if (!row)
|
|
117
|
+
return;
|
|
118
|
+
const cells = ansiCells(ansi);
|
|
119
|
+
for (let k = -1; k <= cells.length; k++)
|
|
120
|
+
if (row[x + k])
|
|
121
|
+
row[x + k] = blank();
|
|
122
|
+
cells.forEach((cell, k) => { if (row[x + k])
|
|
123
|
+
row[x + k] = cell; });
|
|
124
|
+
}
|
|
125
|
+
export const serialize = (grid) => grid.map((row) => row.map((cell) => (cell.paint ? cell.paint(cell.ch) : cell.ch)).join(""));
|
|
126
|
+
/** O logotipo do banner compacto: BRITO sobre uma teia pequena. */
|
|
127
|
+
export function webLogo() {
|
|
128
|
+
const grid = webCanvas(WEB_W, WEB_H, { rings: 3, spokes: 8 });
|
|
129
|
+
stampLogo(grid, Math.floor((WEB_W - LOGO[0].length) / 2), Math.round((WEB_H - 1) / 2) - Math.floor(LOGO.length / 2));
|
|
130
|
+
return serialize(grid);
|
|
131
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Diff de linhas (LCS) com contexto curto, para mostrar ao usuário o que muda numa skill. */
|
|
2
|
+
export function lineDiff(before, after, context = 2) {
|
|
3
|
+
const a = before.split("\n"), b = after.split("\n");
|
|
4
|
+
const lcs = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
|
|
5
|
+
for (let i = a.length - 1; i >= 0; i--)
|
|
6
|
+
for (let j = b.length - 1; j >= 0; j--)
|
|
7
|
+
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
8
|
+
const ops = [];
|
|
9
|
+
let i = 0, j = 0;
|
|
10
|
+
while (i < a.length && j < b.length) {
|
|
11
|
+
if (a[i] === b[j]) {
|
|
12
|
+
ops.push({ t: " ", s: a[i] });
|
|
13
|
+
i++;
|
|
14
|
+
j++;
|
|
15
|
+
}
|
|
16
|
+
else if (lcs[i + 1][j] >= lcs[i][j + 1])
|
|
17
|
+
ops.push({ t: "-", s: a[i++] });
|
|
18
|
+
else
|
|
19
|
+
ops.push({ t: "+", s: b[j++] });
|
|
20
|
+
}
|
|
21
|
+
while (i < a.length)
|
|
22
|
+
ops.push({ t: "-", s: a[i++] });
|
|
23
|
+
while (j < b.length)
|
|
24
|
+
ops.push({ t: "+", s: b[j++] });
|
|
25
|
+
if (ops.every((o) => o.t === " "))
|
|
26
|
+
return [];
|
|
27
|
+
const keep = ops.map((o, k) => o.t !== " " || ops.slice(Math.max(0, k - context), k + context + 1).some((x) => x.t !== " "));
|
|
28
|
+
const out = [];
|
|
29
|
+
ops.forEach((o, k) => {
|
|
30
|
+
if (keep[k])
|
|
31
|
+
out.push(`${o.t} ${o.s}`);
|
|
32
|
+
else if (out[out.length - 1] !== " …")
|
|
33
|
+
out.push(" …");
|
|
34
|
+
});
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function parseFrontmatter(text) {
|
|
2
|
+
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
3
|
+
if (!m)
|
|
4
|
+
return null;
|
|
5
|
+
const meta = {};
|
|
6
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
7
|
+
const kv = line.match(/^([\w-]+):\s*(.*)$/);
|
|
8
|
+
if (kv)
|
|
9
|
+
meta[kv[1]] = kv[2].trim();
|
|
10
|
+
}
|
|
11
|
+
return { meta, body: m[2] };
|
|
12
|
+
}
|
|
13
|
+
export function stringifyFrontmatter(meta, body) {
|
|
14
|
+
const head = Object.entries(meta).map(([k, v]) => `${k}: ${String(v).replace(/\s*\n\s*/g, " ")}`).join("\n");
|
|
15
|
+
return `---\n${head}\n---\n${body.trim()}\n`;
|
|
16
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,68 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brito-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.2",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"brito": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "tsx src/cli.ts",
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"typecheck": "tsc --noEmit",
|
|
13
|
+
"test": "tsx --test test/*.test.ts",
|
|
14
|
+
"eval:learn": "tsx test/eval-learn.ts",
|
|
15
|
+
"prepare": "tsc",
|
|
16
|
+
"prepublishOnly": "npm run typecheck && npm test"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@anthropic-ai/sdk": "^0.131.0",
|
|
20
|
+
"@modelcontextprotocol/sdk": "^1.32.0",
|
|
21
|
+
"commander": "latest",
|
|
22
|
+
"fast-glob": "latest",
|
|
23
|
+
"kuzu": "latest",
|
|
24
|
+
"string-width": "^8.3.0",
|
|
25
|
+
"tree-sitter-wasms": "latest",
|
|
26
|
+
"web-tree-sitter": "0.24.7",
|
|
27
|
+
"zod": "latest"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "latest",
|
|
31
|
+
"tsx": "latest",
|
|
32
|
+
"typescript": "latest"
|
|
33
|
+
},
|
|
34
|
+
"license": "PolyForm-Noncommercial-1.0.0",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/wilsonbrito/brito.git"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/wilsonbrito/brito#readme",
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/wilsonbrito/brito/issues"
|
|
42
|
+
},
|
|
43
|
+
"author": "Wilson Brito",
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=20"
|
|
46
|
+
},
|
|
47
|
+
"files": [
|
|
48
|
+
"dist",
|
|
49
|
+
".brito/agents",
|
|
50
|
+
"README.md",
|
|
51
|
+
"README.pt-BR.md",
|
|
52
|
+
"LICENSE"
|
|
53
|
+
],
|
|
54
|
+
"keywords": [
|
|
55
|
+
"cli",
|
|
56
|
+
"agent",
|
|
57
|
+
"coding-agent",
|
|
58
|
+
"llm",
|
|
59
|
+
"knowledge-graph",
|
|
60
|
+
"code-graph",
|
|
61
|
+
"mcp",
|
|
62
|
+
"claude",
|
|
63
|
+
"tree-sitter",
|
|
64
|
+
"svelte",
|
|
65
|
+
"typescript",
|
|
66
|
+
"python"
|
|
67
|
+
]
|
|
68
|
+
}
|