brito-cli 0.13.2 → 0.14.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 CHANGED
@@ -44,7 +44,7 @@ Upgrading from 0.9: `npm i -g brito-cli@latest`, and update the desktop app to t
44
44
 
45
45
  ```mermaid
46
46
  flowchart LR
47
- code["Your project<br/>.ts · .js · .py · .java · .kt · .go · .rb · .php · .cs · .rs · .svelte · .vue · .astro"] -->|"tree-sitter<br/>incremental"| cg[("Code graph<br/>symbols · calls · imports")]
47
+ code["Your project<br/>.ts · .js · .py · .java · .kt · .go · .rb · .php · .cs · .rs · .svelte · .vue · .astro · .md"] -->|"tree-sitter<br/>incremental"| cg[("Code graph<br/>symbols · calls · imports")]
48
48
  cg <-->|"graph_search · graph_callers<br/>graph_impact · graph_context"| agent{{"Brito agent"}}
49
49
  mem[("Memory<br/>decisions · conventions")] <-->|"recall / remember"| agent
50
50
  agent -->|"after a complex task"| learn["Reflection"]
@@ -191,7 +191,7 @@ OK: 20 arquivos, 66 símbolos, 54 chamadas, 17 imports | +0 ~0 -0 =20 (28ms)
191
191
 
192
192
  ## What the graph indexes
193
193
 
194
- - **Languages:** TypeScript/JavaScript (`.ts .tsx .mts .cts .js .jsx .mjs .cjs`), Python (`.py`), backend languages — Java (`.java`), Kotlin (`.kt`), Go (`.go`), Ruby (`.rb`), PHP (`.php`), C# (`.cs`), Rust (`.rs`) — and the `.svelte`, `.vue` and `.astro` components. In components Brito reads the script block(s) (and Astro's frontmatter) and records calls made from the template to functions the script declares or imports; each component becomes a `component` symbol.
194
+ - **Languages:** TypeScript/JavaScript (`.ts .tsx .mts .cts .js .jsx .mjs .cjs`), Python (`.py`), backend languages — Java (`.java`), Kotlin (`.kt`), Go (`.go`), Ruby (`.rb`), PHP (`.php`), C# (`.cs`), Rust (`.rs`) — and the `.svelte`, `.vue` and `.astro` components. **Markdown (`.md`)** is indexed as a note graph (no tree-sitter): headings → `heading` symbols, frontmatter and `#tags` → `tag` symbols (`status:open`), `#123` mentions → `ref` symbols, and `[text](./b.md)` / `[[wikilinks]]` → file→file edges, so `graph_impact` on a note lists its backlinks. Frontmatter values that name another note (`pessoas: [ana]`) and numeric ids (`pendencias: [14]`) link too. For note vaults use `graph_notes_find` (filter by folder, field, date/number, links, `#N`), `graph_notes_backlinks` and `graph_notes_path` (follows relations between notes in one call; `{}` lists the vault's relations), no Cypher needed. On a real 318-note vault they cut ~28% of tokens (and ~48% of cost) at equal accuracy; see the CHANGELOG. In components Brito reads the script block(s) (and Astro's frontmatter) and records calls made from the template to functions the script declares or imports; each component becomes a `component` symbol.
195
195
  - **Scope:** inside a git repository, only what git considers part of the project (tracked + untracked-but-not-ignored), so anything in `.gitignore` stays out. Outside git, or with `BRITO_INDEX_IGNORED=1`, it indexes by glob (still skipping `node_modules`, `dist`, `build`, `target`, `vendor`, `obj`, `.gradle`, `.venv`, `.brito`).
196
196
  - **Imports:** relative and aliased. Resolves `paths`/`baseUrl` from `tsconfig.json`/`jsconfig.json` (following relative `extends`, like `.svelte-kit/tsconfig.json`), `package.json` `imports` (`#internal/*`) and SvelteKit's `$lib`. Configuration applies per file (nearest config), so monorepos work; editing a tsconfig re-indexes the affected files.
197
197
  - **Backend languages:** the graph holds classes/interfaces/structs/traits/enums/modules, functions and methods (`Class.method`), calls and imports. Imports name a package, not a path, so Brito matches them against the end of your files' paths (`com.acme.Repo` ↔ `src/main/java/com/acme/Repo.java`; PHP PSR-4 namespaces; Rust `use crate::…`; Ruby `require`/`require_relative`; Go packages through the `go.mod` module path). External packages (`java.util`, `serde`, `github.com/gin-gonic/gin`) never match and create no edges. There is no type inference: a call like `userRepository.save()` is linked by matching the receiver name to a type in the project (`UserRepository`), `this`/`self`/receivers resolve to the enclosing class, and a bare call inside a class tries the class's own methods first.
package/README.pt-BR.md CHANGED
@@ -44,7 +44,7 @@ Atualizando da 0.9: `npm i -g brito-cli@latest` e atualize o app de desktop para
44
44
 
45
45
  ```mermaid
46
46
  flowchart LR
47
- code["Seu projeto<br/>.ts · .js · .py · .java · .kt · .go · .rb · .php · .cs · .rs · .svelte · .vue · .astro"] -->|"tree-sitter<br/>incremental"| cg[("Grafo de código<br/>símbolos · chamadas · imports")]
47
+ code["Seu projeto<br/>.ts · .js · .py · .java · .kt · .go · .rb · .php · .cs · .rs · .svelte · .vue · .astro · .md"] -->|"tree-sitter<br/>incremental"| cg[("Grafo de código<br/>símbolos · chamadas · imports")]
48
48
  cg <-->|"graph_search · graph_callers<br/>graph_impact · graph_context"| agent{{"Agente Brito"}}
49
49
  mem[("Memória<br/>decisões · convenções")] <-->|"recall / remember"| agent
50
50
  agent -->|"após uma tarefa complexa"| learn["Reflexão"]
@@ -187,7 +187,7 @@ OK: 20 arquivos, 66 símbolos, 54 chamadas, 17 imports | +0 ~0 -0 =20 (28ms)
187
187
 
188
188
  ## O que o grafo indexa
189
189
 
190
- - **Linguagens:** TypeScript/JavaScript (`.ts .tsx .mts .cts .js .jsx .mjs .cjs`), Python (`.py`), linguagens de backend — Java (`.java`), Kotlin (`.kt`), Go (`.go`), Ruby (`.rb`), PHP (`.php`), C# (`.cs`), Rust (`.rs`) — e os componentes `.svelte`, `.vue` e `.astro`. Nos componentes o Brito lê o(s) bloco(s) de script (e o frontmatter do Astro) e registra as chamadas feitas no template a funções que o script declara ou importa; cada componente vira um símbolo `component`.
190
+ - **Linguagens:** TypeScript/JavaScript (`.ts .tsx .mts .cts .js .jsx .mjs .cjs`), Python (`.py`), linguagens de backend — Java (`.java`), Kotlin (`.kt`), Go (`.go`), Ruby (`.rb`), PHP (`.php`), C# (`.cs`), Rust (`.rs`) — e os componentes `.svelte`, `.vue` e `.astro`. **Markdown (`.md`)** entra como grafo de notas (sem tree-sitter): headings viram símbolos `heading`, frontmatter e `#tags` viram `tag` (`status:aberta`), menções `#123` viram `ref`, e `[texto](./b.md)` / `[[wikilinks]]` viram arestas arquivo→arquivo; `graph_impact` numa nota lista os backlinks. Valores de frontmatter que nomeiam outra nota (`pessoas: [ana]`) e ids numéricos (`pendencias: [14]`) também ligam. Para vaults de notas use `graph_notes_find` (filtro por pasta, campo, data/número, links, `#N`), `graph_notes_backlinks` e `graph_notes_path` (segue relações entre notas em uma chamada; `{}` lista as relações do vault), sem Cypher. Num vault real de 318 notas cortaram ~28% dos tokens (e ~48% do custo) com o mesmo acerto; veja o CHANGELOG. Nos componentes o Brito lê o(s) bloco(s) de script (e o frontmatter do Astro) e registra as chamadas feitas no template a funções que o script declara ou importa; cada componente vira um símbolo `component`.
191
191
  - **Escopo:** em um repositório git, só o que o git considera parte do projeto (rastreados + novos não ignorados), então o que está no `.gitignore` não entra. Fora do git, ou com `BRITO_INDEX_IGNORED=1`, indexa por glob (ainda sem `node_modules`, `dist`, `build`, `target`, `vendor`, `obj`, `.gradle`, `.venv`, `.brito`).
