brito-cli 0.13.3 → 0.15.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/cli.js +2 -1
- package/dist/desktop/serve.js +1 -0
- package/dist/graph/indexer.js +24 -8
- package/dist/graph/markdown.js +168 -0
- package/dist/graph/sections.js +244 -0
- package/dist/graph/store.js +12 -0
- package/dist/graph/vault.js +108 -0
- package/dist/mcp/server.js +3 -2
- 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/dist/tools/vault.js +17 -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/cli.js
CHANGED
|
@@ -29,6 +29,7 @@ import { addRule, loadRules, projectSettingsPath } from "./rules.js";
|
|
|
29
29
|
import { serveMcp } from "./mcp/server.js";
|
|
30
30
|
import "./tools/skills.js";
|
|
31
31
|
import "./tools/digest.js";
|
|
32
|
+
import "./tools/vault.js";
|
|
32
33
|
import { adviseSkill, approvePending, lintSkill, listBuiltins, listPending, listSkills, rejectPending, skillsIndex, staleRefs } from "./skills/store.js";
|
|
33
34
|
import { applyProposal, autoConsolidate, describeProposal, proposeConsolidation, pruneCandidates } from "./skills/consolidate.js";
|
|
34
35
|
import { defaultComplete, recordOutcomes, reflect, shouldReflect } from "./skills/learn.js";
|
|
@@ -57,7 +58,7 @@ program
|
|
|
57
58
|
const g = await GraphStore.open(root);
|
|
58
59
|
const t = Date.now();
|
|
59
60
|
const s = await indexProject(root, g, { full: o.full, onProgress: (m) => console.log(m) });
|
|
60
|
-
console.log(`OK: ${s.files} arquivos, ${s.symbols} símbolos, ${s.calls} chamadas, ${s.imports} imports | +${s.added} ~${s.changed} -${s.removed} =${s.unchanged} (${Date.now() - t}ms)`);
|
|
61
|
+
console.log(`OK: ${s.files} arquivos, ${s.symbols} símbolos, ${s.calls} chamadas, ${s.imports} imports | +${s.added} ~${s.changed} -${s.removed} =${s.unchanged}${s.docs ? ` | ${s.docs} docs, ${s.secoes} seções, ${s.ligas} ligações, ${s.citas} citações, ${s.linksQuebrados} links sem destino` : ""} (${Date.now() - t}ms)`);
|
|
61
62
|
await g.close();
|
|
62
63
|
});
|
|
63
64
|
program
|
package/dist/desktop/serve.js
CHANGED
|
@@ -17,6 +17,7 @@ import "../tools/implement.js";
|
|
|
17
17
|
import "../tools/spec.js";
|
|
18
18
|
import "../tools/skills.js";
|
|
19
19
|
import "../tools/digest.js";
|
|
20
|
+
import "../tools/vault.js";
|
|
20
21
|
import "../tools/todo.js";
|
|
21
22
|
import { Agent } from "../agent/loop.js";
|
|
22
23
|
import { loadSession, repairHistory, saveSession, titleOf } from "../agent/sessions.js";
|
package/dist/graph/indexer.js
CHANGED
|
@@ -7,6 +7,8 @@ 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";
|
|
11
|
+
import { indexSections } from "./sections.js";
|
|
10
12
|
import { EXTRA_EXT, ProjectIndex, extractLang, isExtraLang } from "./langs.js";
|
|
11
13
|
const require = createRequire(import.meta.url);
|
|
12
14
|
const WASM_DIR = path.dirname(require.resolve("tree-sitter-wasms/package.json")) + "/out";
|
|
@@ -14,6 +16,7 @@ const EXT = {
|
|
|
14
16
|
".ts": "typescript", ".mts": "typescript", ".cts": "typescript",
|
|
15
17
|
".tsx": "tsx", ".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
|
|
16
18
|
".py": "python",
|
|
19
|
+
".md": "markdown", ".markdown": "markdown",
|
|
17
20
|
...EXTRA_EXT,
|
|
18
21
|
};
|
|
19
22
|
/** 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 +191,7 @@ function resolveImport(from, spec, known, aliases) {
|
|
|
188
191
|
}
|
|
189
192
|
return null;
|
|
190
193
|
}
|
|
191
|
-
const INDEXER_VERSION = "
|
|
194
|
+
const INDEXER_VERSION = "v7";
|
|
192
195
|
const TYPE_KINDS = new Set(["class", "interface", "struct", "trait", "enum", "module", "type"]);
|
|
193
196
|
const JS_LIKE_RE = /\.(m|c)?[jt]sx?$|\.py$|\.svelte$|\.vue$|\.astro$/;
|
|
194
197
|
const SEP = "\t";
|
|
@@ -245,7 +248,9 @@ const count = async (store, q) => Number((await store.run(q))[0].n);
|
|
|
245
248
|
export async function indexProject(root, store, opts = {}) {
|
|
246
249
|
const release = await acquireIndexLock(root, opts.onProgress);
|
|
247
250
|
try {
|
|
248
|
-
|
|
251
|
+
const code = await indexProjectLocked(root, store, opts);
|
|
252
|
+
const md = await indexSections(root, store, opts);
|
|
253
|
+
return { ...code, docs: md.docs, secoes: md.secoes, ligas: md.ligas, citas: md.citas, linksQuebrados: md.unresolved, mdChanged: md.added + md.changed + md.removed };
|
|
249
254
|
}
|
|
250
255
|
finally {
|
|
251
256
|
release();
|
|
@@ -341,9 +346,15 @@ async function indexProjectLocked(root, store, opts = {}) {
|
|
|
341
346
|
// Componente (.svelte/.vue/.astro): o parser vê só os blocos de script; o template vira chamadas de um símbolo próprio.
|
|
342
347
|
const sfc = SFC_EXT.has(ext) ? splitSfc(src, ext) : null;
|
|
343
348
|
const lang = sfc ? (sfc.tsx ? "tsx" : "typescript") : EXT[ext];
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
349
|
+
let info;
|
|
350
|
+
if (MD_EXT.has(ext.toLowerCase())) { // markdown: sem parser, só estrutura e links
|
|
351
|
+
info = { ...extractMarkdown(src, rel), calls: [], bindings: [] };
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
const tree = (await getParser(lang)).parse(sfc ? sfc.script : src);
|
|
355
|
+
info = isExtraLang(lang) ? extractLang(tree.rootNode, lang, rel) : extract(tree.rootNode, lang, rel);
|
|
356
|
+
tree.delete();
|
|
357
|
+
}
|
|
347
358
|
if (sfc) {
|
|
348
359
|
const name = componentName(rel);
|
|
349
360
|
const comp = { id: `${rel}::${name}:1`, name, kind: "component", file: rel, startLine: 1, endLine: src.split("\n").length };
|
|
@@ -355,9 +366,8 @@ async function indexProjectLocked(root, store, opts = {}) {
|
|
|
355
366
|
const calls = [...new Set(info.calls.filter((c) => c.callerId).map((c) => [c.callerId, c.callee, c.obj].join(SEP)))];
|
|
356
367
|
const bindings = [...new Set(info.bindings.map((b) => b.join(SEP)))];
|
|
357
368
|
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 });
|
|
369
|
+
for (const s of info.syms) { // símbolo e DEFINES numa query só: metade das idas ao banco
|
|
370
|
+
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
371
|
}
|
|
362
372
|
stored.set(rel, { hash, calls, imports: [...new Set(info.imports)], bindings });
|
|
363
373
|
await store.yieldIfWaited(); // dá a vez a outra sessão que só quer consultar
|
|
@@ -365,8 +375,14 @@ async function indexProjectLocked(root, store, opts = {}) {
|
|
|
365
375
|
// 3. Recalcula arestas desejadas em memória (sem parsear) e aplica diff.
|
|
366
376
|
const known = new Set(stored.keys());
|
|
367
377
|
const projectIndex = new ProjectIndex(root, known);
|
|
378
|
+
const wikiIndex = new WikiIndex(known);
|
|
379
|
+
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
380
|
/** 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
381
|
const resolveAll = (from, spec) => {
|
|
382
|
+
if (spec.startsWith(WIKI_PREFIX))
|
|
383
|
+
return wikiIndex.resolve(spec);
|
|
384
|
+
if (spec.startsWith(IDREF_PREFIX))
|
|
385
|
+
return idIndex.resolve(spec);
|
|
370
386
|
const r = projectIndex.resolve(from, spec);
|
|
371
387
|
if (r)
|
|
372
388
|
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
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown como cidadão do grafo: cada .md vira um Doc, cada cabeçalho uma Secao (com texto e linhas), os [[wikilinks]]
|
|
3
|
+
* viram LIGA entre Docs e as passagens bíblicas viram CITA → Passagem. Parser puro (sem banco) + indexação incremental.
|
|
4
|
+
*/
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
export const SECTIONS_VERSION = "m1";
|
|
10
|
+
const MD_RE = /\.(md|markdown)$/i;
|
|
11
|
+
/** Formatos que só viram metadado (Doc sem seções): a fonte de verdade é o .md derivado deles. */
|
|
12
|
+
const META_RE = /\.(pdf|epub|docx)$/i;
|
|
13
|
+
const IGNORE_RE = /(^|\/)(node_modules|dist|build|\.git|\.brito|\.venv|target|vendor)(\/|$)|(^|\/)\./;
|
|
14
|
+
const MAX_MD_BYTES = 5_000_000;
|
|
15
|
+
/** Siglas da NAA (e variantes sem acento); só estas valem como livro, para não confundir "Cap 3" com passagem. */
|
|
16
|
+
const LIVROS = new Set(("Gn Êx Ex Lv Nm Dt Js Jz Rt 1Sm 2Sm 1Rs 2Rs 1Cr 2Cr Ed Ne Et Jó Sl Pv Ec Ct Is Jr Lm Ez Dn Os Jl Am Ob Jn Mq Na Hc Sf Ag Zc Ml " +
|
|
17
|
+
"Mt Mc Lc Jo At Rm 1Co 2Co Gl Ef Fp Cl 1Ts 2Ts 1Tm 2Tm Tt Fm Hb Tg 1Pe 2Pe 1Jo 2Jo 3Jo Jd Ap").split(" "));
|
|
18
|
+
/** "Rm 9.13; Rm 3.25-26; Ob 15" → ["Rm 9.13","Rm 3.25","Rm 3.26","Ob 15"]. Intervalo vira um item por versículo (máx. 60). */
|
|
19
|
+
export function parsePassagens(txt) {
|
|
20
|
+
const out = [];
|
|
21
|
+
for (const m of txt.normalize("NFC").matchAll(/(?<![\p{L}\d])(\d?[A-Za-zÀ-ÿ]+)\s+(\d+)(?:\.(\d+)(?:-(\d+))?)?(?![\d])/gu)) {
|
|
22
|
+
const [, livro, cap, v1, v2] = m;
|
|
23
|
+
if (!LIVROS.has(livro))
|
|
24
|
+
continue;
|
|
25
|
+
if (v1 === undefined) {
|
|
26
|
+
out.push(`${livro} ${cap}`);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
for (let v = +v1; v <= +(v2 || v1) && v - +v1 < 60; v++)
|
|
30
|
+
out.push(`${livro} ${cap}.${v}`);
|
|
31
|
+
}
|
|
32
|
+
return [...new Set(out)];
|
|
33
|
+
}
|
|
34
|
+
/** Frontmatter simples (chave: valor, inclusive listas inline). Retorna também quantas linhas ele ocupa. */
|
|
35
|
+
export function splitFrontmatter(txt) {
|
|
36
|
+
const m = txt.match(/^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/);
|
|
37
|
+
if (!m)
|
|
38
|
+
return { fm: {}, corpo: txt, linhas: 0 };
|
|
39
|
+
const fm = {};
|
|
40
|
+
for (const l of m[1].split(/\r?\n/)) {
|
|
41
|
+
const k = l.match(/^([\p{L}\d_-]+):\s*(.*)$/u);
|
|
42
|
+
if (k)
|
|
43
|
+
fm[k[1]] = k[2].replace(/^["']|["']$/g, "");
|
|
44
|
+
}
|
|
45
|
+
return { fm, corpo: txt.slice(m[0].length), linhas: m[0].split("\n").length - 1 };
|
|
46
|
+
}
|
|
47
|
+
const wikilinks = (t) => [...t.matchAll(/\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]/g)].map((m) => m[1].trim()).filter(Boolean);
|
|
48
|
+
/** Tira blocos e trechos de código (os cabeçalhos e [[links]] dentro deles são exemplo, não estrutura). */
|
|
49
|
+
const semCodigo = (t) => t.replace(/^(```|~~~)[\s\S]*?^\1[^\n]*$/gm, "").replace(/`[^`\n]*`/g, "");
|
|
50
|
+
export function parseMarkdown(rel, src) {
|
|
51
|
+
const { fm, corpo, linhas: offset } = splitFrontmatter(src);
|
|
52
|
+
const h1 = corpo.match(/^#\s+(.+)$/m);
|
|
53
|
+
const tipo = (fm.tipo || rel.split("/")[0] || "").toLowerCase();
|
|
54
|
+
const secoes = [];
|
|
55
|
+
let atual = { nivel: 0, titulo: "(abertura)", buf: [], start: offset + 1 };
|
|
56
|
+
let ordem = 0, fence = null;
|
|
57
|
+
const lines = corpo.split("\n");
|
|
58
|
+
const fecha = (fim) => {
|
|
59
|
+
const texto = atual.buf.join("\n").trim();
|
|
60
|
+
if (texto || atual.nivel)
|
|
61
|
+
secoes.push({ id: `${rel}#${ordem}`, nivel: atual.nivel, titulo: atual.titulo, ordem: ordem++, texto, startLine: atual.start, endLine: Math.max(atual.start, fim) });
|
|
62
|
+
};
|
|
63
|
+
lines.forEach((raw, i) => {
|
|
64
|
+
const l = raw.replace(/\r$/, "");
|
|
65
|
+
const f = l.match(/^(```|~~~)/);
|
|
66
|
+
if (f)
|
|
67
|
+
fence = fence === null ? f[1] : fence === f[1] ? null : fence;
|
|
68
|
+
const h = fence === null && !f ? l.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/) : null;
|
|
69
|
+
if (h) {
|
|
70
|
+
fecha(offset + i);
|
|
71
|
+
atual = { nivel: h[1].length, titulo: h[2].trim(), buf: [], start: offset + i + 1 };
|
|
72
|
+
}
|
|
73
|
+
else
|
|
74
|
+
atual.buf.push(l);
|
|
75
|
+
});
|
|
76
|
+
fecha(offset + lines.length);
|
|
77
|
+
const links = [];
|
|
78
|
+
const modelo = rel.startsWith("Modelos/");
|
|
79
|
+
if (!modelo) {
|
|
80
|
+
for (const [k, v] of Object.entries(fm))
|
|
81
|
+
for (const a of wikilinks(v))
|
|
82
|
+
links.push({ tipo: k, alvo: a });
|
|
83
|
+
for (const a of wikilinks(semCodigo(corpo)))
|
|
84
|
+
links.push({ tipo: "menciona", alvo: a });
|
|
85
|
+
}
|
|
86
|
+
let passagens = [];
|
|
87
|
+
if (!modelo) {
|
|
88
|
+
if (fm.passagens)
|
|
89
|
+
passagens = parsePassagens(fm.passagens);
|
|
90
|
+
if (tipo === "tema") {
|
|
91
|
+
const bb = secoes.find((s) => /^Base bíblica/i.test(s.titulo));
|
|
92
|
+
if (bb)
|
|
93
|
+
passagens = passagens.concat(parsePassagens(bb.texto));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
path: rel, tipo, titulo: fm.titulo || h1?.[1].trim() || path.basename(rel).replace(MD_RE, ""),
|
|
98
|
+
ano: fm.ano || "", status: fm.status || "", tamanho: Buffer.byteLength(src), secoes, links, passagens: [...new Set(passagens)],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** Nome → caminho, comparando em NFC e sem diferenciar caixa (links do macOS vêm decompostos). Nome repetido: vale o 1º por ordem de caminho. */
|
|
102
|
+
export function linkResolver(paths) {
|
|
103
|
+
const nfc = (s) => s.normalize("NFC").toLowerCase();
|
|
104
|
+
const map = new Map();
|
|
105
|
+
const add = (k, p) => { const a = map.get(k) ?? map.set(k, []).get(k); if (!a.includes(p))
|
|
106
|
+
a.push(p); };
|
|
107
|
+
for (const p of [...paths].sort()) {
|
|
108
|
+
const noExt = p.replace(MD_RE, "");
|
|
109
|
+
add(nfc(p), p);
|
|
110
|
+
add(nfc(noExt), p);
|
|
111
|
+
add(nfc(path.posix.basename(p)), p);
|
|
112
|
+
add(nfc(path.posix.basename(noExt)), p);
|
|
113
|
+
}
|
|
114
|
+
return (alvo, de) => {
|
|
115
|
+
const c = map.get(nfc(alvo));
|
|
116
|
+
if (!c)
|
|
117
|
+
return null;
|
|
118
|
+
const top = de.split("/")[0];
|
|
119
|
+
return c.find((x) => x.split("/")[0] === top) ?? c[0]; // ambíguo: prefere a mesma pasta raiz
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function listFiles(root) {
|
|
123
|
+
if (process.env.BRITO_INDEX_IGNORED !== "1") {
|
|
124
|
+
const r = spawnSync("git", ["ls-files", "-co", "--exclude-standard", "-z"], { cwd: root, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
125
|
+
if (!r.error && r.status === 0)
|
|
126
|
+
return r.stdout.split("\0").filter(Boolean).filter((f) => !IGNORE_RE.test(f) && fs.existsSync(path.join(root, f)));
|
|
127
|
+
}
|
|
128
|
+
const out = [];
|
|
129
|
+
const walk = (dir) => {
|
|
130
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
131
|
+
if (e.name.startsWith(".") || IGNORE_RE.test(e.name))
|
|
132
|
+
continue;
|
|
133
|
+
const p = path.join(dir, e.name);
|
|
134
|
+
if (e.isDirectory())
|
|
135
|
+
walk(p);
|
|
136
|
+
else
|
|
137
|
+
out.push(path.relative(root, p).split(path.sep).join("/"));
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
walk(root);
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
/** Indexação incremental dos .md (e metadados de pdf/epub/docx): só reparseia o que mudou; as LIGA são recalculadas em memória e aplicadas por diff. */
|
|
144
|
+
export async function indexSections(root, store, opts = {}) {
|
|
145
|
+
const files = listFiles(root).filter((f) => MD_RE.test(f) || META_RE.test(f));
|
|
146
|
+
if (!files.length) {
|
|
147
|
+
const had = Number((await store.run("MATCH (d:Doc) RETURN count(d) AS n"))[0].n);
|
|
148
|
+
if (had)
|
|
149
|
+
await store.run("MATCH (d:Doc) DETACH DELETE d");
|
|
150
|
+
return { docs: 0, secoes: 0, ligas: 0, citas: 0, added: 0, changed: 0, removed: had, unresolved: 0 };
|
|
151
|
+
}
|
|
152
|
+
if (opts.full) {
|
|
153
|
+
await store.run("MATCH (s:Secao) DETACH DELETE s");
|
|
154
|
+
await store.run("MATCH (d:Doc) DETACH DELETE d");
|
|
155
|
+
await store.run("MATCH (p:Passagem) DELETE p");
|
|
156
|
+
}
|
|
157
|
+
const stored = new Map();
|
|
158
|
+
for (const r of await store.run("MATCH (d:Doc) RETURN d.path AS p, d.hash AS h"))
|
|
159
|
+
stored.set(r.p, r.h ?? "");
|
|
160
|
+
const current = new Map();
|
|
161
|
+
for (const rel of files) {
|
|
162
|
+
const abs = path.join(root, rel);
|
|
163
|
+
try {
|
|
164
|
+
const st = fs.statSync(abs);
|
|
165
|
+
if (MD_RE.test(rel)) {
|
|
166
|
+
if (st.size > MAX_MD_BYTES)
|
|
167
|
+
continue;
|
|
168
|
+
const src = fs.readFileSync(abs, "utf8");
|
|
169
|
+
current.set(rel, { src, size: st.size, hash: crypto.createHash("sha1").update(src).digest("hex") + ":" + SECTIONS_VERSION });
|
|
170
|
+
}
|
|
171
|
+
else
|
|
172
|
+
current.set(rel, { size: st.size, hash: `meta:${st.size}:${SECTIONS_VERSION}` });
|
|
173
|
+
}
|
|
174
|
+
catch { /* ilegível: ignora */ }
|
|
175
|
+
}
|
|
176
|
+
const added = [], changed = [], removed = [];
|
|
177
|
+
for (const [p, { hash }] of current) {
|
|
178
|
+
const old = stored.get(p);
|
|
179
|
+
if (old === undefined)
|
|
180
|
+
added.push(p);
|
|
181
|
+
else if (old !== hash)
|
|
182
|
+
changed.push(p);
|
|
183
|
+
}
|
|
184
|
+
for (const p of stored.keys())
|
|
185
|
+
if (!current.has(p))
|
|
186
|
+
removed.push(p);
|
|
187
|
+
if (added.length + changed.length + removed.length)
|
|
188
|
+
opts.onProgress?.(`markdown: +${added.length} ~${changed.length} -${removed.length}`);
|
|
189
|
+
for (const p of [...removed, ...changed]) {
|
|
190
|
+
await store.run("MATCH (s:Secao {doc:$p}) DETACH DELETE s", { p });
|
|
191
|
+
await store.run("MATCH (d:Doc {path:$p}) DETACH DELETE d", { p });
|
|
192
|
+
}
|
|
193
|
+
const parsed = new Map();
|
|
194
|
+
for (const rel of [...added, ...changed]) {
|
|
195
|
+
const c = current.get(rel);
|
|
196
|
+
const doc = c.src !== undefined ? parseMarkdown(rel, c.src)
|
|
197
|
+
: { path: rel, tipo: path.extname(rel).slice(1).toLowerCase(), titulo: path.basename(rel), ano: "", status: "", tamanho: c.size, secoes: [], links: [], passagens: [] };
|
|
198
|
+
parsed.set(rel, doc);
|
|
199
|
+
await store.run("CREATE (:Doc {path:$path, tipo:$tipo, titulo:$titulo, ano:$ano, status:$status, tamanho:$tamanho, hash:$hash, links:$links})", { path: rel, tipo: doc.tipo, titulo: doc.titulo, ano: doc.ano, status: doc.status, tamanho: doc.tamanho, hash: c.hash, links: doc.links.map((l) => `${l.tipo}\t${l.alvo}`) });
|
|
200
|
+
for (const s of doc.secoes) {
|
|
201
|
+
await store.run("MATCH (d:Doc {path:$doc}) CREATE (d)-[:TEM_SECAO]->(:Secao {id:$id, doc:$doc, nivel:$nivel, titulo:$titulo, ordem:$ordem, texto:$texto, startLine:$sl, endLine:$el})", { doc: rel, id: s.id, nivel: s.nivel, titulo: s.titulo, ordem: s.ordem, texto: s.texto, sl: s.startLine, el: s.endLine });
|
|
202
|
+
}
|
|
203
|
+
for (const ref of doc.passagens) {
|
|
204
|
+
await store.run("MERGE (p:Passagem {ref:$ref})", { ref });
|
|
205
|
+
await store.run("MATCH (d:Doc {path:$d}), (p:Passagem {ref:$ref}) CREATE (d)-[:CITA]->(p)", { d: rel, ref });
|
|
206
|
+
}
|
|
207
|
+
await store.yieldIfWaited();
|
|
208
|
+
}
|
|
209
|
+
// LIGA: recalculada em memória a partir dos links crus guardados em cada Doc; aplica só a diferença.
|
|
210
|
+
const resolve = linkResolver(current.keys());
|
|
211
|
+
const want = new Set();
|
|
212
|
+
let unresolved = 0;
|
|
213
|
+
const raw = new Map();
|
|
214
|
+
for (const r of await store.run("MATCH (d:Doc) RETURN d.path AS p, d.links AS l"))
|
|
215
|
+
raw.set(r.p, r.l ?? []);
|
|
216
|
+
for (const [de, ls] of raw) {
|
|
217
|
+
for (const l of ls) {
|
|
218
|
+
const [tipo, alvo] = l.split("\t");
|
|
219
|
+
const para = resolve(alvo, de);
|
|
220
|
+
if (!para) {
|
|
221
|
+
unresolved++;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (para !== de)
|
|
225
|
+
want.add([de, para, tipo].join("\t"));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const have = new Set((await store.run("MATCH (a:Doc)-[r:LIGA]->(b:Doc) RETURN a.path AS a, b.path AS b, r.tipo AS t")).map((r) => [r.a, r.b, r.t].join("\t")));
|
|
229
|
+
for (const k of have)
|
|
230
|
+
if (!want.has(k)) {
|
|
231
|
+
const [a, b, t] = k.split("\t");
|
|
232
|
+
await store.run("MATCH (x:Doc {path:$a})-[r:LIGA {tipo:$t}]->(y:Doc {path:$b}) DELETE r", { a, b, t });
|
|
233
|
+
}
|
|
234
|
+
for (const k of want)
|
|
235
|
+
if (!have.has(k)) {
|
|
236
|
+
const [a, b, t] = k.split("\t");
|
|
237
|
+
await store.run("MATCH (x:Doc {path:$a}), (y:Doc {path:$b}) CREATE (x)-[:LIGA {tipo:$t}]->(y)", { a, b, t });
|
|
238
|
+
}
|
|
239
|
+
const n = async (q) => Number((await store.run(q))[0].n);
|
|
240
|
+
return {
|
|
241
|
+
docs: current.size, secoes: await n("MATCH (s:Secao) RETURN count(s) AS n"), ligas: want.size, citas: await n("MATCH ()-[r:CITA]->() RETURN count(r) AS n"),
|
|
242
|
+
added: added.length, changed: changed.length, removed: removed.length, unresolved,
|
|
243
|
+
};
|
|
244
|
+
}
|
package/dist/graph/store.js
CHANGED
|
@@ -149,12 +149,24 @@ export class GraphStore {
|
|
|
149
149
|
"CREATE REL TABLE IF NOT EXISTS DEFINES(FROM File TO Symbol)",
|
|
150
150
|
"CREATE REL TABLE IF NOT EXISTS CALLS(FROM Symbol TO Symbol)",
|
|
151
151
|
"CREATE REL TABLE IF NOT EXISTS IMPORTS(FROM File TO File)",
|
|
152
|
+
// Markdown: Doc por arquivo, Secao por cabeçalho, LIGA = [[wikilink]], CITA = passagem bíblica.
|
|
153
|
+
"CREATE NODE TABLE IF NOT EXISTS Doc(path STRING, tipo STRING, titulo STRING, ano STRING, status STRING, tamanho INT64, PRIMARY KEY(path))",
|
|
154
|
+
"CREATE NODE TABLE IF NOT EXISTS Secao(id STRING, doc STRING, nivel INT64, titulo STRING, ordem INT64, texto STRING, PRIMARY KEY(id))",
|
|
155
|
+
"CREATE NODE TABLE IF NOT EXISTS Passagem(ref STRING, PRIMARY KEY(ref))",
|
|
156
|
+
"CREATE REL TABLE IF NOT EXISTS TEM_SECAO(FROM Doc TO Secao)",
|
|
157
|
+
"CREATE REL TABLE IF NOT EXISTS CITA(FROM Doc TO Passagem)",
|
|
158
|
+
"CREATE REL TABLE IF NOT EXISTS LIGA(FROM Doc TO Doc, tipo STRING)",
|
|
152
159
|
];
|
|
153
160
|
for (const q of ddl)
|
|
154
161
|
await drain(await this.conn.query(q), false);
|
|
155
162
|
// Bancos criados antes do indexador incremental não têm estas colunas.
|
|
156
163
|
for (const col of ["calls", "imports", "bindings"])
|
|
157
164
|
await drain(await this.conn.query(`ALTER TABLE File ADD IF NOT EXISTS ${col} STRING[] DEFAULT []`), false);
|
|
165
|
+
for (const q of [
|
|
166
|
+
"ALTER TABLE Doc ADD IF NOT EXISTS hash STRING DEFAULT ''", "ALTER TABLE Doc ADD IF NOT EXISTS links STRING[] DEFAULT []",
|
|
167
|
+
"ALTER TABLE Secao ADD IF NOT EXISTS startLine INT64 DEFAULT 0", "ALTER TABLE Secao ADD IF NOT EXISTS endLine INT64 DEFAULT 0",
|
|
168
|
+
])
|
|
169
|
+
await drain(await this.conn.query(q), false);
|
|
158
170
|
}
|
|
159
171
|
/** Serializa as queries: leituras paralelas de subagentes dividem uma única Connection. */
|
|
160
172
|
run(cypher, params = {}, forceWrite = false) {
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Busca no cofre de Markdown: seções do grafo (Secao) + trechos de livros/aulas do índice SQLite FTS5 (.brito/textos.db,
|
|
3
|
+
* mantido por Modelos/buscar.py). Cada resultado traz arquivo, linhas ou página e um trecho curto; o 1º também traz os
|
|
4
|
+
* vizinhos do grafo (LIGA), para o agente seguir o link sem abrir o arquivo.
|
|
5
|
+
*/
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
export const fold = (s) => s.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
|
|
9
|
+
const terms = (q) => [...new Set(fold(q).match(/[\p{L}\p{N}]+/gu) ?? [])].filter((t) => t.length > 1 || /\d/.test(t));
|
|
10
|
+
function snippet(texto, ts, width = 220) {
|
|
11
|
+
const flat = texto.replace(/\s+/g, " ");
|
|
12
|
+
const f = fold(flat); // fold remove só marcas combinantes: comprimento igual ao de `flat` em texto já em NFC
|
|
13
|
+
let at = -1;
|
|
14
|
+
for (const t of ts) {
|
|
15
|
+
const i = f.indexOf(t);
|
|
16
|
+
if (i >= 0 && (at < 0 || i < at))
|
|
17
|
+
at = i;
|
|
18
|
+
}
|
|
19
|
+
const a = Math.max(0, (at < 0 ? 0 : at) - 60);
|
|
20
|
+
return (a ? "… " : "") + flat.slice(a, a + width).trim() + (a + width < flat.length ? " …" : "");
|
|
21
|
+
}
|
|
22
|
+
/** Seções do grafo que contêm TODAS as palavras (sem acento nem caixa). Título pesa mais; frase exata ganha bônus. */
|
|
23
|
+
export async function searchSecoes(graph, query, opts = {}) {
|
|
24
|
+
const ts = terms(query);
|
|
25
|
+
if (!ts.length)
|
|
26
|
+
return [];
|
|
27
|
+
const rows = await graph.run("MATCH (s:Secao) RETURN s.doc AS doc, s.titulo AS titulo, s.texto AS texto, s.startLine AS a, s.endLine AS b");
|
|
28
|
+
const em = opts.em ? fold(opts.em) : "";
|
|
29
|
+
const phrase = ts.join(" ");
|
|
30
|
+
const hits = [];
|
|
31
|
+
for (const r of rows) {
|
|
32
|
+
if (em && !fold(r.doc).includes(em))
|
|
33
|
+
continue;
|
|
34
|
+
const f = fold(`${r.titulo}\n${r.texto}`), ft = fold(r.titulo);
|
|
35
|
+
if (!ts.every((t) => f.includes(t)))
|
|
36
|
+
continue;
|
|
37
|
+
let score = 0;
|
|
38
|
+
for (const t of ts) {
|
|
39
|
+
score += Math.min(5, f.split(t).length - 1);
|
|
40
|
+
if (ft.includes(t))
|
|
41
|
+
score += 5;
|
|
42
|
+
}
|
|
43
|
+
if (f.replace(/\s+/g, " ").includes(phrase))
|
|
44
|
+
score += 6;
|
|
45
|
+
score /= 1 + Math.log10(1 + f.length / 2000); // não premia seção gigante só por ser longa
|
|
46
|
+
hits.push({ kind: "md", doc: r.doc, where: `${r.doc}:${r.a}-${r.b}`, title: r.titulo, snippet: snippet(r.texto || r.titulo, ts), score });
|
|
47
|
+
}
|
|
48
|
+
return hits.sort((x, y) => y.score - x.score).slice(0, opts.limit ?? 6);
|
|
49
|
+
}
|
|
50
|
+
/** Trechos de livros/aulas (Textos/*.txt) pelo FTS5 do SQLite; vazio se o índice não existe ou o node não tem node:sqlite. */
|
|
51
|
+
export async function searchTextos(root, query, opts = {}) {
|
|
52
|
+
const file = path.join(root, ".brito", "textos.db");
|
|
53
|
+
if (!fs.existsSync(file))
|
|
54
|
+
return [];
|
|
55
|
+
const ts = terms(query);
|
|
56
|
+
if (!ts.length)
|
|
57
|
+
return [];
|
|
58
|
+
let sqlite;
|
|
59
|
+
try {
|
|
60
|
+
sqlite = await import("node:sqlite");
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
const db = new sqlite.DatabaseSync(file, { readOnly: true });
|
|
66
|
+
try {
|
|
67
|
+
const sql = "SELECT path, loc, snippet(trechos, 2, '«', '»', ' … ', 24) AS t FROM trechos WHERE trechos MATCH ?" + (opts.em ? " AND path LIKE ?" : "") + " ORDER BY rank LIMIT ?";
|
|
68
|
+
for (const match of [`"${ts.join(" ")}"`, ts.map((t) => `"${t}"`).join(" ")]) { // 1º frase exata, depois todas as palavras
|
|
69
|
+
const rows = db.prepare(sql).all(...[match, ...(opts.em ? [`%${opts.em}%`] : []), opts.limit ?? 6]);
|
|
70
|
+
if (rows.length)
|
|
71
|
+
return rows.map((r, i) => ({ kind: "texto", where: `${r.path.replaceAll("\\", "/")} (${r.loc})`, title: path.basename(r.path).replace(/\.txt$/, ""), snippet: r.t, score: rows.length - i }));
|
|
72
|
+
}
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
db.close();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Docs ligados ao documento (entrada e saída), com o tipo da ligação. */
|
|
83
|
+
export async function neighbors(graph, doc, limit = 8) {
|
|
84
|
+
const out = await graph.run("MATCH (a:Doc {path:$p})-[r:LIGA]->(b:Doc) RETURN b.path AS p, r.tipo AS t LIMIT $n", { p: doc, n: limit });
|
|
85
|
+
const inn = await graph.run("MATCH (b:Doc)-[r:LIGA]->(a:Doc {path:$p}) RETURN b.path AS p, r.tipo AS t LIMIT $n", { p: doc, n: limit });
|
|
86
|
+
return [...out.map((r) => `→ ${r.p} (${r.t})`), ...inn.map((r) => `← ${r.p} (${r.t})`)].slice(0, limit);
|
|
87
|
+
}
|
|
88
|
+
export async function vaultSearch(root, graph, query, opts = {}) {
|
|
89
|
+
const md = await searchSecoes(graph, query, opts);
|
|
90
|
+
const tx = opts.textos === false ? [] : await searchTextos(root, query, opts);
|
|
91
|
+
if (!md.length && !tx.length)
|
|
92
|
+
return "nada encontrado (tente menos palavras ou outra forma da palavra)";
|
|
93
|
+
const lines = [];
|
|
94
|
+
if (md.length) {
|
|
95
|
+
lines.push("## Notas do cofre (.md)");
|
|
96
|
+
for (const h of md)
|
|
97
|
+
lines.push(`- ${h.where} | ${h.title} | ${h.snippet}`);
|
|
98
|
+
const near = await neighbors(graph, md[0].doc);
|
|
99
|
+
if (near.length)
|
|
100
|
+
lines.push(`Ligações de ${md[0].doc}:`, ...near.map((n) => ` ${n}`));
|
|
101
|
+
}
|
|
102
|
+
if (tx.length) {
|
|
103
|
+
lines.push("## Livros e aulas (Textos/)");
|
|
104
|
+
for (const h of tx)
|
|
105
|
+
lines.push(`- ${h.where} | ${h.snippet}`);
|
|
106
|
+
}
|
|
107
|
+
return lines.join("\n");
|
|
108
|
+
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -12,6 +12,7 @@ import { implementIo } from "../tools/implement.js";
|
|
|
12
12
|
import "../tools/spec.js";
|
|
13
13
|
import { ORCH_ONLY, orch } from "../orchestrator.js";
|
|
14
14
|
import "../tools/digest.js";
|
|
15
|
+
import "../tools/vault.js";
|
|
15
16
|
import { allTools, execTool } from "../tools/registry.js";
|
|
16
17
|
import { GraphStore } from "../graph/store.js";
|
|
17
18
|
import { INDEXED_EXTENSIONS, indexProject } from "../graph/indexer.js";
|
|
@@ -24,7 +25,7 @@ export const MCP_NAME = "brito";
|
|
|
24
25
|
export const MCP_INSTRUCTIONS = [
|
|
25
26
|
"Brito: grafo de código, memória e skills do projeto.",
|
|
26
27
|
"- 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.`,
|
|
28
|
+
`- 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
29
|
"- recall/remember: notas duráveis (decisões, convenções, bugs descobertos). Consulte recall ao começar uma tarefa.",
|
|
29
30
|
"- 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
31
|
].join("\n");
|
|
@@ -34,7 +35,7 @@ export const MCP_INSTRUCTIONS = [
|
|
|
34
35
|
* tools de consulta, que são baratas e de uso frequente. As de escrita e a de aprovação (interna) continuam adiadas.
|
|
35
36
|
*/
|
|
36
37
|
export const ALWAYS_LOAD_META = "anthropic/alwaysLoad";
|
|
37
|
-
export const alwaysLoaded = (tool) => /^(graph_|recall$|remember$|skill_load$|digest$|implement
|
|
38
|
+
export const alwaysLoaded = (tool) => /^(graph_|recall$|remember$|skill_load$|digest$|implement$|vault_)/.test(tool);
|
|
38
39
|
/** Prefixo com que o claude CLI enxerga as tools deste servidor. */
|
|
39
40
|
export const MCP_PREFIX = `mcp__${MCP_NAME}__`;
|
|
40
41
|
/** Tool usada como --permission-prompt-tool: encaminha o pedido ao REPL do Brito. */
|
|
@@ -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",
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { defineTool } from "./registry.js";
|
|
3
|
+
import { vaultSearch } from "../graph/vault.js";
|
|
4
|
+
defineTool({
|
|
5
|
+
name: "vault_search",
|
|
6
|
+
concurrent: true,
|
|
7
|
+
readsGraph: true,
|
|
8
|
+
description: "Busca por palavras num cofre de notas Markdown (sem acento nem caixa, todas as palavras): devolve seções de .md com arquivo e linhas (`arq.md:12-30`), os vizinhos do 1º resultado no grafo ([[links]]) e, se houver índice, trechos de livros/aulas com página. USE ANTES de abrir arquivos ou PDFs; depois leia só a faixa de linhas indicada.",
|
|
9
|
+
schema: z.object({
|
|
10
|
+
query: z.string().describe("palavras ou frase"),
|
|
11
|
+
em: z.string().optional().describe("limita a caminhos que contêm este texto (ex.: 'Temas/', 'Frame')"),
|
|
12
|
+
limit: z.number().int().min(1).max(20).optional(),
|
|
13
|
+
}),
|
|
14
|
+
async run({ query, em, limit }, { root, graph }) {
|
|
15
|
+
return vaultSearch(root, graph, query, { em, limit });
|
|
16
|
+
},
|
|
17
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brito-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.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": {
|