brito-cli 0.13.3 → 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 +2 -2
- package/README.pt-BR.md +2 -2
- package/dist/graph/indexer.js +20 -7
- package/dist/graph/markdown.js +168 -0
- package/dist/mcp/server.js +1 -1
- package/dist/tools/graph-notes-path.js +171 -0
- package/dist/tools/graph-notes.js +129 -0
- package/dist/tools/graph.js +2 -0
- package/package.json +1 -1
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.
|
package/dist/graph/indexer.js
CHANGED
|
@@ -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 = "
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
+
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
+
});
|
package/dist/tools/graph.js
CHANGED
|
@@ -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.
|
|
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": {
|