192
192
  - **Imports:** relativos e por alias. Resolve `paths`/`baseUrl` do `tsconfig.json`/`jsconfig.json` (seguindo `extends` relativo, como o `.svelte-kit/tsconfig.json`), `imports` do `package.json` (`#interno/*`) e o `$lib` do SvelteKit. A configuração vale por arquivo (a mais próxima), então monorepos funcionam; editar um tsconfig reindexa os arquivos afetados.
193
193
  - **Linguagens de backend:** o grafo guarda classes/interfaces/structs/traits/enums/módulos, funções e métodos (`Classe.metodo`), chamadas e imports. Nelas o import nomeia um pacote, não um caminho; o Brito casa o especificador com o final do caminho dos seus arquivos (`com.acme.Repo` ↔ `src/main/java/com/acme/Repo.java`; namespaces PSR-4 do PHP; `use crate::…` do Rust; `require`/`require_relative` do Ruby; pacotes Go pelo módulo do `go.mod`). Pacote externo (`java.util`, `serde`, `github.com/gin-gonic/gin`) nunca casa e não gera aresta. Não há inferência de tipos: `userRepository.save()` é ligado casando o nome do receptor com um tipo do projeto (`UserRepository`), `this`/`self`/receiver resolvem para a classe que contém a chamada, e uma chamada solta dentro de classe tenta primeiro os métodos da própria classe.
@@ -1,3 +1,5 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
1
3
  import path from "node:path";
2
4
  import { spawn, spawnSync } from "node:child_process";
3
5
  import { randomUUID } from "node:crypto";
@@ -16,6 +18,33 @@ import { fmtBytes, findImages, splitOversize } from "../util/images.js";
16
18
  import { classifyClaudeCliFailure, collectClaudeFailure, shortError } from "../provider/fallback.js";
17
19
  // Backend alternativo: delega ao `claude` oficial, que autentica com o /login do usuário.
18
20
  // O brito nunca lê nem envia o token da assinatura. Grafo, memória e skills chegam ao claude via MCP.
