brito-cli 0.8.0 → 0.10.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 +321 -298
- package/README.pt-BR.md +323 -301
- package/dist/agent/claude-cli.js +9 -3
- package/dist/agent/context.js +17 -10
- package/dist/agent/loop.js +8 -2
- package/dist/agent/prompt.js +25 -25
- package/dist/cli.js +148 -7
- package/dist/desktop/panels.js +93 -0
- package/dist/desktop/protocol.js +5 -0
- package/dist/desktop/serve.js +162 -14
- package/dist/graph/migrate.js +6 -6
- package/dist/import/claude-code.js +286 -0
- package/dist/local/compact.js +66 -0
- package/dist/local/slm.js +5 -1
- package/dist/local/stats.js +17 -0
- 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/provider/resolve.js +3 -1
- package/dist/repl.js +5 -1
- package/dist/skills/builtin.js +63 -63
- package/dist/skills/by.js +7 -0
- package/dist/skills/consolidate.js +7 -7
- package/dist/skills/learn.js +26 -24
- package/dist/skills/store.js +21 -2
- package/dist/skills/trial.js +32 -3
- package/dist/tools/delegate.js +30 -22
- package/dist/tools/implement.js +50 -0
- package/dist/tools/spec.js +47 -0
- package/dist/usage/command.js +20 -0
- package/dist/usage/plan.js +111 -0
- package/package.json +81 -80
package/dist/desktop/serve.js
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
|
+
import { fetchPlanUsage } from "../usage/plan.js";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import readline from "node:readline";
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { PROTOCOL } from "./protocol.js";
|
|
8
|
+
import { Panels, graphRootFor } from "./panels.js";
|
|
5
9
|
import { randomUUID } from "node:crypto";
|
|
6
10
|
import "../tools/fs.js";
|
|
7
11
|
import "../tools/bash.js";
|
|
8
12
|
import "../tools/graph.js";
|
|
9
13
|
import "../tools/delegate.js";
|
|
10
14
|
import "../tools/plan.js";
|
|
15
|
+
import "../tools/implement.js";
|
|
16
|
+
import "../tools/spec.js";
|
|
11
17
|
import "../tools/skills.js";
|
|
12
18
|
import "../tools/digest.js";
|
|
13
19
|
import "../tools/todo.js";
|
|
@@ -21,13 +27,14 @@ import { openGraphAfterClaude } from "../graph/store.js";
|
|
|
21
27
|
import { indexProject } from "../graph/indexer.js";
|
|
22
28
|
import { initialEffort, initialModel, isEffort, loadProfiles, MODEL_ALIASES } from "../model.js";
|
|
23
29
|
import { NO_ANTHROPIC, fallbackStatus, openEngine } from "../engine.js";
|
|
24
|
-
import { activateFallback, chooseModel, cliShouldFallBack, fallbackWarning, newFallbackState, renderFallback, retryPrompt } from "../provider/fallback.js";
|
|
30
|
+
import { engineChip, activateFallback, chooseModel, cliShouldFallBack, fallbackWarning, newFallbackState, renderFallback, retryPrompt } from "../provider/fallback.js";
|
|
25
31
|
import { contextWindowFor, resolveModel } from "../provider/resolve.js";
|
|
26
32
|
import { PLAN_NOTE, perms } from "../permissions.js";
|
|
27
|
-
import {
|
|
33
|
+
import { ORCH_NOTE, implStatus, mainTools, orch, orchGuard, restoreOrch } from "../orchestrator.js";
|
|
34
|
+
import { getSkill, listBuiltins, listSkills, skillsIndex } from "../skills/store.js";
|
|
28
35
|
/**
|
|
29
36
|
* 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"}
|
|
37
|
+
* 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
38
|
* stdout → linhas "@@{json}" (qualquer outra linha, como logs perdidos, a janela ignora).
|
|
32
39
|
* Motor: perfil de endpoint (ex.: Qwen) ou ANTHROPIC_API_KEY usam o loop próprio (como o `brito` no terminal); sem
|
|
33
40
|
* nada disso, o `claude` CLI (assinatura). Se o Claude cai por conta/capacidade (ou nem há conta), segue no qwen.
|
|
@@ -35,6 +42,66 @@ import { skillsIndex } from "../skills/store.js";
|
|
|
35
42
|
*/
|
|
36
43
|
const MARK = "@@";
|
|
37
44
|
const emit = (ev) => process.stdout.write(MARK + JSON.stringify(ev) + "\n");
|
|
45
|
+
/** Selo do motor (☁ nuvem / 🌐 endpoint / 💻 local, ou ⚠ FALLBACK), igual ao da barra de status da CLI. */
|
|
46
|
+
const chipOf = (model, fb) => { try {
|
|
47
|
+
return engineChip(model, fb).text;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return model;
|
|
51
|
+
} };
|
|
52
|
+
const BUILTIN_COMMANDS = [
|
|
53
|
+
{ name: "clear", description: "limpa a conversa", kind: "builtin" },
|
|
54
|
+
{ name: "fallback", description: "estado do fallback Claude → modelo local", kind: "builtin" },
|
|
55
|
+
{ name: "help", description: "lista os comandos", kind: "builtin" },
|
|
56
|
+
];
|
|
57
|
+
/** Arquivos do projeto para o `@`: git ls-files (respeita .gitignore); sem git, uma varredura rasa. */
|
|
58
|
+
let fileCache = null;
|
|
59
|
+
function projectFiles(root) {
|
|
60
|
+
if (fileCache && fileCache.root === root && Date.now() - fileCache.at < 15000)
|
|
61
|
+
return fileCache.files;
|
|
62
|
+
let files = [];
|
|
63
|
+
try {
|
|
64
|
+
files = execFileSync("git", ["ls-files", "--cached", "--others", "--exclude-standard"], { cwd: root, encoding: "utf8", maxBuffer: 16 << 20, timeout: 5000 }).split("\n").filter(Boolean);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
const walk = (dir, depth) => {
|
|
68
|
+
if (depth > 4 || files.length > 5000)
|
|
69
|
+
return;
|
|
70
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
71
|
+
if (e.name.startsWith(".") || e.name === "node_modules")
|
|
72
|
+
continue;
|
|
73
|
+
const rel = path.relative(root, path.join(dir, e.name)).split(path.sep).join("/");
|
|
74
|
+
e.isDirectory() ? walk(path.join(dir, e.name), depth + 1) : files.push(rel);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
try {
|
|
78
|
+
walk(root, 0);
|
|
79
|
+
}
|
|
80
|
+
catch { /* pasta ilegível */ }
|
|
81
|
+
}
|
|
82
|
+
fileCache = { root, at: Date.now(), files };
|
|
83
|
+
return files;
|
|
84
|
+
}
|
|
85
|
+
/** "Permitir sempre neste projeto": tipos de ação liberados, guardados por pasta em ~/.brito/desktop-allow.json. */
|
|
86
|
+
const allowFile = () => path.join(os.homedir(), ".brito", "desktop-allow.json");
|
|
87
|
+
function loadAllow(root) {
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(fs.readFileSync(allowFile(), "utf8"))[path.resolve(root)] ?? [];
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function saveAllow(root, kinds) {
|
|
96
|
+
let all = {};
|
|
97
|
+
try {
|
|
98
|
+
all = JSON.parse(fs.readFileSync(allowFile(), "utf8"));
|
|
99
|
+
}
|
|
100
|
+
catch { /* primeiro uso */ }
|
|
101
|
+
all[path.resolve(root)] = kinds;
|
|
102
|
+
fs.mkdirSync(path.dirname(allowFile()), { recursive: true });
|
|
103
|
+
fs.writeFileSync(allowFile(), JSON.stringify(all, null, 1));
|
|
104
|
+
}
|
|
38
105
|
const sink = {
|
|
39
106
|
turnStart: () => emit({ t: "turnStart" }),
|
|
40
107
|
turnEnd: (ok = true) => emit({ t: "turnEnd", ok }),
|
|
@@ -42,7 +109,7 @@ const sink = {
|
|
|
42
109
|
textEnd: () => emit({ t: "textEnd" }),
|
|
43
110
|
thinking: (text) => emit({ t: "thinking", text }),
|
|
44
111
|
toolStart: (id, name, input) => emit({ t: "toolStart", id, name, input }),
|
|
45
|
-
toolEnd: (id, isError, output) => emit({ t: "toolEnd", id, isError, output: (output ?? "").slice(0,
|
|
112
|
+
toolEnd: (id, isError, output) => emit({ t: "toolEnd", id, isError, output: (output ?? "").slice(0, 20000) }),
|
|
46
113
|
info: (msg) => emit({ t: "info", msg }),
|
|
47
114
|
warn: (msg) => emit({ t: "warn", msg }),
|
|
48
115
|
error: (msg) => emit({ t: "error", msg }),
|
|
@@ -50,10 +117,15 @@ const sink = {
|
|
|
50
117
|
const apiLabel = (m) => (resolveModel(m).kind === "openai" ? "endpoint" : "API Anthropic");
|
|
51
118
|
export async function serveDesktop(root, resumeId) {
|
|
52
119
|
const pending = new Map();
|
|
53
|
-
|
|
120
|
+
/** Tipos de pedido liberados com "permitir nesta sessão" (nunca vale para pedidos `force`, como aprovar plano ou skill). */
|
|
121
|
+
const projectAllow = new Set(loadAllow(root));
|
|
122
|
+
const sessionAllow = new Set(projectAllow);
|
|
123
|
+
emit({ t: "hello", protocol: PROTOCOL });
|
|
124
|
+
const kindOf = (text) => text.split(/[:\s]/, 1)[0].toLowerCase();
|
|
125
|
+
const ask = (text, opts) => (perms.mode === "bypass" || sessionAllow.has(kindOf(text))) && !opts?.force ? Promise.resolve(true) : new Promise((resolve) => {
|
|
54
126
|
const id = randomUUID();
|
|
55
|
-
pending.set(id, resolve);
|
|
56
|
-
emit({ t: "approval", id, text, force: !!opts?.force });
|
|
127
|
+
pending.set(id, { resolve, kind: opts?.force ? "" : kindOf(text) });
|
|
128
|
+
emit({ t: "approval", id, text, force: !!opts?.force, kind: kindOf(text) });
|
|
57
129
|
});
|
|
58
130
|
// Na pasta pessoal (projeto "Home") indexar tudo seria enorme e inútil: sem índice ali.
|
|
59
131
|
const isHome = path.resolve(root) === path.resolve(os.homedir());
|
|
@@ -92,8 +164,8 @@ export async function serveDesktop(root, resumeId) {
|
|
|
92
164
|
catch { /* sem notas */ }
|
|
93
165
|
const ctx = { root, graph, ask, sync, dirty: false, loadedSkills: o.loadedSkills ?? new Set(), model: () => agent.model };
|
|
94
166
|
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 });
|
|
167
|
+
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 : ""));
|
|
168
|
+
const agent = new Agent(system, ctx, { model, sink, fallback, tools: mainTools });
|
|
97
169
|
if (o.messages)
|
|
98
170
|
agent.messages = o.messages;
|
|
99
171
|
if (o.effort)
|
|
@@ -144,6 +216,7 @@ export async function serveDesktop(root, resumeId) {
|
|
|
144
216
|
async send(text) {
|
|
145
217
|
ac = new AbortController();
|
|
146
218
|
sink.turnStart();
|
|
219
|
+
orchGuard(agent.model, sink.warn); // troca para o qwen (fallback ou modelo escolhido): não há mais a quem delegar
|
|
147
220
|
let ok = true;
|
|
148
221
|
try {
|
|
149
222
|
await agent.send(text, ac.signal);
|
|
@@ -264,10 +337,13 @@ export async function serveDesktop(root, resumeId) {
|
|
|
264
337
|
engine = claude;
|
|
265
338
|
}
|
|
266
339
|
let busy = false;
|
|
267
|
-
|
|
340
|
+
restoreOrch(engine.model, (m) => emit({ t: "warn", msg: m }), (m) => emit({ t: "info", msg: m }));
|
|
341
|
+
const panels = new Panels(root, graphRootFor(root), isHome);
|
|
342
|
+
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
343
|
ready();
|
|
269
344
|
const rl = readline.createInterface({ input: process.stdin });
|
|
270
345
|
const quit = async () => { closeApproval(); try {
|
|
346
|
+
await panels.close();
|
|
271
347
|
await engine.close();
|
|
272
348
|
}
|
|
273
349
|
catch { /* saindo */ } process.exit(0); };
|
|
@@ -294,23 +370,85 @@ export async function serveDesktop(root, resumeId) {
|
|
|
294
370
|
sink.turnEnd(true);
|
|
295
371
|
return;
|
|
296
372
|
}
|
|
373
|
+
// /nome de uma skill: mesma invocação do REPL (skill_load + contexto).
|
|
374
|
+
let text = String(m.text ?? "");
|
|
375
|
+
const [cmd, ...args] = text.trim().split(/\s+/);
|
|
376
|
+
const sk = cmd.startsWith("/") && !cmd.slice(1).includes("/") ? getSkill(root, cmd.slice(1).toLowerCase()) : null;
|
|
377
|
+
if (sk) {
|
|
378
|
+
const ctxText = args.join(" ");
|
|
379
|
+
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.)"}`;
|
|
380
|
+
}
|
|
297
381
|
busy = true;
|
|
298
382
|
try {
|
|
299
|
-
await engine.send(
|
|
383
|
+
await engine.send(text);
|
|
300
384
|
}
|
|
301
385
|
finally {
|
|
302
386
|
busy = false;
|
|
303
|
-
emit({ t: "usage", ...engine.stats(), model: engine.model, sessionId: engine.sessionId, fallback: engine.fallback.active });
|
|
387
|
+
emit({ t: "usage", ...engine.stats(), model: engine.model, sessionId: engine.sessionId, fallback: engine.fallback.active, chip: chipOf(engine.model, engine.fallback) });
|
|
304
388
|
}
|
|
305
389
|
break;
|
|
306
390
|
}
|
|
307
391
|
case "abort":
|
|
308
392
|
engine.abort();
|
|
309
393
|
break;
|
|
310
|
-
case "
|
|
311
|
-
|
|
394
|
+
case "query": {
|
|
395
|
+
let data = [];
|
|
396
|
+
try {
|
|
397
|
+
const arg = String(m.arg ?? "").toLowerCase();
|
|
398
|
+
if (["skills", "skill", "agents", "symbols", "symbol", "notes"].includes(m.q)) {
|
|
399
|
+
const raw = String(m.arg ?? "");
|
|
400
|
+
data = m.q === "skills" ? panels.skills() : m.q === "skill" ? panels.skill(raw) : m.q === "agents" ? panels.agents()
|
|
401
|
+
: m.q === "symbols" ? await panels.symbols(raw) : m.q === "symbol" ? await panels.symbol(raw) : await panels.notes(raw);
|
|
402
|
+
emit({ t: "result", id: m.id, data });
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
if (m.q === "files")
|
|
406
|
+
data = projectFiles(root).filter((f) => !f.startsWith(".brito/") && (!arg || f.toLowerCase().includes(arg))).sort((a, b) => a.length - b.length).slice(0, 30);
|
|
407
|
+
else if (m.q === "commands") {
|
|
408
|
+
const skills = [...listBuiltins(root), ...listSkills(root).filter((s) => s.invoke === "manual")].map((s) => ({ name: s.name, description: s.description, kind: "skill" }));
|
|
409
|
+
data = [...BUILTIN_COMMANDS, ...skills].filter((c) => !arg || c.name.includes(arg));
|
|
410
|
+
}
|
|
411
|
+
else if (m.q === "usage")
|
|
412
|
+
data = { plan: await fetchPlanUsage({ force: arg === "force" }), session: engine.stats(), model: engine.model };
|
|
413
|
+
else if (m.q === "models")
|
|
414
|
+
data = { aliases: Object.keys(MODEL_ALIASES), profiles: Object.keys(loadProfiles().profiles) };
|
|
415
|
+
}
|
|
416
|
+
catch (e) {
|
|
417
|
+
sink.warn(`consulta ${m.q} falhou: ${e?.message ?? e}`);
|
|
418
|
+
}
|
|
419
|
+
emit({ t: "result", id: m.id, data });
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
case "action": {
|
|
423
|
+
try {
|
|
424
|
+
const arg = String(m.arg ?? "");
|
|
425
|
+
const msg = m.op === "graph.reindex" ? `grafo reindexado (${(await panels.reindex()).symbols} símbolos)`
|
|
426
|
+
: m.op === "note.delete" ? await panels.deleteNote(arg)
|
|
427
|
+
: panels.skillAction(String(m.op).replace(/^skill\./, ""), arg);
|
|
428
|
+
emit({ t: "result", id: m.id, data: { ok: true, msg } });
|
|
429
|
+
}
|
|
430
|
+
catch (e) {
|
|
431
|
+
emit({ t: "result", id: m.id, data: { ok: false, msg: e?.message ?? String(e) } });
|
|
432
|
+
}
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
case "approve": {
|
|
436
|
+
const p = pending.get(m.id);
|
|
437
|
+
if (p && m.allow && (m.scope === "session" || m.scope === "project") && p.kind) {
|
|
438
|
+
sessionAllow.add(p.kind);
|
|
439
|
+
if (m.scope === "project")
|
|
440
|
+
try {
|
|
441
|
+
projectAllow.add(p.kind);
|
|
442
|
+
saveAllow(root, [...projectAllow]);
|
|
443
|
+
}
|
|
444
|
+
catch (e) {
|
|
445
|
+
sink.warn(`não consegui salvar a permissão: ${e?.message ?? e}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
p?.resolve(!!m.allow);
|
|
312
449
|
pending.delete(m.id);
|
|
313
450
|
break;
|
|
451
|
+
}
|
|
314
452
|
case "config": {
|
|
315
453
|
if (typeof m.model === "string")
|
|
316
454
|
engine.setModel(m.model ? MODEL_ALIASES[m.model] ?? m.model : ""); // vazio = modelo/perfil salvo
|
|
@@ -318,6 +456,16 @@ export async function serveDesktop(root, resumeId) {
|
|
|
318
456
|
engine.effort = isEffort(m.effort) ? m.effort : "";
|
|
319
457
|
if (["manual", "auto", "plan", "bypass"].includes(m.mode))
|
|
320
458
|
perms.mode = m.mode;
|
|
459
|
+
// "on" liga o modo orquestrador (se o implementador estiver pronto); "" desliga.
|
|
460
|
+
if (m.orch === "")
|
|
461
|
+
orch.on = false;
|
|
462
|
+
else if (m.orch === "on") {
|
|
463
|
+
const s = implStatus(engine.model, orch.model, process.env);
|
|
464
|
+
if (s.ready)
|
|
465
|
+
orch.on = true;
|
|
466
|
+
else
|
|
467
|
+
sink.warn(`modo orquestrador não ligou: ${s.reason}`);
|
|
468
|
+
}
|
|
321
469
|
ready();
|
|
322
470
|
break;
|
|
323
471
|
}
|
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. */
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { parseRule, addRule, loadRules } from "../rules.js";
|
|
6
|
+
import { NAME_RE, deleteSkill, getSkillIn, lintSkill, listSkills, savePending, validate, writeSkill } from "../skills/store.js";
|
|
7
|
+
import { stringifyFrontmatter } from "../util/frontmatter.js";
|
|
8
|
+
/**
|
|
9
|
+
* Import do Claude Code: skills, subagentes, regras de permissão e memória. Sempre em duas etapas — `planImport` só lê
|
|
10
|
+
* e decide (nada é gravado), `applyImport` grava o que o plano marcou como `import`.
|
|
11
|
+
*
|
|
12
|
+
* Princípios: nada importado vira ativo sem teste (skills entram "em teste"); credenciais nunca são lidas; `deny` entra
|
|
13
|
+
* direto (só restringe) mas `allow` exige pedido explícito; tudo importado é reversível (`undoImport`).
|
|
14
|
+
*/
|
|
15
|
+
export const IMPORT_TAG = "import:claude-code";
|
|
16
|
+
export const claudeHome = () => process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
17
|
+
/** Frontmatter YAML simples, aceitando valores em várias linhas (`>`, `|` ou continuação indentada), como nas skills do Claude. */
|
|
18
|
+
export function parseRichFrontmatter(text) {
|
|
19
|
+
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
20
|
+
if (!m)
|
|
21
|
+
return null;
|
|
22
|
+
const meta = {};
|
|
23
|
+
let key = "";
|
|
24
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
25
|
+
const kv = /^([\w-]+):\s*(.*)$/.exec(line);
|
|
26
|
+
if (kv) {
|
|
27
|
+
key = kv[1];
|
|
28
|
+
meta[key] = /^[>|][+-]?$/.test(kv[2].trim()) ? "" : kv[2].trim().replace(/^(['"])(.*)\1$/, "$2");
|
|
29
|
+
}
|
|
30
|
+
else if (key && /^\s+\S/.test(line))
|
|
31
|
+
meta[key] = `${meta[key]} ${line.trim()}`.trim();
|
|
32
|
+
}
|
|
33
|
+
return { meta, body: m[2] };
|
|
34
|
+
}
|
|
35
|
+
const readText = (file) => { try {
|
|
36
|
+
return fs.readFileSync(file, "utf8");
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
} };
|
|
41
|
+
const dirsIn = (dir) => { try {
|
|
42
|
+
return fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith(".")).map((d) => d.name);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return [];
|
|
46
|
+
} };
|
|
47
|
+
const mdIn = (dir) => { try {
|
|
48
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith(".md"));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return [];
|
|
52
|
+
} };
|
|
53
|
+
/** Descrição do Claude pode passar de 200 caracteres: corta numa fronteira de frase/palavra. */
|
|
54
|
+
function fitDescription(d) {
|
|
55
|
+
const t = d.replace(/\s+/g, " ").trim();
|
|
56
|
+
if (t.length <= 200)
|
|
57
|
+
return t;
|
|
58
|
+
const cut = t.slice(0, 197);
|
|
59
|
+
return cut.slice(0, Math.max(cut.lastIndexOf(" "), 100)) + "…";
|
|
60
|
+
}
|
|
61
|
+
function planSkills(root, home) {
|
|
62
|
+
const out = [];
|
|
63
|
+
const sources = [
|
|
64
|
+
{ dir: path.join(root, ".claude", "skills"), scope: "project", label: ".claude/skills" },
|
|
65
|
+
{ dir: path.join(home, "skills"), scope: "global", label: "~/.claude/skills" },
|
|
66
|
+
];
|
|
67
|
+
for (const { dir, scope, label } of sources) {
|
|
68
|
+
for (const folder of dirsIn(dir)) {
|
|
69
|
+
const source = `${label}/${folder}`;
|
|
70
|
+
const text = readText(path.join(dir, folder, "SKILL.md"));
|
|
71
|
+
const fm = text ? parseRichFrontmatter(text) : null;
|
|
72
|
+
const name = (fm?.meta.name || folder).trim();
|
|
73
|
+
const skip = (note) => ({ kind: "skill", name, source, action: "skip", note, scope });
|
|
74
|
+
if (!fm) {
|
|
75
|
+
out.push(skip("SKILL.md ausente ou sem frontmatter"));
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (!NAME_RE.test(name)) {
|
|
79
|
+
out.push(skip(`nome "${name}" fora do padrão kebab-case de 2 a 49 caracteres`));
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const description = fitDescription(fm.meta.description ?? "");
|
|
83
|
+
const body = fm.body.trim();
|
|
84
|
+
const input = { name, description, body, invoke: fm.meta["disable-model-invocation"] === "true" ? "manual" : "auto" };
|
|
85
|
+
try {
|
|
86
|
+
validate(input);
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
out.push(skip(String(e?.message ?? e)));
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (getSkillIn(root, name, scope)) {
|
|
93
|
+
out.push(skip("já existe uma skill com esse nome"));
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const extras = dirsIn(path.join(dir, folder)).concat(fs.readdirSync(path.join(dir, folder)).filter((f) => f !== "SKILL.md" && !fs.statSync(path.join(dir, folder, f)).isDirectory()));
|
|
97
|
+
const warnings = lintSkill(input).warnings.filter((w) => w.startsWith("comando destrutivo"));
|
|
98
|
+
const notes = [extras.length ? `arquivos de apoio não copiados: ${extras.join(", ")}` : "", fm.meta["allowed-tools"] ? "allowed-tools do Claude é ignorado" : "", warnings.length ? "tem comando destrutivo: vai para /skills pending" : ""].filter(Boolean);
|
|
99
|
+
out.push({ kind: "skill", name, source, action: "import", scope, pending: warnings.length > 0, note: notes.join("; ") || undefined, data: input });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
/** Nomes de tool do Claude → do Brito. */
|
|
105
|
+
const TOOL_MAP = { read: "read_file", write: "write_file", edit: "edit_file", multiedit: "edit_file", bash: "bash", glob: "glob", grep: "grep", notebookedit: "edit_file" };
|
|
106
|
+
const mapTool = (t) => TOOL_MAP[t.trim().toLowerCase()];
|
|
107
|
+
const READ_ONLY = ["read_file", "glob", "grep", "graph_search", "graph_callers", "graph_callees", "graph_impact", "graph_context", "graph_query", "recall"];
|
|
108
|
+
function planAgents(root, home) {
|
|
109
|
+
const out = [];
|
|
110
|
+
for (const { dir, label } of [{ dir: path.join(root, ".claude", "agents"), label: ".claude/agents" }, { dir: path.join(home, "agents"), label: "~/.claude/agents" }]) {
|
|
111
|
+
for (const f of mdIn(dir)) {
|
|
112
|
+
const source = `${label}/${f}`;
|
|
113
|
+
const fm = parseRichFrontmatter(readText(path.join(dir, f)) ?? "");
|
|
114
|
+
const name = (fm?.meta.name || f.replace(/\.md$/, "")).trim();
|
|
115
|
+
const skip = (note) => ({ kind: "agent", name, source, action: "skip", note });
|
|
116
|
+
if (!fm || !fm.meta.description || !fm.body.trim()) {
|
|
117
|
+
out.push(skip("precisa de frontmatter com description e de um corpo"));
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!NAME_RE.test(name)) {
|
|
121
|
+
out.push(skip(`nome "${name}" fora do padrão kebab-case`));
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (fs.existsSync(path.join(root, ".brito", "agents", `${name}.md`))) {
|
|
125
|
+
out.push(skip("já existe um agente com esse nome em .brito/agents"));
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const raw = (fm.meta.tools ?? "").split(",").map((t) => t.trim()).filter(Boolean);
|
|
129
|
+
const mapped = [...new Set(raw.map(mapTool).filter((t) => !!t))];
|
|
130
|
+
const dropped = raw.filter((t) => !mapTool(t));
|
|
131
|
+
const tools = !raw.length ? "all" : mapped.length ? mapped : READ_ONLY;
|
|
132
|
+
const model = /^claude-/.test(fm.meta.model ?? "") ? fm.meta.model : "";
|
|
133
|
+
const notes = [dropped.length ? `tools sem equivalente: ${dropped.join(", ")}` : "", raw.length && !mapped.length ? "nenhuma tool equivalente: ficou somente leitura" : "", fm.meta.model && !model ? `modelo "${fm.meta.model}" não mapeado: usa o padrão do Brito` : ""].filter(Boolean);
|
|
134
|
+
out.push({ kind: "agent", name, source, action: "import", note: notes.join("; ") || undefined, data: { description: fitDescription(fm.meta.description).replace(/\s+/g, " "), tools, model, body: fm.body.trim() } });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
/** `Bash(npm test:*)` → `bash(npm test:*)`; devolve null quando o Brito não tem a tool (WebFetch, mcp__…). */
|
|
140
|
+
export function mapRule(rule) {
|
|
141
|
+
const r = parseRule(rule);
|
|
142
|
+
if (!r)
|
|
143
|
+
return null;
|
|
144
|
+
const tool = mapTool(r.tool);
|
|
145
|
+
if (!tool)
|
|
146
|
+
return null;
|
|
147
|
+
if (r.pattern === undefined)
|
|
148
|
+
return tool;
|
|
149
|
+
const pattern = tool === "bash" ? r.pattern : r.pattern.replace(/^\.\//, "").replace(/^\/\//, "/");
|
|
150
|
+
return `${tool}(${pattern})`;
|
|
151
|
+
}
|
|
152
|
+
function planRules(root, home, includeAllow) {
|
|
153
|
+
const out = [];
|
|
154
|
+
const have = loadRules(root);
|
|
155
|
+
const files = [path.join(root, ".claude", "settings.json"), path.join(root, ".claude", "settings.local.json"), path.join(home, "settings.json")];
|
|
156
|
+
const seen = new Set();
|
|
157
|
+
for (const file of files) {
|
|
158
|
+
let perms;
|
|
159
|
+
try {
|
|
160
|
+
perms = JSON.parse(fs.readFileSync(file, "utf8"))?.permissions;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const source = file.startsWith(root) ? path.relative(root, file).split(path.sep).join("/") : `~/.claude/${path.basename(file)}`;
|
|
166
|
+
for (const kind of ["deny", "allow"]) {
|
|
167
|
+
for (const rule of (Array.isArray(perms?.[kind]) ? perms[kind] : []).filter((x) => typeof x === "string")) {
|
|
168
|
+
const key = `${kind}:${rule}`;
|
|
169
|
+
if (seen.has(key))
|
|
170
|
+
continue;
|
|
171
|
+
seen.add(key);
|
|
172
|
+
const mapped = mapRule(rule);
|
|
173
|
+
const item = (action, note) => ({ kind, name: rule, source, action, note, data: mapped });
|
|
174
|
+
if (!mapped)
|
|
175
|
+
out.push(item("skip", "o Brito não tem tool equivalente"));
|
|
176
|
+
else if (have[kind].includes(mapped))
|
|
177
|
+
out.push(item("skip", "já existe"));
|
|
178
|
+
else if (kind === "allow" && !includeAllow)
|
|
179
|
+
out.push(item("skip", "libera ações sem perguntar: use --incluir-allow para importar"));
|
|
180
|
+
else
|
|
181
|
+
out.push(item("import", mapped !== rule ? `vira ${mapped}` : undefined));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
/** Memória do Claude Code deste projeto: `~/.claude/projects/<caminho com - no lugar de separadores>/memory/*.md`. */
|
|
188
|
+
export const memoryDirOf = (root, home) => path.join(home, "projects", path.resolve(root).replace(/[^a-zA-Z0-9]/g, "-"), "memory");
|
|
189
|
+
const NOTE_KIND = { feedback: "convention", project: "decision", user: "learning", reference: "learning" };
|
|
190
|
+
function planMemory(root, home) {
|
|
191
|
+
const dir = memoryDirOf(root, home);
|
|
192
|
+
return mdIn(dir).filter((f) => f !== "MEMORY.md").flatMap((f) => {
|
|
193
|
+
const text = readText(path.join(dir, f)) ?? "";
|
|
194
|
+
const fm = parseRichFrontmatter(text);
|
|
195
|
+
const body = (fm?.body ?? text).trim();
|
|
196
|
+
const source = `~/.claude/projects/…/memory/${f}`;
|
|
197
|
+
if (body.length < 10)
|
|
198
|
+
return [{ kind: "memory", name: f, source, action: "skip", note: "vazia" }];
|
|
199
|
+
const type = fm?.meta.type ?? /^\s*type:\s*(\w+)/m.exec(text.slice(0, text.indexOf("\n---", 3) + 1))?.[1] ?? ""; // `type` pode estar aninhado em metadata:
|
|
200
|
+
const title = fm?.meta.name ?? f.replace(/\.md$/, "");
|
|
201
|
+
const lint = lintSkill({ name: "x", description: title, body });
|
|
202
|
+
if (lint.errors.length)
|
|
203
|
+
return [{ kind: "memory", name: f, source, action: "skip", note: `não importada: ${lint.errors.join("; ")}` }];
|
|
204
|
+
const id = `cc-${crypto.createHash("sha1").update(f + body).digest("hex").slice(0, 16)}`;
|
|
205
|
+
return [{ kind: "memory", name: title, source, action: "import", data: { id, kind: NOTE_KIND[type] ?? "learning", text: `[importada do Claude Code] ${title}: ${body}`.slice(0, 2000) } }];
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
export function planImport(root, options = {}) {
|
|
209
|
+
const home = options.claudeHome ?? claudeHome();
|
|
210
|
+
const items = [
|
|
211
|
+
...planSkills(root, home),
|
|
212
|
+
...planAgents(root, home),
|
|
213
|
+
...planRules(root, home, !!options.includeAllow),
|
|
214
|
+
...(options.includeMemory ? planMemory(root, home) : []),
|
|
215
|
+
];
|
|
216
|
+
const claudeMd = ["CLAUDE.md", path.join(".claude", "CLAUDE.md")].find((f) => fs.existsSync(path.join(root, f)));
|
|
217
|
+
if (claudeMd)
|
|
218
|
+
items.push({ kind: "info", name: claudeMd, source: claudeMd, action: "skip", note: "o Brito já lê o CLAUDE.md sozinho; nada a importar" });
|
|
219
|
+
if (fs.existsSync(path.join(root, ".mcp.json")))
|
|
220
|
+
items.push({ kind: "info", name: ".mcp.json", source: ".mcp.json", action: "skip", note: "o Brito ainda não conecta a servidores MCP externos; os servidores não foram importados" });
|
|
221
|
+
return { items, options };
|
|
222
|
+
}
|
|
223
|
+
/** Grava o plano (menos as notas de memória, que precisam do grafo: ficam em `result.notes` para quem tem a conexão). */
|
|
224
|
+
export function applyImport(root, plan) {
|
|
225
|
+
const res = { skills: 0, agents: 0, rules: 0, pending: 0, notes: [], errors: [] };
|
|
226
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
227
|
+
for (const it of plan.items) {
|
|
228
|
+
if (it.action !== "import")
|
|
229
|
+
continue;
|
|
230
|
+
try {
|
|
231
|
+
if (it.kind === "skill") {
|
|
232
|
+
const d = it.data;
|
|
233
|
+
if (it.pending) {
|
|
234
|
+
savePending(root, { ...d, reason: `importada do Claude Code (${it.source})` }, it.scope);
|
|
235
|
+
res.pending++;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const skill = { name: d.name, description: d.description, version: 1, uses: 0, created: today, updated: today, lastUsed: today, refs: [], status: "trial", origin: "auto", wins: 0, losses: 0, body: d.body, scope: it.scope ?? "project", invoke: d.invoke, model: "", lastLoss: "", by: IMPORT_TAG };
|
|
239
|
+
writeSkill(root, skill);
|
|
240
|
+
res.skills++;
|
|
241
|
+
}
|
|
242
|
+
else if (it.kind === "agent") {
|
|
243
|
+
const d = it.data;
|
|
244
|
+
const file = path.join(root, ".brito", "agents", `${it.name}.md`);
|
|
245
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
246
|
+
fs.writeFileSync(file, stringifyFrontmatter({ name: it.name, description: d.description, ...(d.tools !== "all" ? { tools: d.tools.join(", ") } : {}), ...(d.model ? { model: d.model } : {}), imported: IMPORT_TAG }, d.body));
|
|
247
|
+
res.agents++;
|
|
248
|
+
}
|
|
249
|
+
else if (it.kind === "deny" || it.kind === "allow") {
|
|
250
|
+
addRule(root, it.kind, it.data);
|
|
251
|
+
res.rules++;
|
|
252
|
+
}
|
|
253
|
+
else if (it.kind === "memory") {
|
|
254
|
+
res.notes.push(it.data);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
catch (e) {
|
|
258
|
+
res.errors.push(`${it.kind} ${it.name}: ${e?.message ?? e}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return res;
|
|
262
|
+
}
|
|
263
|
+
/** Desfaz o que o import criou: skills (vão para a lixeira do escopo) e agentes marcados. Regras e notas ficam: apague à mão. */
|
|
264
|
+
export function undoImport(root) {
|
|
265
|
+
let skills = 0, agents = 0;
|
|
266
|
+
for (const s of listSkills(root))
|
|
267
|
+
if (s.by === IMPORT_TAG && deleteSkill(root, s.name, s.scope))
|
|
268
|
+
skills++;
|
|
269
|
+
const dir = path.join(root, ".brito", "agents");
|
|
270
|
+
for (const f of mdIn(dir)) {
|
|
271
|
+
const text = readText(path.join(dir, f)) ?? "";
|
|
272
|
+
if (parseRichFrontmatter(text)?.meta.imported === IMPORT_TAG) {
|
|
273
|
+
fs.rmSync(path.join(dir, f));
|
|
274
|
+
agents++;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return { skills, agents };
|
|
278
|
+
}
|
|
279
|
+
const LABEL = { skill: "skill", agent: "agente", deny: "deny", allow: "allow", memory: "memória", info: "aviso" };
|
|
280
|
+
/** Relatório legível do plano (antes de gravar) ou do resultado. */
|
|
281
|
+
export function describePlan(plan) {
|
|
282
|
+
const lines = plan.items.map((i) => `${i.action === "import" ? "+" : "·"} ${LABEL[i.kind]} ${i.name} ← ${i.source}${i.note ? ` (${i.note})` : ""}`);
|
|
283
|
+
const n = (k) => plan.items.filter((i) => i.kind === k && i.action === "import").length;
|
|
284
|
+
lines.push(`resumo: ${n("skill")} skill(s), ${n("agent")} agente(s), ${n("deny") + n("allow")} regra(s), ${n("memory")} nota(s) a importar; ${plan.items.filter((i) => i.action === "skip" && i.kind !== "info").length} ignorado(s)`);
|
|
285
|
+
return lines;
|
|
286
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { slmGenerate, slmStatus } from "./slm.js";
|
|
2
|
+
import { slmStats } from "./stats.js";
|
|
3
|
+
export const slmCompactEnabled = () => process.env.BRITO_SLM_COMPACT !== "off";
|
|
4
|
+
/** Tamanho de cada fatia em caracteres (~2,5k tokens de JSON de conversa: cabe com folga junto do prompt e da saída). */
|
|
5
|
+
const CHUNK_CHARS = 9_000;
|
|
6
|
+
/** Teto de reduções encadeadas, para nunca entrar em laço. */
|
|
7
|
+
const MAX_ROUNDS = 3;
|
|
8
|
+
const SYSTEM = "Você resume trechos de uma conversa de um agente de programação. Preserve: objetivo, decisões, arquivos e símbolos tocados (caminhos exatos), erros encontrados, estado atual e próximos passos. Não invente nada que não esteja no texto. Seja denso e use tópicos curtos. Responda só com o resumo.";
|
|
9
|
+
/** Fatia em pedaços de até `size` caracteres, preferindo cortar em quebra de linha ou vírgula. */
|
|
10
|
+
export function chunkText(text, size = CHUNK_CHARS) {
|
|
11
|
+
const out = [];
|
|
12
|
+
let i = 0;
|
|
13
|
+
while (i < text.length) {
|
|
14
|
+
let end = Math.min(i + size, text.length);
|
|
15
|
+
if (end < text.length) {
|
|
16
|
+
const cut = Math.max(text.lastIndexOf("\n", end), text.lastIndexOf(",", end));
|
|
17
|
+
if (cut > i + size * 0.5)
|
|
18
|
+
end = cut + 1;
|
|
19
|
+
}
|
|
20
|
+
out.push(text.slice(i, end));
|
|
21
|
+
i = end;
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
/** Resume `text` com o SLM. `null` = não deu (indisponível, erro ou resposta vazia). */
|
|
26
|
+
export async function slmSummarize(text, gen = slmGenerate, signal) {
|
|
27
|
+
try {
|
|
28
|
+
let parts = chunkText(text);
|
|
29
|
+
for (let round = 0; round < MAX_ROUNDS; round++) {
|
|
30
|
+
const sums = [];
|
|
31
|
+
for (const p of parts) {
|
|
32
|
+
const s = (await gen({ system: SYSTEM, user: p, maxTokens: 700, temperature: 0.1, signal })).trim();
|
|
33
|
+
if (s)
|
|
34
|
+
sums.push(s);
|
|
35
|
+
}
|
|
36
|
+
if (!sums.length)
|
|
37
|
+
return null;
|
|
38
|
+
const joined = sums.join("\n\n");
|
|
39
|
+
if (joined.length <= CHUNK_CHARS)
|
|
40
|
+
return joined;
|
|
41
|
+
parts = chunkText(joined);
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Resumo da compactação via SLM, ou `null` quando o SLM não está pronto/habilitado ou falhou.
|
|
51
|
+
* Registra a economia em `slmStats` (tokens que o modelo principal não precisou ler).
|
|
52
|
+
*/
|
|
53
|
+
export async function slmCompactSummary(text, gen = slmGenerate) {
|
|
54
|
+
if (!slmCompactEnabled())
|
|
55
|
+
return null;
|
|
56
|
+
if (gen === slmGenerate && !(await slmStatus()).ready)
|
|
57
|
+
return null;
|
|
58
|
+
const s = await slmSummarize(text, gen);
|
|
59
|
+
if (s === null) {
|
|
60
|
+
slmStats.compactFallbacks++;
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
slmStats.compactCalls++;
|
|
64
|
+
slmStats.tokensSaved += Math.max(0, Math.ceil((text.length - s.length) / 4));
|
|
65
|
+
return s;
|
|
66
|
+
}
|