brito-cli 0.7.5 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -4
- package/README.pt-BR.md +54 -4
- package/dist/agent/approval.js +19 -2
- package/dist/agent/claude-cli.js +10 -4
- package/dist/agent/loop.js +38 -9
- package/dist/agent/prompt.js +27 -1
- package/dist/agent/sessions.js +98 -0
- package/dist/cli.js +296 -26
- package/dist/desktop/panels.js +93 -0
- package/dist/desktop/protocol.js +5 -0
- package/dist/desktop/serve.js +191 -23
- package/dist/engine.js +28 -4
- package/dist/local/digest.js +202 -0
- package/dist/local/slm.js +218 -0
- package/dist/mcp/server.js +36 -3
- package/dist/model.js +21 -0
- package/dist/orchestrator.js +131 -0
- package/dist/permissions.js +4 -0
- package/dist/provider/anthropic-params.js +32 -0
- package/dist/provider/anthropic.js +13 -9
- package/dist/provider/fallback.js +67 -8
- package/dist/provider/index.js +4 -0
- package/dist/provider/local.js +88 -0
- package/dist/provider/resolve.js +8 -0
- package/dist/repl.js +49 -6
- package/dist/rules.js +102 -0
- package/dist/skills/builtin.js +89 -0
- package/dist/skills/describe.js +3 -2
- package/dist/skills/learn.js +2 -2
- package/dist/skills/store.js +64 -8
- package/dist/skills/trial.js +3 -3
- package/dist/tools/delegate.js +31 -22
- package/dist/tools/digest.js +29 -0
- package/dist/tools/fs.js +51 -8
- package/dist/tools/implement.js +50 -0
- package/dist/tools/registry.js +10 -1
- package/dist/tools/skills.js +10 -6
- package/dist/tools/spec.js +47 -0
- package/dist/tools/todo.js +22 -0
- package/dist/ui/ui.js +12 -0
- package/dist/util/todos.js +4 -0
- package/package.json +10 -6
- package/dist/setup.js +0 -110
package/dist/provider/index.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { AnthropicProvider } from "./anthropic.js";
|
|
2
|
+
import { LocalSlmProvider } from "./local.js";
|
|
2
3
|
import { OpenAICompatProvider } from "./openai-compat.js";
|
|
3
4
|
import { resolveModel } from "./resolve.js";
|
|
4
5
|
let factory = null;
|
|
5
6
|
/** Só para testes: troca a criação de provedores. `null` volta ao normal. */
|
|
6
7
|
export function setProviderFactory(f) { factory = f; }
|
|
7
8
|
let anthropic;
|
|
9
|
+
let local;
|
|
8
10
|
const openai = new Map();
|
|
9
11
|
/**
|
|
10
12
|
* Provedor de um modelo pelo nome que o usuário usa. O cliente da Anthropic só nasce quando um modelo Claude é usado:
|
|
@@ -16,6 +18,8 @@ export function providerFor(name) {
|
|
|
16
18
|
return factory(r);
|
|
17
19
|
if (r.kind === "anthropic")
|
|
18
20
|
return (anthropic ??= new AnthropicProvider());
|
|
21
|
+
if (r.local)
|
|
22
|
+
return (local ??= new LocalSlmProvider());
|
|
19
23
|
// O config pode mudar na sessão: refaz o provedor quando o perfil muda.
|
|
20
24
|
const key = JSON.stringify(r.profile);
|
|
21
25
|
const hit = openai.get(r.name);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { slmChat, slmChatCtx } from "../local/slm.js";
|
|
2
|
+
const textOf = (c) => typeof c === "string" ? c : c.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("\n");
|
|
3
|
+
const resultText = (c) => typeof c === "string" ? c : (c ?? []).flatMap((b) => (b.type === "text" ? [b.text] : [])).join("\n");
|
|
4
|
+
/**
|
|
5
|
+
* Histórico interno (formato da Anthropic) → histórico do LlamaChat. Cada `tool_use` vira uma `functionCall` com o
|
|
6
|
+
* `result` do `tool_result` correspondente; chamada sem resultado fica com resultado vazio. Imagens são descartadas.
|
|
7
|
+
*/
|
|
8
|
+
export function toChatHistory(system, messages) {
|
|
9
|
+
const out = [{ type: "system", text: system }];
|
|
10
|
+
const pending = new Map();
|
|
11
|
+
for (const m of messages) {
|
|
12
|
+
if (m.role === "assistant") {
|
|
13
|
+
const response = [];
|
|
14
|
+
if (typeof m.content === "string")
|
|
15
|
+
response.push(m.content);
|
|
16
|
+
else
|
|
17
|
+
for (const b of m.content) {
|
|
18
|
+
if (b.type === "text" && b.text)
|
|
19
|
+
response.push(b.text);
|
|
20
|
+
else if (b.type === "tool_use") {
|
|
21
|
+
const call = { type: "functionCall", name: b.name, params: b.input ?? {}, result: "" };
|
|
22
|
+
pending.set(b.id, call);
|
|
23
|
+
response.push(call);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (response.length)
|
|
27
|
+
out.push({ type: "model", response });
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (typeof m.content === "string") {
|
|
31
|
+
out.push({ type: "user", text: m.content });
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const text = [];
|
|
35
|
+
for (const b of m.content) {
|
|
36
|
+
if (b.type === "tool_result") {
|
|
37
|
+
const call = pending.get(b.tool_use_id);
|
|
38
|
+
if (call)
|
|
39
|
+
call.result = (b.is_error ? "ERRO: " : "") + resultText(b.content);
|
|
40
|
+
}
|
|
41
|
+
else if (b.type === "text")
|
|
42
|
+
text.push(b.text);
|
|
43
|
+
}
|
|
44
|
+
if (text.length)
|
|
45
|
+
out.push({ type: "user", text: text.join("\n") });
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
/** Só as palavras-chave de JSON Schema que a gramática do llama.cpp entende; o resto é descartado. */
|
|
50
|
+
export function toGbnfSchema(s) {
|
|
51
|
+
if (!s || typeof s !== "object")
|
|
52
|
+
return { type: "string" };
|
|
53
|
+
if (Array.isArray(s.enum))
|
|
54
|
+
return { enum: s.enum };
|
|
55
|
+
const t = Array.isArray(s.type) ? s.type.find((x) => x !== "null") : s.type;
|
|
56
|
+
if (t === "object" || s.properties) {
|
|
57
|
+
const props = Object.fromEntries(Object.entries(s.properties ?? {}).map(([k, v]) => [k, toGbnfSchema(v)]));
|
|
58
|
+
return { type: "object", properties: props, ...(Array.isArray(s.required) && s.required.length ? { required: s.required.filter((k) => k in props) } : {}) };
|
|
59
|
+
}
|
|
60
|
+
if (t === "array")
|
|
61
|
+
return { type: "array", items: toGbnfSchema(s.items) };
|
|
62
|
+
if (t === "string" || t === "number" || t === "integer" || t === "boolean")
|
|
63
|
+
return { type: t };
|
|
64
|
+
return { type: "string" };
|
|
65
|
+
}
|
|
66
|
+
export const toChatFunctions = (tools = []) => Object.fromEntries(tools.map((t) => [t.name, { description: t.description, params: toGbnfSchema(t.input_schema) }]));
|
|
67
|
+
const approxTokens = (s) => Math.ceil(s.length / 4);
|
|
68
|
+
/** SLM embutido como modelo do loop do Brito (fallback do Claude): mesma interface dos outros provedores. */
|
|
69
|
+
export class LocalSlmProvider {
|
|
70
|
+
kind = "openai";
|
|
71
|
+
async send(req, hooks = {}) {
|
|
72
|
+
const history = toChatHistory(req.system, req.messages);
|
|
73
|
+
const r = await slmChat({
|
|
74
|
+
history, functions: toChatFunctions(req.tools),
|
|
75
|
+
maxTokens: Math.min(req.maxTokens, Math.floor(slmChatCtx() / 2)), signal: hooks.signal, onText: hooks.onText,
|
|
76
|
+
});
|
|
77
|
+
const content = [];
|
|
78
|
+
if (r.text.trim())
|
|
79
|
+
content.push({ type: "text", text: r.text });
|
|
80
|
+
r.calls.forEach((c, i) => content.push({ type: "tool_use", id: `slm_${Date.now().toString(36)}_${i}`, name: c.name, input: c.input }));
|
|
81
|
+
const input = approxTokens(JSON.stringify(history));
|
|
82
|
+
return {
|
|
83
|
+
content,
|
|
84
|
+
stopReason: r.calls.length ? "tool_use" : r.truncated ? "max_tokens" : "end_turn",
|
|
85
|
+
usage: { input, output: approxTokens(r.text + JSON.stringify(r.calls)), cacheRead: 0 },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
package/dist/provider/resolve.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { MODEL_ALIASES, loadProfiles } from "../model.js";
|
|
2
|
+
/** Nome do SLM local como modelo (fallback do Claude e `/model slm`). Um perfil `slm` no config tem precedência. */
|
|
3
|
+
export const SLM_MODEL = "slm";
|
|
4
|
+
const SLM_PROFILE = {
|
|
5
|
+
name: SLM_MODEL, type: "openai", label: "SLM local", baseURL: "local:", model: SLM_MODEL, apiKeyEnv: "",
|
|
6
|
+
contextWindow: 16_384, vision: false, thinkingToggle: false,
|
|
7
|
+
};
|
|
2
8
|
/** Janela dos modelos Claude quando nada informa outra. */
|
|
3
9
|
const CLAUDE_WINDOW = 200_000;
|
|
4
10
|
const COMPACT_DEFAULT = 120_000;
|
|
@@ -6,6 +12,8 @@ const COMPACT_DEFAULT = 120_000;
|
|
|
6
12
|
export function resolveModel(name) {
|
|
7
13
|
const key = name.trim().toLowerCase();
|
|
8
14
|
const profile = loadProfiles().profiles[key];
|
|
15
|
+
if (!profile && key === SLM_MODEL)
|
|
16
|
+
return { kind: "openai", name: SLM_MODEL, wireModel: SLM_MODEL, label: SLM_PROFILE.label, contextWindow: SLM_PROFILE.contextWindow, profile: SLM_PROFILE, local: true };
|
|
9
17
|
if (profile)
|
|
10
18
|
return { kind: "openai", name: key, wireModel: profile.model, label: profile.label, contextWindow: profile.contextWindow, profile };
|
|
11
19
|
const wire = MODEL_ALIASES[key] ?? name.trim();
|
package/dist/repl.js
CHANGED
|
@@ -7,6 +7,7 @@ import { Dock } from "./ui/dock.js";
|
|
|
7
7
|
import os from "node:os";
|
|
8
8
|
import { ui, c, fmtTokens } from "./ui/ui.js";
|
|
9
9
|
import { cols } from "./ui/theme.js";
|
|
10
|
+
import { engineChip } from "./provider/fallback.js";
|
|
10
11
|
/** Nome do usuário da máquina (fallback para variáveis de ambiente). */
|
|
11
12
|
const userName = () => {
|
|
12
13
|
try {
|
|
@@ -16,9 +17,10 @@ const userName = () => {
|
|
|
16
17
|
return process.env.USER ?? process.env.USERNAME ?? "você";
|
|
17
18
|
}
|
|
18
19
|
};
|
|
19
|
-
import { listPending, listSkills, deleteSkill, promoteSkill, globalHome } from "./skills/store.js";
|
|
20
|
+
import { listPending, listSkills, listBuiltins, getSkill, suggestSkills, deleteSkill, promoteSkill, globalHome } from "./skills/store.js";
|
|
20
21
|
import { listAgents } from "./agent/agents.js";
|
|
21
22
|
import { perms, cycleMode, MODE_LABEL } from "./permissions.js";
|
|
23
|
+
import { orch } from "./orchestrator.js";
|
|
22
24
|
/**
|
|
23
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
|
|
24
26
|
* comandos ficam estáveis e o que o motor atual não tem responde "indisponível". `usage`/`cost` somam todos os
|
|
@@ -56,6 +58,7 @@ export function switchableBackend(initial) {
|
|
|
56
58
|
const all = [...seen].map((b) => b.cost?.()).filter((x) => x !== undefined);
|
|
57
59
|
return all.length ? all.reduce((s, x) => s + x, 0) : undefined;
|
|
58
60
|
},
|
|
61
|
+
fallback: () => cur.fallback?.() ?? [...seen].map((b) => b.fallback?.()).find((s) => s?.active),
|
|
59
62
|
turn: (text, signal) => cur.turn(text, signal),
|
|
60
63
|
abort: () => cur.abort?.(),
|
|
61
64
|
// Todos os motores: o claude CLI fora de uso guarda a sessão (--resume) e os turnos que o fallback herdaria.
|
|
@@ -73,24 +76,32 @@ const COMMON = {
|
|
|
73
76
|
"/help": "esta ajuda",
|
|
74
77
|
"/agents": "subagentes disponíveis",
|
|
75
78
|
"/indicadores": "explica os números da barra de status",
|
|
76
|
-
"/skills": "lista; pending | approve <nome> | reject <nome> | consolidate | rm <nome> | promote <nome>",
|
|
79
|
+
"/skills": "lista; suggest <tarefa> | pending | approve <nome> | reject <nome> | consolidate | rm <nome> | promote <nome>",
|
|
77
80
|
"/modo": "mostra ou troca o modo (manual, auto, plano) — ou Shift+Tab",
|
|
78
|
-
"/clear": "
|
|
81
|
+
"/clear": "começa outra conversa (a anterior fica salva)",
|
|
79
82
|
"/sair": "encerra (ou Ctrl+C duas vezes)",
|
|
80
83
|
};
|
|
81
84
|
export async function runRepl(b, o) {
|
|
82
85
|
const interactive = !!process.stdin.isTTY && !!process.stdout.isTTY;
|
|
83
86
|
let skillCount = listSkills(o.root).length;
|
|
84
87
|
const refreshSkills = () => { skillCount = listSkills(o.root).length; };
|
|
88
|
+
// Skills de invocação manual (embutidas ou do usuário) viram /nome, com Tab.
|
|
89
|
+
const skillCmds = [...listBuiltins(o.root), ...listSkills(o.root).filter((s) => s.invoke === "manual")]
|
|
90
|
+
.map((s) => ({ name: `/${s.name}`, desc: `skill: ${s.description}` }));
|
|
85
91
|
const menu = [
|
|
92
|
+
...skillCmds,
|
|
86
93
|
...Object.entries(b.commands).map(([name, v]) => ({ name, desc: v.desc })),
|
|
87
94
|
...Object.entries(COMMON).map(([name, desc]) => ({ name, desc })),
|
|
88
95
|
].sort((x, y) => x.name.localeCompare(y.name));
|
|
89
96
|
const modeColor = { manual: c.muted, auto: c.ok, plan: c.warn, bypass: c.err };
|
|
90
|
-
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
|
+
};
|
|
91
101
|
const status = () => ui.statusLine({
|
|
92
102
|
project: path.basename(o.root),
|
|
93
103
|
mode: modeChip(),
|
|
104
|
+
engine: engineChip(b.model(), b.fallback?.()),
|
|
94
105
|
model: b.model(), ctxTokens: b.contextTokens(), ctxWindow: b.ctxWindow,
|
|
95
106
|
input: b.usage().input, output: b.usage().output, cacheRead: b.usage().cacheRead, startedAt: o.startedAt, cost: b.cost?.(),
|
|
96
107
|
extra: `${skillCount} ${skillCount === 1 ? "skill" : "skills"}`,
|
|
@@ -175,9 +186,21 @@ export async function runRepl(b, o) {
|
|
|
175
186
|
// Este tratador vale antes de `b.commands` (ver `run` mais abaixo): ele cuida de `promote` e `rm` e DELEGA o resto
|
|
176
187
|
// (listar, pending, approve, reject, consolidate) ao /skills do backend. Sem a delegação, esse /skills completo
|
|
177
188
|
// ficava escondido e a revisão de propostas pendentes era inalcançável no REPL.
|
|
178
|
-
"/skills": async ([sub, name]) => {
|
|
189
|
+
"/skills": async ([sub, name, ...rest]) => {
|
|
190
|
+
const args = [name, ...rest].filter((x) => !!x);
|
|
179
191
|
const full = b.commands["/skills"];
|
|
180
|
-
if (sub === "
|
|
192
|
+
if (sub === "suggest") {
|
|
193
|
+
const q = args.join(" ");
|
|
194
|
+
const found = q ? suggestSkills(o.root, q) : [];
|
|
195
|
+
if (!q)
|
|
196
|
+
ui.warn("uso: /skills suggest <descrição da tarefa>");
|
|
197
|
+
else if (!found.length)
|
|
198
|
+
ui.print(c.dim(" (nenhuma skill parecida)"));
|
|
199
|
+
else
|
|
200
|
+
for (const { skill: s, score } of found)
|
|
201
|
+
ui.print(` ${c.soft(s.invoke === "manual" ? "/" + s.name : s.name)} ${c.muted(`(${score.toFixed(1)}) ${s.description}`)}`);
|
|
202
|
+
}
|
|
203
|
+
else if (sub === "promote" && name) {
|
|
181
204
|
try {
|
|
182
205
|
promoteSkill(o.root, name);
|
|
183
206
|
ui.print(c.ok(`promovida para global: ${name}`));
|
|
@@ -243,6 +266,26 @@ export async function runRepl(b, o) {
|
|
|
243
266
|
await run(args);
|
|
244
267
|
return;
|
|
245
268
|
}
|
|
269
|
+
// /nome de uma skill: o usuário a invoca de propósito (inclusive as manuais). Argumentos viram o contexto.
|
|
270
|
+
const sk = line.startsWith("/") && !cmd.slice(1).includes("/") ? getSkill(o.root, key.slice(1)) : null;
|
|
271
|
+
if (sk) {
|
|
272
|
+
const one = args.includes("--um") || args.includes("--one");
|
|
273
|
+
const ctxText = args.filter((x) => x !== "--um" && x !== "--one").join(" ");
|
|
274
|
+
const msg = `Invoquei a skill ${sk.name}. Chame skill_load("${sk.name}") agora e siga-a.${one ? " Faça UMA pergunta por mensagem." : ""}${ctxText ? `\n\nContexto: ${ctxText}` : "\n\n(Sem contexto extra: comece perguntando sobre o que quero trabalhar.)"}`;
|
|
275
|
+
const ac = (running = new AbortController());
|
|
276
|
+
dock?.setBusy(true);
|
|
277
|
+
try {
|
|
278
|
+
await b.turn(msg, ac.signal);
|
|
279
|
+
await b.afterTurn?.();
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
running = null;
|
|
283
|
+
dock?.setBusy(false);
|
|
284
|
+
refreshSkills();
|
|
285
|
+
dock?.repaint();
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
246
289
|
// "/Users/x/arquivo.pdf" é caminho, não comando: segue como mensagem ao agente
|
|
247
290
|
if (line.startsWith("/") && !cmd.slice(1).includes("/")) {
|
|
248
291
|
ui.warn(`comando desconhecido: ${cmd}. Digite /help.`);
|
package/dist/rules.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const home = () => process.env.BRITO_HOME ?? path.join(os.homedir(), ".brito");
|
|
5
|
+
export const projectSettingsPath = (root) => path.join(root, ".brito", "settings.json");
|
|
6
|
+
const settingsFiles = (root) => [path.join(home(), "settings.json"), projectSettingsPath(root), path.join(root, ".brito", "settings.local.json")];
|
|
7
|
+
function readPerms(file) {
|
|
8
|
+
try {
|
|
9
|
+
const p = JSON.parse(fs.readFileSync(file, "utf8"))?.permissions ?? {};
|
|
10
|
+
const list = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string" && x.trim() !== "") : []);
|
|
11
|
+
return { allow: list(p.allow), deny: list(p.deny) };
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return {};
|
|
15
|
+
} // arquivo ausente ou ilegível: sem regras dele
|
|
16
|
+
}
|
|
17
|
+
export function loadRules(root) {
|
|
18
|
+
const parts = settingsFiles(root).map(readPerms);
|
|
19
|
+
return { allow: parts.flatMap((p) => p.allow ?? []), deny: parts.flatMap((p) => p.deny ?? []) };
|
|
20
|
+
}
|
|
21
|
+
/** Acrescenta uma regra ao `.brito/settings.json` do projeto, preservando o resto do arquivo. */
|
|
22
|
+
export function addRule(root, kind, rule) {
|
|
23
|
+
if (!parseRule(rule))
|
|
24
|
+
throw new Error(`regra inválida: ${rule} (use tool ou tool(padrão))`);
|
|
25
|
+
const file = projectSettingsPath(root);
|
|
26
|
+
let cfg = {};
|
|
27
|
+
try {
|
|
28
|
+
cfg = JSON.parse(fs.readFileSync(file, "utf8")) ?? {};
|
|
29
|
+
}
|
|
30
|
+
catch { /* arquivo novo */ }
|
|
31
|
+
cfg.permissions ??= {};
|
|
32
|
+
const list = (cfg.permissions[kind] ??= []);
|
|
33
|
+
if (!list.includes(rule))
|
|
34
|
+
list.push(rule);
|
|
35
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
36
|
+
fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n");
|
|
37
|
+
}
|
|
38
|
+
export function parseRule(rule) {
|
|
39
|
+
const m = /^\s*([A-Za-z_][\w-]*)\s*(?:\((.*)\))?\s*$/s.exec(rule);
|
|
40
|
+
return m ? { tool: m[1], pattern: m[2] } : null;
|
|
41
|
+
}
|
|
42
|
+
const esc = (s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
43
|
+
/** Padrão de comando: `*` = qualquer coisa; `:*` no fim = o prefixo sozinho ou seguido de argumentos. */
|
|
44
|
+
function commandRegex(p) {
|
|
45
|
+
const prefix = p.endsWith(":*");
|
|
46
|
+
const body = (prefix ? p.slice(0, -2) : p).trim().split("*").map(esc).join(".*");
|
|
47
|
+
return new RegExp(`^${body}${prefix ? "(\\s.*)?" : ""}$`, "s");
|
|
48
|
+
}
|
|
49
|
+
/** Padrão de caminho: `**` cruza diretórios, `*` não. */
|
|
50
|
+
function pathRegex(p) {
|
|
51
|
+
const body = p.replace(/\\/g, "/").split("**").map((part) => part.split("*").map(esc).join("[^/]*")).join(".*");
|
|
52
|
+
return new RegExp(`^${body}$`);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Divide um comando nos pedaços que o shell executaria (`&&`, `||`, `;`, `|`, quebra de linha). Uma regra `allow`
|
|
56
|
+
* precisa cobrir cada pedaço: `npm test*` não pode liberar `npm test && rm -rf ~`. Aspas não são interpretadas, o
|
|
57
|
+
* que só pode gerar pedaços a mais (mais perguntas), nunca a menos.
|
|
58
|
+
*/
|
|
59
|
+
export function commandSegments(cmd) {
|
|
60
|
+
return cmd.split(/&&|\|\||[;|\n]/).map((s) => s.trim()).filter(Boolean);
|
|
61
|
+
}
|
|
62
|
+
/** Construções que rodam ou escrevem algo fora do que o texto do pedaço mostra: nunca liberadas por regra. */
|
|
63
|
+
const OPAQUE = /\$\(|`|<\(|>\(/;
|
|
64
|
+
const SAFE_REDIRECTS = /\s*(?:\d?>\s*\/dev\/null|2>&1|>&2)/g;
|
|
65
|
+
/** O alvo da regra para cada tool que pede aprovação; `undefined` = a tool não tem alvo (regra só pelo nome). */
|
|
66
|
+
function targetOf(tool, input, root) {
|
|
67
|
+
if (tool === "bash")
|
|
68
|
+
return typeof input?.command === "string" ? input.command : undefined;
|
|
69
|
+
if (typeof input?.path === "string")
|
|
70
|
+
return path.relative(root, path.resolve(root, input.path)).split(path.sep).join("/");
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
function matches(rule, tool, target, segment) {
|
|
74
|
+
const r = parseRule(rule);
|
|
75
|
+
if (!r || r.tool !== tool)
|
|
76
|
+
return false;
|
|
77
|
+
if (r.pattern === undefined || r.pattern.trim() === "*")
|
|
78
|
+
return true;
|
|
79
|
+
if (target === undefined)
|
|
80
|
+
return false;
|
|
81
|
+
if (tool === "bash")
|
|
82
|
+
return commandRegex(r.pattern).test(segment ?? target);
|
|
83
|
+
return pathRegex(r.pattern).test(target);
|
|
84
|
+
}
|
|
85
|
+
/** Decide uma chamada de tool pelas regras. `ask` = nenhuma regra decide; o modo de permissão segue valendo. */
|
|
86
|
+
export function decide(rules, tool, input, root) {
|
|
87
|
+
const target = targetOf(tool, input, root);
|
|
88
|
+
if (tool === "bash" && target !== undefined) {
|
|
89
|
+
const segs = commandSegments(target);
|
|
90
|
+
if (rules.deny.some((d) => matches(d, tool, target, target) || segs.some((s) => matches(d, tool, target, s))))
|
|
91
|
+
return "deny";
|
|
92
|
+
if (OPAQUE.test(target) || /[<>]/.test(target.replace(SAFE_REDIRECTS, "")))
|
|
93
|
+
return "ask";
|
|
94
|
+
const clean = (s) => s.replace(SAFE_REDIRECTS, "").trim();
|
|
95
|
+
return segs.length && segs.every((s) => rules.allow.some((a) => matches(a, tool, target, clean(s)))) ? "allow" : "ask";
|
|
96
|
+
}
|
|
97
|
+
if (rules.deny.some((d) => matches(d, tool, target)))
|
|
98
|
+
return "deny";
|
|
99
|
+
if (rules.allow.some((a) => matches(a, tool, target)))
|
|
100
|
+
return "allow";
|
|
101
|
+
return "ask";
|
|
102
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export const BUILTIN_SKILLS = [
|
|
2
|
+
{
|
|
3
|
+
name: "grill-me",
|
|
4
|
+
description: "Entrevista o usuário sobre uma ideia/plano vago até ele conseguir se comprometer; sem escrever arquivos.",
|
|
5
|
+
model: "modelo mais capaz",
|
|
6
|
+
body: `## Quando usar
|
|
7
|
+
O usuário digitou /grill-me com uma ideia, funcionalidade ou decisão ainda vaga. Comece numa conversa nova, sem plano pronto.
|
|
8
|
+
|
|
9
|
+
## Passos
|
|
10
|
+
1. Antes de perguntar, use graph_search/graph_context/recall para responder sozinho o que o código ou a memória já dizem. Nunca pergunte o que dá para descobrir.
|
|
11
|
+
2. Trabalhe em RODADAS. Cada rodada é a "frontier": todas as perguntas cujos pré-requisitos já foram respondidos. Nunca pergunte algo que depende de resposta ainda não dada.
|
|
12
|
+
3. Numere as perguntas (Q1, Q2…), agrupe por tema e dê, em cada uma, a sua recomendação e o porquê. O usuário decide: não aceite "ok, ok, ok" sem teste; peça a razão quando a resposta for vaga.
|
|
13
|
+
4. Se o usuário pediu --um (ou "uma por vez"), faça só UMA pergunta por mensagem, a mais importante da frontier.
|
|
14
|
+
5. Perguntas que conversa não resolve (ex.: "formato longo ou curto?") não se discutem: proponha um protótipo descartável (em worktree ou arquivo temporário), peça para avaliar e volte com uma resposta curta.
|
|
15
|
+
6. Mostre premissas implícitas que você notou ("você está assumindo que X"). Procure o que quebra: falhas, casos de borda, dependências, custo.
|
|
16
|
+
7. Não escreva nem edite arquivos. Não use o modo plano: o objetivo é continuar perguntando, não correr para um plano.
|
|
17
|
+
|
|
18
|
+
## Armadilhas
|
|
19
|
+
- Passar de ~50 perguntas ou 4–5 rodadas: o escopo está grande demais. Proponha dividir em partes menores (ver wayfinder) em vez de seguir.
|
|
20
|
+
- Sessão longa piora a qualidade das perguntas; resuma o que já foi decidido a cada rodada.
|
|
21
|
+
|
|
22
|
+
## Verificação
|
|
23
|
+
Termine quando a frontier esvaziar: todos os ramos visitados e nenhuma premissa implícita. Feche com um resumo curto das decisões tomadas e sugira o próximo passo (grill-with-docs para registrar, ou começar a implementar).`,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: "grill-with-docs",
|
|
27
|
+
description: "Como o grill-me, mas lê o código e registra cada decisão fechada na memória do grafo (remember).",
|
|
28
|
+
model: "modelo mais capaz",
|
|
29
|
+
body: `## Quando usar
|
|
30
|
+
O usuário digitou /grill-with-docs: a mesma entrevista do grill-me, mas o resultado precisa ficar registrado para as próximas sessões.
|
|
31
|
+
|
|
32
|
+
## Passos
|
|
33
|
+
1. Rode recall com os termos do tema e leia as notas e decisões existentes antes de perguntar. Não reabra o que já foi decidido, a menos que o usuário peça.
|
|
34
|
+
2. Siga o método do grill-me (rodadas pela frontier, recomendação em cada pergunta, nada de perguntar o que o grafo responde). Use graph_context/graph_callers para embasar as perguntas em código real.
|
|
35
|
+
3. A cada decisão fechada, chame remember(kind="decision") com o texto da decisão, o motivo e refs para os arquivos/símbolos afetados. Termos de domínio novos viram remember(kind="convention") com a definição.
|
|
36
|
+
4. Quando o usuário discordar de uma nota antiga, registre a nova e diga qual substitui qual.
|
|
37
|
+
5. Ao terminar, liste as notas gravadas e, se fizer sentido, sugira atualizar README/docs (só sugerir; editar exige pedido).
|
|
38
|
+
|
|
39
|
+
## Armadilhas
|
|
40
|
+
- Não grave hipóteses ou perguntas em aberto como decisão.
|
|
41
|
+
- Não grave segredos nem caminhos absolutos.
|
|
42
|
+
|
|
43
|
+
## Verificação
|
|
44
|
+
Cada decisão do resumo final tem uma nota correspondente (confira com recall) e a frontier está vazia.`,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: "wayfinder",
|
|
48
|
+
description: "Para trabalho grande demais para uma sessão: mapeia o esforço em partes, ordena e delega cada parte.",
|
|
49
|
+
model: "modelo mais capaz",
|
|
50
|
+
body: `## Quando usar
|
|
51
|
+
O usuário digitou /wayfinder com um objetivo amplo (migração, refatoração grande, funcionalidade com várias partes) que não cabe em um contexto só.
|
|
52
|
+
|
|
53
|
+
## Passos
|
|
54
|
+
1. Mapeie o terreno com o grafo: graph_search para achar os pontos de entrada, graph_impact nos arquivos centrais para medir o raio de mudança. Dispense ler arquivos inteiros.
|
|
55
|
+
2. Quebre o objetivo em partes pequenas e independentes, cada uma com: objetivo, arquivos/símbolos, dependências de outras partes e como verificar (typecheck/teste).
|
|
56
|
+
3. Ordene por dependência; marque quais podem rodar em paralelo.
|
|
57
|
+
4. Mostre o mapa ao usuário e confirme antes de executar. Se houver dúvidas de escopo, rode o método do grill-me só nelas.
|
|
58
|
+
5. Execute parte por parte. Use delegate para as que são grandes ou só de leitura (explore/plan em paralelo); mantenha uma lista com todo_write, uma parte in_progress por vez.
|
|
59
|
+
6. Ao fim de cada parte, verifique, grave com remember o que foi decidido e siga para a próxima.
|
|
60
|
+
|
|
61
|
+
## Armadilhas
|
|
62
|
+
- Partes que tocam os mesmos arquivos não devem rodar em paralelo.
|
|
63
|
+
- Não deixe uma parte depender de contexto que só existe na conversa: escreva a tarefa completa para o subagente.
|
|
64
|
+
|
|
65
|
+
## Verificação
|
|
66
|
+
Todas as partes do mapa estão concluídas e verificadas, e o typecheck e os testes do projeto passam.`,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: "grill-diff",
|
|
70
|
+
description: "Revisão por perguntas: usa o grafo no diff atual e questiona o usuário sobre impacto e intenção das mudanças.",
|
|
71
|
+
model: "modelo mais capaz",
|
|
72
|
+
body: `## Quando usar
|
|
73
|
+
O usuário digitou /grill-diff antes de commitar/abrir PR, ou quer testar se as mudanças foram intencionais.
|
|
74
|
+
|
|
75
|
+
## Passos
|
|
76
|
+
1. Rode git diff (e git status) via bash para listar o que mudou. Não edite nada.
|
|
77
|
+
2. Para cada símbolo ou arquivo alterado, use graph_callers/graph_impact e liste quem depende dele.
|
|
78
|
+
3. Faça perguntas pontuais, numeradas, só onde há risco: "você mudou X, que Y e Z chamam. O comportamento para o caso W continua igual?", "esta função perdeu o tratamento de erro: foi intencional?", "falta teste para este caminho".
|
|
79
|
+
4. Dê a sua recomendação em cada pergunta. Não repita o que o diff já deixa óbvio.
|
|
80
|
+
5. Se uma resposta revelar um problema, aponte a correção, mas só aplique se o usuário pedir.
|
|
81
|
+
|
|
82
|
+
## Armadilhas
|
|
83
|
+
- Mudança só de formatação ou de comentário não precisa de pergunta.
|
|
84
|
+
- Não confunda impacto no grafo (quem chama) com quebra real: confirme lendo o trecho.
|
|
85
|
+
|
|
86
|
+
## Verificação
|
|
87
|
+
Cada símbolo alterado com chamadores teve a pergunta feita e respondida (ou descartada como segura), e o resumo final lista riscos restantes e testes a rodar.`,
|
|
88
|
+
},
|
|
89
|
+
];
|
package/dist/skills/describe.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getSkill, lintSkill, targetScope } from "./store.js";
|
|
1
|
+
import { adviseSkill, getSkill, lintSkill, targetScope } from "./store.js";
|
|
2
2
|
import { lineDiff } from "../util/diff.js";
|
|
3
3
|
/** Texto de aprovação de um skill_save: body inteiro na criação, diff na atualização, com o resultado do lint no topo. */
|
|
4
4
|
export function describeSave(root, i) {
|
|
@@ -6,7 +6,8 @@ export function describeSave(root, i) {
|
|
|
6
6
|
const prev = getSkill(root, i.name);
|
|
7
7
|
const tag = scope === "global" ? "GLOBAL (vale em todos os projetos)" : "do projeto";
|
|
8
8
|
const updating = prev && prev.scope === scope;
|
|
9
|
-
const { errors, warnings } = lintSkill(i);
|
|
9
|
+
const { errors, warnings: lintWarnings } = lintSkill(i);
|
|
10
|
+
const warnings = [...lintWarnings, ...adviseSkill(i)];
|
|
10
11
|
const shown = updating ? lineDiff(prev.body, i.body.trim()) : i.body.split("\n");
|
|
11
12
|
return `${updating ? `ATUALIZAR skill ${tag} ${i.name} (v${prev.version} → v${prev.version + 1})` : `CRIAR skill ${tag} ${i.name}`}\n quando: ${i.description}\n motivo: ${i.reason}\n` +
|
|
12
13
|
[...errors.map((e) => ` ✗ ${e}`), ...warnings.map((w) => ` ⚠ ${w}`)].join("\n") + (errors.length || warnings.length ? "\n" : "") +
|
package/dist/skills/learn.js
CHANGED
|
@@ -96,7 +96,7 @@ export function recordOutcomes(turn, ctx) {
|
|
|
96
96
|
const verdict = judgeTurn(turn);
|
|
97
97
|
if (!verdict)
|
|
98
98
|
return [];
|
|
99
|
-
return [...(ctx.loadedSkills ?? [])].flatMap((n) => recordOutcome(ctx.root, n, verdict === "win") ?? []);
|
|
99
|
+
return [...(ctx.loadedSkills ?? [])].flatMap((n) => recordOutcome(ctx.root, n, verdict === "win", extractSignals(turn)[0]?.detail) ?? []);
|
|
100
100
|
}
|
|
101
101
|
export const defaultComplete = async ({ model, system, user, tools, maxTokens }) => {
|
|
102
102
|
// Raciocínio desligado: o julgamento cabe no orçamento e a resposta útil é a chamada de skill_save.
|
|
@@ -183,7 +183,7 @@ export function buildReflectInput(turn, ctx, previous) {
|
|
|
183
183
|
const seen = similarEntries(ctx.root, turn);
|
|
184
184
|
return (`# Skills existentes\n${existing.length ? existing.map((s) => `- ${s.name}${s.scope === "global" ? " (global)" : ""}${staleRefs(ctx.root, s).length ? " [refs obsoletas]" : ""}: ${s.description}`).join("\n") : "(nenhuma)"}\n\n` +
|
|
185
185
|
(pending.length ? `# Propostas pendentes (aguardam revisão do usuário; contam como existentes: para ajustar uma, use o MESMO name)\n${pending.map((p) => `- ${p.name}${p.scope === "global" ? " (global)" : ""}: ${p.description}`).join("\n")}\n\n` : "") +
|
|
186
|
-
(loaded.length ? `# Skills carregadas neste turno (candidatas a melhoria)\n${loaded.map((s) => `## ${s.name} (${s.scope}) v${s.version}${staleRefs(ctx.root, s).length ? `\n(refs que não existem mais: ${staleRefs(ctx.root, s).join(", ")} — atualize se o turno mostrou os caminhos novos)` : ""}\n${s.body}`).join("\n\n")}\n\n` : "") +
|
|
186
|
+
(loaded.length ? `# Skills carregadas neste turno (candidatas a melhoria)\n${loaded.map((s) => `## ${s.name} (${s.scope}) v${s.version}${s.lastLoss ? `\n(último tropeço registrado: ${s.lastLoss})` : ""}${staleRefs(ctx.root, s).length ? `\n(refs que não existem mais: ${staleRefs(ctx.root, s).join(", ")} — atualize se o turno mostrou os caminhos novos)` : ""}\n${s.body}`).join("\n\n")}\n\n` : "") +
|
|
187
187
|
(signals.length ? `# Sinais\n${signals.map((x) => `- ${x.kind}: ${x.detail}`).join("\n")}\n\n` : "") +
|
|
188
188
|
(seen.length ? `# Tarefas parecidas já vistas (sessões anteriores)\n${seen.map(describeEntry).join("\n")}\n\n` : "") +
|
|
189
189
|
(previous ? `# Turno anterior (só contexto, não é o que está sendo avaliado)\n${renderPrevious(previous)}\n\n` : "") +
|
package/dist/skills/store.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { parseFrontmatter, stringifyFrontmatter } from "../util/frontmatter.js";
|
|
5
|
+
import { BUILTIN_SKILLS } from "./builtin.js";
|
|
5
6
|
export const NAME_RE = /^[a-z0-9][a-z0-9-]{1,48}$/;
|
|
6
7
|
const today = () => new Date().toISOString().slice(0, 10);
|
|
7
8
|
/** Skills globais valem para todos os projetos. `BRITO_HOME` troca o local (útil em testes). */
|
|
@@ -28,12 +29,15 @@ function parse(text, scope) {
|
|
|
28
29
|
losses: Number(meta.losses) || 0,
|
|
29
30
|
body: body.trim(),
|
|
30
31
|
scope,
|
|
32
|
+
invoke: meta.invoke === "manual" ? "manual" : "auto",
|
|
33
|
+
model: meta.model ?? "",
|
|
34
|
+
lastLoss: meta.lastLoss ?? "",
|
|
31
35
|
};
|
|
32
36
|
}
|
|
33
37
|
export function writeSkill(root, s) {
|
|
34
38
|
const file = fileOf(root, s.scope, s.name);
|
|
35
39
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
36
|
-
fs.writeFileSync(file, stringifyFrontmatter({ name: s.name, description: s.description, version: s.version, uses: s.uses, created: s.created, updated: s.updated, lastUsed: s.lastUsed, refs: s.refs.join(", "), status: s.status, origin: s.origin, wins: s.wins, losses: s.losses }, s.body));
|
|
40
|
+
fs.writeFileSync(file, stringifyFrontmatter({ name: s.name, description: s.description, version: s.version, uses: s.uses, created: s.created, updated: s.updated, lastUsed: s.lastUsed, refs: s.refs.join(", "), status: s.status, origin: s.origin, wins: s.wins, losses: s.losses, ...(s.invoke === "manual" ? { invoke: "manual" } : {}), ...(s.model ? { model: s.model } : {}), ...(s.lastLoss ? { lastLoss: s.lastLoss } : {}) }, s.body));
|
|
37
41
|
}
|
|
38
42
|
/** Lê uma skill de um escopo específico. */
|
|
39
43
|
export function getSkillIn(root, name, scope) {
|
|
@@ -48,7 +52,19 @@ export function getSkillIn(root, name, scope) {
|
|
|
48
52
|
}
|
|
49
53
|
/** Resolve por nome: a skill do projeto sobrepõe a global de mesmo nome. */
|
|
50
54
|
export function getSkill(root, name) {
|
|
51
|
-
return getSkillIn(root, name, "project") ?? getSkillIn(root, name, "global");
|
|
55
|
+
return getSkillIn(root, name, "project") ?? getSkillIn(root, name, "global") ?? getBuiltin(name);
|
|
56
|
+
}
|
|
57
|
+
/** Skills embutidas: disponíveis em qualquer projeto sem gravar nada em disco. */
|
|
58
|
+
export function getBuiltin(name) {
|
|
59
|
+
const b = BUILTIN_SKILLS.find((x) => x.name === name);
|
|
60
|
+
if (!b)
|
|
61
|
+
return null;
|
|
62
|
+
const d = "2026-11-07";
|
|
63
|
+
return { name: b.name, description: b.description, version: 1, uses: 0, created: d, updated: d, lastUsed: d, refs: [], status: "active", origin: "manual", wins: 0, losses: 0, body: b.body, scope: "global", invoke: "manual", model: b.model ?? "", lastLoss: "", builtin: true };
|
|
64
|
+
}
|
|
65
|
+
/** Embutidas ainda não sobrepostas por uma skill do usuário. */
|
|
66
|
+
export function listBuiltins(root) {
|
|
67
|
+
return BUILTIN_SKILLS.flatMap((b) => (getSkillIn(root, b.name, "project") ?? getSkillIn(root, b.name, "global") ? [] : [getBuiltin(b.name)]));
|
|
52
68
|
}
|
|
53
69
|
function listScope(root, scope) {
|
|
54
70
|
const dir = dirOf(root, scope);
|
|
@@ -118,6 +134,17 @@ export function lintSkill(input) {
|
|
|
118
134
|
const destructive = DESTRUCTIVE_RULES.filter((r) => r.re.test(text)).map((r) => `comando destrutivo (${r.msg}): só mantenha se houver guarda (backup, cópia/ambiente de teste ou confirmação)`);
|
|
119
135
|
return { errors, warnings: [...urls, ...destructive] };
|
|
120
136
|
}
|
|
137
|
+
/** Limite suave: skills longas pioram a qualidade do agente ("dumb zone"); acima disso, sugerimos dividir. */
|
|
138
|
+
export const SOFT_BODY_LIMIT = 3000;
|
|
139
|
+
/** Dicas de qualidade (não bloqueiam nem entram em `lintSkill`): tamanho e critério de término/verificação. */
|
|
140
|
+
export function adviseSkill(input) {
|
|
141
|
+
const tips = [];
|
|
142
|
+
if (input.body.length > SOFT_BODY_LIMIT)
|
|
143
|
+
tips.push(`body com ${input.body.length} chars (> ${SOFT_BODY_LIMIT}): considere dividir em skills menores`);
|
|
144
|
+
if (!/verifica|crit[ée]rio de (t[ée]rmino|sa[ií]da|pronto)|done when|pronto quando/i.test(input.body))
|
|
145
|
+
tips.push("sem seção '## Verificação' ou critério de término: diga como saber que acabou");
|
|
146
|
+
return tips;
|
|
147
|
+
}
|
|
121
148
|
export function validate(input) {
|
|
122
149
|
if (!NAME_RE.test(input.name))
|
|
123
150
|
throw new Error("name deve ser kebab-case (a-z, 0-9, hífen), 2–49 chars.");
|
|
@@ -164,6 +191,9 @@ export function saveSkill(root, input, scope, opts = {}) {
|
|
|
164
191
|
losses: opts.auto && prev?.status === "trial" ? 0 : prev?.losses ?? 0,
|
|
165
192
|
body: input.body.trim(),
|
|
166
193
|
scope: target,
|
|
194
|
+
invoke: input.invoke ?? prev?.invoke ?? "auto",
|
|
195
|
+
model: input.model ?? prev?.model ?? "",
|
|
196
|
+
lastLoss: prev?.lastLoss ?? "",
|
|
167
197
|
};
|
|
168
198
|
writeSkill(root, skill);
|
|
169
199
|
return { created: !prev, version: skill.version, scope: target };
|
|
@@ -215,13 +245,13 @@ export function rejectPending(root, name) {
|
|
|
215
245
|
}
|
|
216
246
|
export function bumpUses(root, name) {
|
|
217
247
|
const s = getSkill(root, name);
|
|
218
|
-
if (s)
|
|
248
|
+
if (s && !s.builtin)
|
|
219
249
|
writeSkill(root, { ...s, uses: s.uses + 1, lastUsed: today() });
|
|
220
250
|
}
|
|
221
251
|
/** Move para `.trash` do próprio escopo em vez de apagar. */
|
|
222
252
|
export function deleteSkill(root, name, scope) {
|
|
223
253
|
const s = scope ? getSkillIn(root, name, scope) : getSkill(root, name);
|
|
224
|
-
if (!s)
|
|
254
|
+
if (!s || s.builtin)
|
|
225
255
|
return false;
|
|
226
256
|
const base = dirOf(root, s.scope);
|
|
227
257
|
fs.mkdirSync(path.join(base, ".trash"), { recursive: true });
|
|
@@ -239,8 +269,34 @@ export function promoteSkill(root, name) {
|
|
|
239
269
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
240
270
|
fs.renameSync(path.join(dirOf(root, "project"), name), dest);
|
|
241
271
|
}
|
|
242
|
-
/**
|
|
243
|
-
export
|
|
244
|
-
|
|
245
|
-
|
|
272
|
+
/** Quantas skills vão para o índice do system prompt; o resto é achado por `skill_load` (sugestões) ou `/skills suggest`. */
|
|
273
|
+
export const INDEX_MAX = 25;
|
|
274
|
+
/** Índice compacto para o system prompt (divulgação progressiva: o corpo só entra via skill_load). Skills `manual` ficam de fora. */
|
|
275
|
+
export function skillsIndex(root, max = INDEX_MAX, now = Date.now()) {
|
|
276
|
+
const eligible = listSkills(root, now).filter((s) => s.invoke !== "manual");
|
|
277
|
+
const all = eligible.slice(0, max);
|
|
278
|
+
if (!all.length)
|
|
279
|
+
return "";
|
|
280
|
+
const rest = eligible.length - all.length;
|
|
281
|
+
return all.map((s) => `- ${s.name}${s.scope === "global" ? " (global)" : ""}${s.status === "trial" ? " (em teste)" : ""}${staleRefs(root, s).length ? " [refs obsoletas]" : ""}: ${s.description}`).join("\n")
|
|
282
|
+
+ (rest > 0 ? `\n(+${rest} skills não listadas: chame skill_load com uma palavra da tarefa para receber sugestões)` : "");
|
|
283
|
+
}
|
|
284
|
+
const STOP = new Set(["para", "como", "com", "uma", "que", "the", "and", "uso", "usar", "skill", "fazer", "esta", "isso", "nos", "nas", "dos", "das", "por"]);
|
|
285
|
+
export const words = (t) => (t.toLowerCase().normalize("NFD").replace(/\p{M}/gu, "").match(/[a-z0-9]{3,}/g) ?? []).filter((w) => !STOP.has(w));
|
|
286
|
+
/** Skills (incluindo manuais e embutidas) ordenadas por afinidade lexical com o texto. Nome pesa mais que descrição. */
|
|
287
|
+
export function suggestSkills(root, text, limit = 5) {
|
|
288
|
+
const q = new Set(words(text));
|
|
289
|
+
if (!q.size)
|
|
290
|
+
return [];
|
|
291
|
+
return [...listSkills(root), ...listBuiltins(root)]
|
|
292
|
+
.map((skill) => {
|
|
293
|
+
const nm = new Set(words(skill.name)), ds = new Set(words(skill.description)), bd = new Set(words(skill.body));
|
|
294
|
+
let score = 0;
|
|
295
|
+
for (const w of q)
|
|
296
|
+
score += (nm.has(w) ? 3 : 0) + (ds.has(w) ? 2 : 0) + (bd.has(w) ? 0.5 : 0);
|
|
297
|
+
return { skill, score };
|
|
298
|
+
})
|
|
299
|
+
.filter((x) => x.score > 0)
|
|
300
|
+
.sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name))
|
|
301
|
+
.slice(0, limit);
|
|
246
302
|
}
|
package/dist/skills/trial.js
CHANGED
|
@@ -9,11 +9,11 @@ export const TRIAL_DAYS = 21;
|
|
|
9
9
|
/** Teto de skills novas criadas sozinhas por dia (contido: cada uma vira instrução para o agente). */
|
|
10
10
|
export const DAILY_LIMIT = 3;
|
|
11
11
|
/** Registra o resultado de um turno que usou a skill. Devolve um aviso se ela foi promovida ou arquivada. */
|
|
12
|
-
export function recordOutcome(root, name, win) {
|
|
12
|
+
export function recordOutcome(root, name, win, loss) {
|
|
13
13
|
const s = getSkill(root, name);
|
|
14
|
-
if (!s)
|
|
14
|
+
if (!s || s.builtin)
|
|
15
15
|
return null;
|
|
16
|
-
const next = { ...s, wins: s.wins + (win ? 1 : 0), losses: s.losses + (win ? 0 : 1) };
|
|
16
|
+
const next = { ...s, wins: s.wins + (win ? 1 : 0), losses: s.losses + (win ? 0 : 1), lastLoss: win ? s.lastLoss : (loss ?? s.lastLoss).replace(/\s+/g, " ").slice(0, 200) };
|
|
17
17
|
if (s.origin === "auto") {
|
|
18
18
|
if (s.status === "trial") {
|
|
19
19
|
if (next.wins >= PROMOTE_WINS && next.wins >= 3 * next.losses) {
|