21
+ /** Limite seguro para a linha de comando: o Windows corta em ~32.767 caracteres (spawn ENAMETOOLONG). */
22
+ const ARGV_BUDGET = 24_000;
23
+ /**
24
+ * Um CLAUDE.md/AGENTS.md grande estoura a linha de comando no Windows. Quando o total passa do limite, o system prompt, o
25
+ * --mcp-config e o --agents vão para arquivos temporários (o claude aceita caminho nos três); `cleanup` apaga a pasta.
26
+ */
27
+ export function spillLongArgs(args) {
28
+ if (args.reduce((n, a) => n + a.length + 1, 0) <= ARGV_BUDGET)
29
+ return { args, cleanup: () => { } };
30
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "brito-claude-"));
31
+ const out = [...args];
32
+ const spill = (flag, newFlag, file) => {
33
+ const i = out.indexOf(flag);
34
+ if (i < 0 || i + 1 >= out.length)
35
+ return;
36
+ const f = path.join(dir, file);
37
+ fs.writeFileSync(f, out[i + 1], { mode: 0o600 });
38
+ out.splice(i, 2, newFlag, f);
39
+ };
40
+ spill("--append-system-prompt", "--append-system-prompt-file", "system.md");
41
+ spill("--mcp-config", "--mcp-config", "mcp.json");
42
+ spill("--agents", "--agents", "agents.json");
43
+ return { args: out, cleanup: () => { try {
44
+ fs.rmSync(dir, { recursive: true, force: true });
45
+ }
46
+ catch { /* temp */ } } };
47
+ }
19
48
  export function hasClaudeCli() {
20
49
  const c = claudeCommand(["--version"]);
21
50
  return spawnSync(c.command, c.args, { stdio: "ignore" }).status === 0;
@@ -257,7 +286,8 @@ export class ClaudeSession {
257
286
  ui.info(`📎 ${imgs.map((i) => `${i.name} (${fmtBytes(i.bytes)})`).join(", ")} anexado${imgs.length > 1 ? "s" : ""}`);
258
287
  // O claude vê a imagem pela tool Read: o prompt diz quais arquivos são imagens anexadas pelo usuário.
259
288
  const prompt = this.pendingHandoff + (imgs.length ? `${text}\n\n[O usuário anexou ${imgs.length > 1 ? "estes arquivos" : "este arquivo"}; abra com a tool Read para vê-${imgs.length > 1 ? "las" : "la"}:\n${imgs.map((i) => `- ${i.path}`).join("\n")}]` : text);
260
- const cmd = (this.o.command ?? claudeCommand)(this.args(prompt, [...new Set(imgs.map((i) => path.dirname(i.path)))]));
289
+ const spilled = spillLongArgs(this.args(prompt, [...new Set(imgs.map((i) => path.dirname(i.path)))]));
290
+ const cmd = (this.o.command ?? claudeCommand)(spilled.args);
261
291
  const child = (this.child = spawn(cmd.command, cmd.args, {
262
292
  cwd: this.o.root, stdio: ["ignore", "pipe", "pipe"],
263
293
  // `/reasoning none`: o claude desliga o raciocínio com MAX_THINKING_TOKENS=0 (não há `--effort none`)
@@ -335,8 +365,9 @@ export class ClaudeSession {
335
365
  }
336
366
  });
337
367
  child.stderr.on("data", (d) => { stderr += d.toString(); });
338
- child.on("error", (e) => { ok = false; failure.spawnError = e.message; ui.error(`não consegui executar o claude: ${e.message}`); });
368
+ child.on("error", (e) => { spilled.cleanup(); ok = false; failure.spawnError = e.message; ui.error(`não consegui executar o claude: ${e.message}`); });
339
369
  child.on("close", (code, signal) => {
370
+ spilled.cleanup();
340
371
  this.child = null;
341
372
  interrupted = signal === "SIGINT" || code === 130 || this.aborted;
342
373
  this.aborted = false;
@@ -7,6 +7,7 @@ import { createRequire } from "node:module";
7
7
  import { spawnSync } from "node:child_process";
8
8
  import { SFC_EXT, componentName, splitSfc, templateCalleeNames } from "./sfc.js";
9
9
  import { Aliases, aliasCandidates } from "./aliases.js";
10
+ import { IDREF_PREFIX, IdIndex, MD_EXT, WIKI_PREFIX, WikiIndex, extractMarkdown } from "./markdown.js";
10
11
  import { EXTRA_EXT, ProjectIndex, extractLang, isExtraLang } from "./langs.js";
11
12
  const require = createRequire(import.meta.url);
12
13
  const WASM_DIR = path.dirname(require.resolve("tree-sitter-wasms/package.json")) + "/out";
@@ -14,6 +15,7 @@ const EXT = {
14
15
  ".ts": "typescript", ".mts": "typescript", ".cts": "typescript",
15
16
  ".tsx": "tsx", ".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
16
17
  ".py": "python",
18
+ ".md": "markdown", ".markdown": "markdown",
17
19
  ...EXTRA_EXT,
18
20
  };
19
21
  /** Tudo que o grafo indexa: código em EXT e os scripts de componentes (.svelte/.vue/.astro). Usado também nas instruções do MCP. */
@@ -188,7 +190,7 @@ function resolveImport(from, spec, known, aliases) {
188
190
  }
189
191
  return null;
190
192
  }
191
- const INDEXER_VERSION = "v4";
193
+ const INDEXER_VERSION = "v7";
192
194
  const TYPE_KINDS = new Set(["class", "interface", "struct", "trait", "enum", "module", "type"]);
193
195
  const JS_LIKE_RE = /\.(m|c)?[jt]sx?$|\.py$|\.svelte$|\.vue$|\.astro$/;
194
196
  const SEP = "\t";
@@ -341,9 +343,15 @@ async function indexProjectLocked(root, store, opts = {}) {
341
343
  // Componente (.svelte/.vue/.astro): o parser vê só os blocos de script; o template vira chamadas de um símbolo próprio.
342
344
  const sfc = SFC_EXT.has(ext) ? splitSfc(src, ext) : null;
343
345
  const lang = sfc ? (sfc.tsx ? "tsx" : "typescript") : EXT[ext];
344
- const tree = (await getParser(lang)).parse(sfc ? sfc.script : src);
345
- const info = isExtraLang(lang) ? extractLang(tree.rootNode, lang, rel) : extract(tree.rootNode, lang, rel);
346
- tree.delete();
346
+ let info;
347
+ if (MD_EXT.has(ext.toLowerCase())) { // markdown: sem parser, só estrutura e links
348
+ info = { ...extractMarkdown(src, rel), calls: [], bindings: [] };
349
+ }
350
+ else {
351
+ const tree = (await getParser(lang)).parse(sfc ? sfc.script : src);
352
+ info = isExtraLang(lang) ? extractLang(tree.rootNode, lang, rel) : extract(tree.rootNode, lang, rel);
353
+ tree.delete();
354
+ }
347
355
  if (sfc) {
348
356
  const name = componentName(rel);
349
357
  const comp = { id: `${rel}::${name}:1`, name, kind: "component", file: rel, startLine: 1, endLine: src.split("\n").length };
@@ -355,9 +363,8 @@ async function indexProjectLocked(root, store, opts = {}) {
355
363
  const calls = [...new Set(info.calls.filter((c) => c.callerId).map((c) => [c.callerId, c.callee, c.obj].join(SEP)))];
356
364
  const bindings = [...new Set(info.bindings.map((b) => b.join(SEP)))];
357
365
  await store.run("CREATE (:File {path:$p, lang:$l, hash:$h, calls:$c, imports:$i, bindings:$b})", { p: rel, l: sfc ? ext.slice(1) : lang, h: hash, c: calls, i: [...new Set(info.imports)], b: bindings });
358
- for (const s of info.syms) {
359
- await store.run("CREATE (:Symbol {id:$id, name:$n, kind:$k, file:$f, startLine:$s, endLine:$e})", { id: s.id, n: s.name, k: s.kind, f: s.file, s: s.startLine, e: s.endLine });
360
- await store.run("MATCH (f:File {path:$f}), (s:Symbol {id:$id}) CREATE (f)-[:DEFINES]->(s)", { f: rel, id: s.id });
366
+ for (const s of info.syms) { // símbolo e DEFINES numa query só: metade das idas ao banco
367
+ await store.run("MATCH (f:File {path:$f}) CREATE (f)-[:DEFINES]->(:Symbol {id:$id, name:$n, kind:$k, file:$f, startLine:$s, endLine:$e})", { id: s.id, n: s.name, k: s.kind, f: s.file, s: s.startLine, e: s.endLine });
361
368
  }
362
369
  stored.set(rel, { hash, calls, imports: [...new Set(info.imports)], bindings });
363
370
  await store.yieldIfWaited(); // dá a vez a outra sessão que só quer consultar
@@ -365,8 +372,14 @@ async function indexProjectLocked(root, store, opts = {}) {
365
372
  // 3. Recalcula arestas desejadas em memória (sem parsear) e aplica diff.
366
373
  const known = new Set(stored.keys());
367
374
  const projectIndex = new ProjectIndex(root, known);
375
+ const wikiIndex = new WikiIndex(known);
376
+ const idIndex = new IdIndex((await store.run("MATCH (s:Symbol) WHERE s.kind = 'tag' AND s.name STARTS WITH 'id:' RETURN s.file AS file, s.name AS name")).map((r) => ({ file: String(r.file), name: String(r.name) })));
368
377
  /** Arquivos que um import pode designar: vários em linguagens de pacote (Go, Java/Kotlin com wildcard), no máximo um em TS/Python. */
369
378
  const resolveAll = (from, spec) => {
379
+ if (spec.startsWith(WIKI_PREFIX))
380
+ return wikiIndex.resolve(spec);
381
+ if (spec.startsWith(IDREF_PREFIX))
382
+ return idIndex.resolve(spec);
370
383
  const r = projectIndex.resolve(from, spec);
371
384
  if (r)
372
385
  return r;
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Markdown no grafo (sem tree-sitter): vault de notas vira rede de relações.
3
+ * - heading → Symbol `heading` (nível no nome não; faixa de linhas até o próximo heading do mesmo nível ou acima)
4
+ * - frontmatter → Symbol `tag` com nome `chave:valor` (status:aberta, pessoa:Xande…); listas viram vários; `null` é ignorado.
5
+ * Valor que é o nome de outra nota (pessoas: [adonis-santos]) também vira link para ela
6
+ * - #tag no texto → Symbol `tag` com nome `#tag`
7
+ * - menção #123 → Symbol `ref` com nome `#123` (mesmo número em notas diferentes = mesmo assunto)
8
+ * - [texto](./b.md) → import relativo (aresta File→File)
9
+ * - [[Nota]] / [[Nota|alias]] / [[Nota#Seção]] → import `wiki:nota`, resolvido pelo nome do arquivo
10
+ */
11
+ export const MD_EXT = new Set([".md", ".markdown"]);
12
+ export const WIKI_PREFIX = "wiki:";
13
+ export const IDREF_PREFIX = "idref:";
14
+ const clip = (s, n = 80) => (s.length > n ? s.slice(0, n) : s);
15
+ const unquote = (s) => s.trim().replace(/^(['"])(.*)\1$/, "$2").trim();
16
+ /** Subconjunto de YAML que notas usam: `k: v`, `k: [a, b]` e lista `- item` abaixo de `k:`. Devolve pares chave/valor. */
17
+ function frontmatter(lines) {
18
+ if (lines[0]?.trim() !== "---")
19
+ return { pairs: [], end: 0 };
20
+ let close = -1;
21
+ for (let i = 1; i < lines.length; i++)
22
+ if (/^(---|\.\.\.)\s*$/.test(lines[i])) {
23
+ close = i;
24
+ break;
25
+ }
26
+ if (close < 0)
27
+ return { pairs: [], end: 0 };
28
+ const pairs = [];
29
+ let key = "";
30
+ for (const raw of lines.slice(1, close)) {
31
+ const item = raw.match(/^\s+-\s+(.*)$/);
32
+ if (item && key) {
33
+ const v = unquote(item[1]);
34
+ if (v)
35
+ pairs.push([key, v]);
36
+ continue;
37
+ }
38
+ const kv = raw.match(/^([A-Za-zÀ-ÿ0-9_-]+)\s*:\s*(.*)$/);
39
+ if (!kv)
40
+ continue;
41
+ key = kv[1].toLowerCase();
42
+ const val = kv[2].trim();
43
+ if (!val)
44
+ continue;
45
+ if (val.startsWith("[") && val.endsWith("]"))
46
+ for (const v of val.slice(1, -1).split(",")) {
47
+ const u = unquote(v);
48
+ if (u)
49
+ pairs.push([key, u]);
50
+ }
51
+ else
52
+ pairs.push([key, unquote(val)]);
53
+ }
54
+ return { pairs, end: close + 1 };
55
+ }
56
+ export function extractMarkdown(src, file) {
57
+ const lines = src.split(/\r?\n/);
58
+ const syms = [];
59
+ const imports = [];
60
+ const seen = new Set();
61
+ const add = (name, kind, line, endLine = line) => {
62
+ const id = `${file}::${name}:${line}`;
63
+ if (seen.has(id))
64
+ return;
65
+ seen.add(id);
66
+ syms.push({ id, name, kind, file, startLine: line, endLine });
67
+ };
68
+ const fm = frontmatter(lines);
69
+ for (const [k, v] of fm.pairs) {
70
+ if (/^(null|~|none)$/i.test(v))
71
+ continue; // campo vazio (concluida_em: null) é ruído
72
+ add(clip(`${k}:${v}`), "tag", 1, fm.end);
73
+ // valor que é o nome de outra nota (pessoas: [adonis-santos]) vira link; sem nota com esse nome, não liga nada
74
+ if (/^[\w][\w.-]{2,}$/.test(v) && !/^\d/.test(v))
75
+ imports.push(WIKI_PREFIX + v.toLowerCase());
76
+ // número em campo plural (pendencias: [14]) aponta para a nota com `id: 14` na pasta de mesmo nome (pendencias/)
77
+ else if (/^\d+$/.test(v) && k !== "id")
78
+ imports.push(`${IDREF_PREFIX}${k}:${v}`);
79
+ }
80
+ const heads = [];
81
+ let fence = null;
82
+ const refs = new Set();
83
+ for (let i = fm.end; i < lines.length; i++) {
84
+ const line = lines[i];
85
+ const f = line.match(/^\s*(```+|~~~+)/);
86
+ if (f) {
87
+ fence = fence === null ? f[1][0] : fence === f[1][0] ? null : fence;
88
+ continue;
89
+ }
90
+ if (fence !== null)
91
+ continue;
92
+ const h = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
93
+ if (h)
94
+ heads.push({ level: h[1].length, name: clip(h[2].replace(/[*_`]/g, "")), line: i + 1 });
95
+ // texto sem código inline: não interpreta `[[x]]` nem `#1` dentro de crase
96
+ const text = line.replace(/`[^`]*`/g, "");
97
+ for (const m of text.matchAll(/\[\[([^\]\n]+)\]\]/g)) {
98
+ const target = m[1].split("|")[0].split("#")[0].trim();
99
+ if (target)
100
+ imports.push(WIKI_PREFIX + target.toLowerCase());
101
+ }
102
+ for (const m of text.matchAll(/(?<!!)\[[^\]\n]*\]\(\s*<?([^)\s>]+)>?[^)]*\)/g)) {
103
+ let t = m[1];
104
+ if (/^([a-z][a-z0-9+.-]*:|#|\/\/)/i.test(t))
105
+ continue; // http:, mailto:, âncora
106
+ t = t.split("#")[0].split("?")[0];
107
+ if (!t)
108
+ continue;
109
+ try {
110
+ t = decodeURIComponent(t);
111
+ }
112
+ catch { /* mantém como veio */ }
113
+ imports.push(t.startsWith(".") || t.startsWith("/") ? t : "./" + t);
114
+ }
115
+ const body = h ? "" : text.replace(/\[\[[^\]]*\]\]|\]\([^)]*\)/g, " "); // não conta URL/âncora como tag
116
+ for (const m of body.matchAll(/(?:^|[\s(])#(\d+)\b/g))
117
+ refs.add("#" + m[1]);
118
+ for (const m of body.matchAll(/(?:^|[\s(])#([A-Za-zÀ-ÿ][\w/-]*)/g))
119
+ add("#" + m[1].toLowerCase(), "tag", i + 1);
120
+ }
121
+ for (const r of refs) {
122
+ const line = lines.findIndex((l, i) => i >= fm.end && l.includes(r)) + 1;
123
+ add(r, "ref", line || 1);
124
+ }
125
+ heads.forEach((h, i) => {
126
+ const next = heads.slice(i + 1).find((x) => x.level <= h.level);
127
+ add(h.name, "heading", h.line, next ? next.line - 1 : lines.length);
128
+ });
129
+ return { syms, imports };
130
+ }
131
+ /** Resolve `wiki:nome` para notas indexadas pelo nome do arquivo (sem extensão, sem caixa) ou pelo caminho relativo. */
132
+ export class WikiIndex {
133
+ byBase = new Map();
134
+ constructor(files) {
135
+ for (const f of files) {
136
+ const dot = f.lastIndexOf(".");
137
+ if (!MD_EXT.has(dot < 0 ? "" : f.slice(dot).toLowerCase()))
138
+ continue;
139
+ const noExt = f.slice(0, dot).toLowerCase();
140
+ for (const key of new Set([noExt.split("/").pop(), noExt]))
141
+ (this.byBase.get(key) ?? this.byBase.set(key, []).get(key)).push(f);
142
+ }
143
+ }
144
+ /** Vários candidatos = nome ambíguo: liga a todos (o usuário vê o conflito no grafo). */
145
+ resolve(spec) {
146
+ const t = spec.slice(WIKI_PREFIX.length).replace(/\.(md|markdown)$/i, "");
147
+ return this.byBase.get(t) ?? [];
148
+ }
149
+ }
150
+ /** Resolve `idref:<campo>:<n>` para a nota da pasta `<campo>/` cujo frontmatter tem `id: n` (pendencias: [14] → pendencias/014-….md). */
151
+ export class IdIndex {
152
+ byKey = new Map();
153
+ /** tags: pares (arquivo, nome do tag) com nome `id:N` */
154
+ constructor(tags) {
155
+ for (const t of tags) {
156
+ const m = /^id:(\d+)$/.exec(t.name);
157
+ if (!m)
158
+ continue;
159
+ const folder = t.file.includes("/") ? t.file.split("/")[0].toLowerCase() : "";
160
+ const key = `${folder}:${Number(m[1])}`;
161
+ (this.byKey.get(key) ?? this.byKey.set(key, []).get(key)).push(t.file);
162
+ }
163
+ }
164
+ resolve(spec) {
165
+ const m = /^idref:([^:]+):(\d+)$/.exec(spec);
166
+ return m ? this.byKey.get(`${m[1].toLowerCase()}:${Number(m[2])}`) ?? [] : [];
167
+ }
168
+ }
@@ -24,7 +24,7 @@ export const MCP_NAME = "brito";
24
24
  export const MCP_INSTRUCTIONS = [
25
25
  "Brito: grafo de código, memória e skills do projeto.",
26
26
  "- Para localizar código, use graph_search/graph_context antes de ler arquivos; antes de alterar, meça o impacto com graph_callers/graph_impact. O grafo se atualiza sozinho após edições (graph_index só força).",
27
- `- O grafo cobre ${INDEXED_EXTENSIONS.join(", ")} (em .svelte/.vue/.astro, o script e as chamadas do template). Imports relativos e por alias (paths/baseUrl do tsconfig, imports do package.json, $lib do SvelteKit) viram arestas. Em Java, Kotlin, Go, Ruby, PHP, C# e Rust os imports são resolvidos pelo pacote/namespace/módulo e o receptor de uma chamada é casado com o tipo pelo nome (sem inferência de tipos). Outras linguagens e arquivos ignorados pelo .gitignore não entram: use busca de texto para eles.`,
27
+ `- O grafo cobre ${INDEXED_EXTENSIONS.join(", ")} (em .svelte/.vue/.astro, o script e as chamadas do template). Imports relativos e por alias (paths/baseUrl do tsconfig, imports do package.json, $lib do SvelteKit) viram arestas. Em Java, Kotlin, Go, Ruby, PHP, C# e Rust os imports são resolvidos pelo pacote/namespace/módulo e o receptor de uma chamada é casado com o tipo pelo nome (sem inferência de tipos). Em .md o grafo guarda headings, tags (frontmatter e #tag), refs (#123) e links/[[wikilinks]] entre notas: graph_impact numa nota dá os backlinks; graph_search kind=tag/ref/heading acha por assunto. Para vaults de notas prefira graph_notes_find (filtros por campo, data, link e #N), graph_notes_backlinks e graph_notes_path (segue relações entre notas em uma chamada), sem escrever Cypher. Outras linguagens e arquivos ignorados pelo .gitignore não entram: use busca de texto para eles.`,
28
28
  "- recall/remember: notas duráveis (decisões, convenções, bugs descobertos). Consulte recall ao começar uma tarefa.",
29
29
  "- skill_load: carregue a skill antes de uma tarefa que ela cobre. No modo \"tudo liberado\", skill_save grava em teste; só o arriscado (comando destrutivo, mudança em skill aprovada) vira proposta pendente para /skills approve.",
30
30
  ].join("\n");
@@ -0,0 +1,171 @@
1
+ import { z } from "zod";
2
+ import { defineTool } from "./registry.js";
3
+ /**
4
+ * Travessia de relações entre notas .md em uma chamada (ex.: calls de outubro → pendências abertas → donos).
5
+ * O grafo tipado é montado em memória a partir do índice (tags do frontmatter, refs #N e links), sem mudar o banco:
6
+ * - relação = nome do campo do frontmatter: `pessoas: [ana]` em A cria A -[pessoas]-> pessoas/ana.md (valor = nome de arquivo);
7
+ * `pendencias: [14]` cria A -[pendencias]-> nota da pasta pendencias/ com `id: 14`.
8
+ * - `cita`: nota que menciona #N → nota com `id: N`. `link`: link Markdown ou [[wikilink]].
9
+ */
10
+ const OPS = ["=", "!=", "<", "<=", ">", ">="];
11
+ const cmp = (a, b) => {
12
+ const na = Number(a), nb = Number(b);
13
+ if (a.trim() !== "" && b.trim() !== "" && Number.isFinite(na) && Number.isFinite(nb))
14
+ return na - nb;
15
+ return a < b ? -1 : a > b ? 1 : 0;
16
+ };
17
+ const test = (v, op, ref) => { const c = cmp(v, ref); return op === "=" ? c === 0 : op === "!=" ? c !== 0 : op === "<" ? c < 0 : op === "<=" ? c <= 0 : op === ">" ? c > 0 : c >= 0; };
18
+ export async function buildNotesGraph(graph) {
19
+ const files = (await graph.run("MATCH (f:File) WHERE f.lang = 'markdown' RETURN f.path AS path")).map((r) => String(r.path));
20
+ const props = new Map();
21
+ const idOf = new Map(); // `${pasta}\t${n}` e `*\t${n}` → arquivos
22
+ const byBase = new Map();
23
+ const push = (m, k, v) => (m.get(k) ?? m.set(k, []).get(k)).push(v);
24
+ for (const f of files) {
25
+ props.set(f, new Map());
26
+ push(byBase, f.replace(/\.(md|markdown)$/i, "").split("/").pop().toLowerCase(), f);
27
+ }
28
+ for (const r of await graph.run("MATCH (f:File)-[:DEFINES]->(s:Symbol) WHERE f.lang = 'markdown' AND s.kind = 'tag' RETURN f.path AS path, s.name AS name")) {
29
+ const name = String(r.name);
30
+ const i = name.indexOf(":");
31
+ if (i < 0)
32
+ continue;
33
+ const m = props.get(String(r.path));
34
+ if (!m)
35
+ continue;
36
+ push(m, name.slice(0, i).toLowerCase(), name.slice(i + 1));
37
+ }
38
+ for (const [f, m] of props)
39
+ for (const id of m.get("id") ?? [])
40
+ if (/^\d+$/.test(id)) {
41
+ push(idOf, `${f.split("/")[0].toLowerCase()}\t${Number(id)}`, f);
42
+ push(idOf, `*\t${Number(id)}`, f);
43
+ }
44
+ const g = { files, props, out: new Map(), inn: new Map() };
45
+ const add = (rel, a, b) => {
46
+ if (a === b)
47
+ return;
48
+ (g.out.get(`${rel}\t${a}`) ?? g.out.set(`${rel}\t${a}`, new Set()).get(`${rel}\t${a}`)).add(b);
49
+ (g.inn.get(`${rel}\t${b}`) ?? g.inn.set(`${rel}\t${b}`, new Set()).get(`${rel}\t${b}`)).add(a);
50
+ };
51
+ for (const [f, m] of props)
52
+ for (const [k, vs] of m) {
53
+ if (k === "id")
54
+ continue;
55
+ for (const v of vs) {
56
+ if (/^\d+$/.test(v))
57
+ for (const t of idOf.get(`${k}\t${Number(v)}`) ?? [])
58
+ add(k, f, t);
59
+ else if (!/^\d/.test(v))
60
+ for (const t of byBase.get(v.toLowerCase()) ?? [])
61
+ add(k, f, t); // data (2026-10-26) não é nome de nota
62
+ }
63
+ }
64
+ for (const r of await graph.run("MATCH (s:Symbol) WHERE s.kind = 'ref' RETURN s.file AS file, s.name AS name")) {
65
+ const n = String(r.name).slice(1);
66
+ for (const t of idOf.get(`*\t${Number(n)}`) ?? [])
67
+ add("cita", String(r.file), t);
68
+ }
69
+ for (const r of await graph.run("MATCH (a:File)-[:IMPORTS]->(b:File) WHERE a.lang = 'markdown' AND b.lang = 'markdown' RETURN a.path AS a, b.path AS b"))
70
+ add("link", String(r.a), String(r.b));
71
+ return g;
72
+ }
73
+ const matches = (g, f, wh) => (wh ?? []).every((w) => {
74
+ if (w.missing)
75
+ return !(w.missing.dir === "in" ? g.inn : g.out).get(`${w.missing.rel}\t${f}`)?.size;
76
+ const vs = g.props.get(f)?.get((w.key ?? "").toLowerCase()) ?? [];
77
+ const op = w.op ?? "=";
78
+ return op === "!=" ? vs.length > 0 && vs.every((v) => test(v, op, w.value ?? "")) : vs.some((v) => test(v, op, w.value ?? ""));
79
+ });
80
+ /** Chamada sem argumentos: mostra o que existe no vault (relações com pastas de origem/destino e campos por pasta). */
81
+ export function describeNotesGraph(g) {
82
+ const top = (f) => (f.includes("/") ? f.split("/")[0] + "/" : "(raiz)");
83
+ const rels = new Map();
84
+ for (const [k, dsts] of g.out) {
85
+ const [rel, src] = k.split("\t");
86
+ const r = rels.get(rel) ?? rels.set(rel, { n: 0, pairs: new Map() }).get(rel);
87
+ for (const d of dsts) {
88
+ r.n++;
89
+ const pair = `${top(src)}→${top(d)}`;
90
+ r.pairs.set(pair, (r.pairs.get(pair) ?? 0) + 1);
91
+ }
92
+ }
93
+ const lines = ["Relações (dir out = da nota que tem o campo para a nota citada):"];
94
+ for (const [rel, r] of [...rels].sort((a, b) => b[1].n - a[1].n))
95
+ lines.push(` ${rel} (${r.n}): ${[...r.pairs].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([p, n]) => `${p} ${n}`).join(", ")}`);
96
+ const fields = new Map();
97
+ for (const [f, m] of g.props)
98
+ for (const k of m.keys()) {
99
+ const t = top(f);
100
+ const c = fields.get(t) ?? fields.set(t, new Map()).get(t);
101
+ c.set(k, (c.get(k) ?? 0) + 1);
102
+ }
103
+ lines.push("Campos do frontmatter por pasta:");
104
+ for (const [t, c] of [...fields].sort((a, b) => (a[0] < b[0] ? -1 : 1)))
105
+ lines.push(` ${t}: ${[...c].sort((a, b) => b[1] - a[1]).slice(0, 14).map(([k]) => k).join(", ")}`);
106
+ return lines.join("\n");
107
+ }
108
+ /** Falha alto em chave desconhecida: ignorar um filtro em silêncio devolve resposta errada com cara de certa. `has: ['k:v']` vira where k = v. */
109
+ function checked(o, allowed, ctx) {
110
+ if (!o)
111
+ return o;
112
+ for (const k of Object.keys(o))
113
+ if (!allowed.includes(k))
114
+ throw new Error(`chave desconhecida "${k}" em ${ctx}. Use: ${allowed.join(", ")}`);
115
+ if (!o.has?.length)
116
+ return o;
117
+ const extra = o.has.map((h) => { const i = h.indexOf(":"); return { key: h.slice(0, i), op: "=", value: h.slice(i + 1) }; });
118
+ return { ...o, where: [...(o.where ?? []), ...extra] };
119
+ }
120
+ export function traverse(g, rawSpec) {
121
+ for (const k of Object.keys(rawSpec))
122
+ if (!["from", "steps", "return", "limit"].includes(k))
123
+ throw new Error(`chave desconhecida "${k}" na spec. Use: from, steps, return, limit`);
124
+ const spec = { ...rawSpec, from: checked(rawSpec.from, ["folder", "where", "has"], "from"), steps: rawSpec.steps?.map((st, i) => checked(st, ["rel", "dir", "folder", "where", "has"], `steps[${i}]`)) };
125
+ if (!spec.from && !spec.steps && !spec.return)
126
+ return describeNotesGraph(g);
127
+ let cur = new Set(g.files.filter((f) => (!spec.from?.folder || f.startsWith(spec.from.folder)) && matches(g, f, spec.from?.where)));
128
+ for (const st of spec.steps ?? []) {
129
+ const next = new Set();
130
+ for (const f of cur)
131
+ for (const t of (st.dir === "in" ? g.inn : g.out).get(`${st.rel}\t${f}`) ?? [])
132
+ next.add(t);
133
+ cur = new Set([...next].filter((f) => (!st.folder || f.startsWith(st.folder)) && matches(g, f, st.where)));
134
+ }
135
+ const limit = spec.limit ?? 100;
136
+ const list = [...cur].sort();
137
+ const ret = spec.return ?? "id";
138
+ if (ret === "count")
139
+ return String(list.length);
140
+ if (ret !== "id") {
141
+ const cnt = new Map();
142
+ for (const f of list)
143
+ for (const v of new Set(g.props.get(f)?.get(ret.toLowerCase()) ?? []))
144
+ cnt.set(v, (cnt.get(v) ?? 0) + 1);
145
+ const rows = [...cnt].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
146
+ return `${list.length} notas; valores de "${ret}":\n` + (rows.length ? rows.slice(0, limit).map(([v, n]) => `${v} (${n})`).join("\n") : "(vazio)");
147
+ }
148
+ return `${list.length} notas${list.length > limit ? ` (mostrando ${limit})` : ""}:\n` + (list.length ? list.slice(0, limit).join("\n") : "(vazio)");
149
+ }
150
+ const whereSchema = z.object({ key: z.string().optional(), op: z.enum(OPS).optional(), value: z.string().optional(), missing: z.object({ rel: z.string(), dir: z.enum(["in", "out"]).optional() }).optional() });
151
+ defineTool({
152
+ name: "graph_notes_path",
153
+ concurrent: true,
154
+ readsGraph: true,
155
+ description: "Segue relações entre notas .md em uma chamada (ex.: calls de outubro → pendências abertas que elas tratam → dono). " +
156
+ "Relação = nome de um campo do frontmatter: `pessoas: [ana]` liga a nota a pessoas/ana.md; `pendencias: [14]` liga à nota da pasta pendencias/ com `id: 14`; `dono`, `desbloqueia` etc. igual. " +
157
+ "Também existem `cita` (menção #N no texto, mais ampla que o campo do frontmatter) e `link` (link Markdown/wikilink); para vínculo formal prefira o campo do frontmatter. dir 'out' segue da nota que tem o campo para a nota citada; 'in' segue ao contrário. " +
158
+ "O resultado é o conjunto do ÚLTIMO passo, não o de partida: para listar quem cita/trata X, parta de X e siga dir 'in'. " +
159
+ "Spec: from {folder, where}, steps [{rel, dir, folder?, where?}], return ('id' lista notas, 'count', ou nome de um campo para listar seus valores). " +
160
+ "where: {key, op (= != < <= > >=), value} ou has: ['campo:valor'] (igualdade) sobre campos do frontmatter (datas ISO e números comparam certo) ou {missing:{rel,dir}} (nota sem aquela relação). " +
161
+ "Chamada com {} lista as relações e os campos do vault (use antes de montar a spec). Não cobre texto do corpo além de #N e links: para achar palavra ou menção em prosa, use grep.",
162
+ schema: z.object({
163
+ from: z.object({ folder: z.string().optional(), where: z.array(whereSchema).optional(), has: z.array(z.string()).optional() }).strict().optional(),
164
+ steps: z.array(z.object({ rel: z.string(), dir: z.enum(["in", "out"]).optional(), folder: z.string().optional(), where: z.array(whereSchema).optional(), has: z.array(z.string()).optional() }).strict()).optional(),
165
+ return: z.string().optional(),
166
+ limit: z.number().int().min(1).max(500).optional(),
167
+ }).strict(),
168
+ async run(spec, { graph }) {
169
+ return traverse(await buildNotesGraph(graph), spec);
170
+ },
171
+ });
@@ -0,0 +1,129 @@
1
+ import { z } from "zod";
2
+ import { defineTool } from "./registry.js";
3
+ /**
4
+ * Ferramentas para vaults de notas .md: escondem o Cypher e respondem as perguntas de relação, filtro e data
5
+ * que o agente faria com grep+script. Os dados vêm do indexador de markdown (tags do frontmatter, refs #N, links).
6
+ */
7
+ const OPS = ["=", "!=", "<", "<=", ">", ">="];
8
+ /** Compara como número quando os dois lados são numéricos; senão como texto (datas ISO ordenam certo). */
9
+ function cmp(a, b) {
10
+ const na = Number(a), nb = Number(b);
11
+ if (a.trim() !== "" && b.trim() !== "" && Number.isFinite(na) && Number.isFinite(nb))
12
+ return na - nb;
13
+ return a < b ? -1 : a > b ? 1 : 0;
14
+ }
15
+ const test = (v, op, ref) => {
16
+ const c = cmp(v, ref);
17
+ return op === "=" ? c === 0 : op === "!=" ? c !== 0 : op === "<" ? c < 0 : op === "<=" ? c <= 0 : op === ">" ? c > 0 : c >= 0;
18
+ };
19
+ /** Nome do Symbol tag → [chave, valor]. `#urgente` vira ["tag","urgente"]. */
20
+ function splitTag(name) {
21
+ if (name.startsWith("#"))
22
+ return ["tag", name.slice(1)];
23
+ const i = name.indexOf(":");
24
+ return i < 0 ? [name, ""] : [name.slice(0, i).toLowerCase(), name.slice(i + 1)];
25
+ }
26
+ const FIND_KEYS = ["folder", "has", "where", "links_to", "not_linked_from", "mentions", "pick", "limit"];
27
+ export async function findNotes(graph, p) {
28
+ for (const k of Object.keys(p))
29
+ if (!FIND_KEYS.includes(k))
30
+ throw new Error(`chave desconhecida "${k}". Use: ${FIND_KEYS.join(", ")}`); // filtro ignorado em silêncio = resposta errada
31
+ const folder = p.folder ?? "";
32
+ const files = (await graph.run(`MATCH (f:File) WHERE f.lang = 'markdown' ${folder ? "AND f.path STARTS WITH $p" : ""} RETURN f.path AS path`, folder ? { p: folder } : {})).map((r) => String(r.path));
33
+ let set = new Set(files);
34
+ const needTags = (p.has?.length ?? 0) > 0 || (p.where?.length ?? 0) > 0 || !!p.pick;
35
+ const tags = new Map();
36
+ if (needTags) {
37
+ for (const r of await graph.run("MATCH (f:File)-[:DEFINES]->(s:Symbol) WHERE f.lang = 'markdown' AND s.kind = 'tag' RETURN f.path AS path, s.name AS name")) {
38
+ if (!set.has(String(r.path)))
39
+ continue;
40
+ const [k, v] = splitTag(String(r.name));
41
+ const m = tags.get(r.path) ?? tags.set(r.path, new Map()).get(r.path);
42
+ (m.get(k) ?? m.set(k, []).get(k)).push(v);
43
+ }
44
+ }
45
+ const valuesOf = (path, key) => tags.get(path)?.get(key.toLowerCase()) ?? [];
46
+ for (const h of p.has ?? []) {
47
+ const [k, ...rest] = h.split(":");
48
+ const v = rest.join(":");
49
+ set = new Set([...set].filter((f) => valuesOf(f, k).includes(v)));
50
+ }
51
+ for (const w of p.where ?? []) {
52
+ set = new Set([...set].filter((f) => {
53
+ const vs = valuesOf(f, w.key);
54
+ return w.op === "!=" ? vs.length > 0 && vs.every((v) => test(v, w.op, w.value)) : vs.some((v) => test(v, w.op, w.value));
55
+ }));
56
+ }
57
+ if (p.links_to) {
58
+ const src = new Set((await graph.run("MATCH (a:File)-[:IMPORTS]->(b:File {path:$t}) RETURN a.path AS path", { t: p.links_to })).map((r) => String(r.path)));
59
+ set = new Set([...set].filter((f) => src.has(f)));
60
+ }
61
+ if (p.not_linked_from) {
62
+ const hit = new Set((await graph.run("MATCH (a:File)-[:IMPORTS]->(b:File) WHERE a.path STARTS WITH $p RETURN DISTINCT b.path AS path", { p: p.not_linked_from })).map((r) => String(r.path)));
63
+ set = new Set([...set].filter((f) => !hit.has(f)));
64
+ }
65
+ if (p.mentions) {
66
+ const n = p.mentions.startsWith("#") ? p.mentions : "#" + p.mentions;
67
+ const m = new Set((await graph.run("MATCH (s:Symbol) WHERE s.kind = 'ref' AND s.name = $n RETURN s.file AS file", { n })).map((r) => String(r.file)));
68
+ set = new Set([...set].filter((f) => m.has(f)));
69
+ }
70
+ const limit = p.limit ?? 100;
71
+ const list = [...set].sort();
72
+ if (p.pick) {
73
+ const cnt = new Map();
74
+ for (const f of list)
75
+ for (const v of new Set(valuesOf(f, p.pick)))
76
+ cnt.set(v, (cnt.get(v) ?? 0) + 1);
77
+ const rows = [...cnt].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
78
+ return `${list.length} notas; ${rows.length} valores distintos de "${p.pick}":\n` + (rows.length ? rows.slice(0, limit).map(([v, n]) => `${v} (${n})`).join("\n") : "(vazio)");
79
+ }
80
+ return `${list.length} notas${list.length > limit ? ` (mostrando ${limit})` : ""}:\n` + (list.length ? list.slice(0, limit).join("\n") : "(vazio)");
81
+ }
82
+ export async function backlinks(graph, file) {
83
+ const linked = (await graph.run("MATCH (a:File)-[:IMPORTS]->(b:File {path:$f}) RETURN a.path AS path", { f: file })).map((r) => String(r.path));
84
+ const idTag = (await graph.run("MATCH (f:File {path:$f})-[:DEFINES]->(s:Symbol) WHERE s.kind = 'tag' AND s.name STARTS WITH 'id:' RETURN s.name AS name", { f: file }))[0];
85
+ let mentioned = [];
86
+ const id = idTag ? String(idTag.name).slice(3) : "";
87
+ if (/^\d+$/.test(id)) {
88
+ mentioned = (await graph.run("MATCH (s:Symbol) WHERE s.kind = 'ref' AND s.name = $n RETURN DISTINCT s.file AS file", { n: "#" + id })).map((r) => String(r.file)).filter((f) => f !== file);
89
+ }
90
+ const all = new Set([...linked, ...mentioned]);
91
+ if (!all.size)
92
+ return "(nenhuma nota aponta para este arquivo)";
93
+ const rows = [...all].sort().map((f) => `${f} [${linked.includes(f) ? "link" : ""}${linked.includes(f) && mentioned.includes(f) ? "+" : ""}${mentioned.includes(f) ? "menção #" + id : ""}]`);
94
+ return `${all.size} notas apontam para ${file}:\n` + rows.join("\n");
95
+ }
96
+ defineTool({
97
+ name: "graph_notes_find",
98
+ concurrent: true,
99
+ readsGraph: true,
100
+ description: "Acha notas .md por pasta, campo do frontmatter, data, links e menções #N, sem escrever Cypher. Todos os filtros se combinam (E). " +
101
+ "folder: prefixo de caminho (ex. 'pendencias/'). has: ['status:aberta','produto:Predize'] (igualdade). " +
102
+ "where: [{key:'prazo',op:'<',value:'2026-11-12'}] (op = != < <= > >=; datas ISO e números comparam certo). " +
103
+ "links_to: notas que linkam para esse caminho. not_linked_from: exclui notas linkadas por qualquer arquivo dessa pasta (ex. pendências sem call: folder 'pendencias/', has status:aberta, not_linked_from 'calls/'). " +
104
+ "mentions: '#85' (menção no texto). pick: devolve os valores distintos de um campo no conjunto (ex. pick 'pessoas') em vez da lista de notas. " +
105
+ "Use para perguntas de relação, filtro e data sobre notas; para achar uma palavra no corpo, use grep.",
106
+ schema: z.object({
107
+ folder: z.string().optional(),
108
+ has: z.array(z.string()).optional(),
109
+ where: z.array(z.object({ key: z.string(), op: z.enum(OPS), value: z.string() })).optional(),
110
+ links_to: z.string().optional(),
111
+ not_linked_from: z.string().optional(),
112
+ mentions: z.string().optional(),
113
+ pick: z.string().optional(),
114
+ limit: z.number().int().min(1).max(500).optional(),
115
+ }).strict(),
116
+ async run(args, { graph }) {
117
+ return findNotes(graph, args);
118
+ },
119
+ });
120
+ defineTool({
121
+ name: "graph_notes_backlinks",
122
+ concurrent: true,
123
+ readsGraph: true,
124
+ description: "Quem aponta para uma nota .md: notas que a linkam e, se ela tem `id: N` no frontmatter, notas que a mencionam como #N no texto. Use antes de grep para achar o que se liga a uma pessoa, pendência ou decisão.",
125
+ schema: z.object({ file: z.string() }),
126
+ async run({ file }, { graph }) {
127
+ return backlinks(graph, file);
128
+ },
129
+ });
@@ -3,6 +3,8 @@ import crypto from "node:crypto";
3
3
  import { defineTool } from "./registry.js";
4
4
  import { indexProject } from "../graph/indexer.js";
5
5
  import { listSkills } from "../skills/store.js";
6
+ import "./graph-notes.js";
7
+ import "./graph-notes-path.js";
6
8
  const fmt = (rows, cols) => (rows.length ? rows.map((r) => cols.map((c) => r[c]).join(" ")).join("\n") : "(vazio)");
7
9
  defineTool({
8
10
  name: "graph_index",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brito-cli",
3
- "version": "0.13.2",
3
+ "version": "0.14.0",
4
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
5
  "type": "module",
6
6
  "bin": {