brito-cli 0.14.0 → 0.16.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/dist/cli.js +2 -1
- package/dist/desktop/serve.js +1 -0
- package/dist/graph/indexer.js +4 -1
- package/dist/graph/pathfind.js +168 -0
- package/dist/graph/sections.js +247 -0
- package/dist/graph/store.js +12 -0
- package/dist/graph/vault.js +108 -0
- package/dist/graph/vaultgraph.js +89 -0
- package/dist/graph/vaultquery.js +103 -0
- package/dist/mcp/server.js +2 -1
- package/dist/tools/vault.js +30 -0
- package/package.json +1 -1
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
|
@@ -8,6 +8,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
10
|
import { IDREF_PREFIX, IdIndex, MD_EXT, WIKI_PREFIX, WikiIndex, extractMarkdown } from "./markdown.js";
|
|
11
|
+
import { indexSections } from "./sections.js";
|
|
11
12
|
import { EXTRA_EXT, ProjectIndex, extractLang, isExtraLang } from "./langs.js";
|
|
12
13
|
const require = createRequire(import.meta.url);
|
|
13
14
|
const WASM_DIR = path.dirname(require.resolve("tree-sitter-wasms/package.json")) + "/out";
|
|
@@ -247,7 +248,9 @@ const count = async (store, q) => Number((await store.run(q))[0].n);
|
|
|
247
248
|
export async function indexProject(root, store, opts = {}) {
|
|
248
249
|
const release = await acquireIndexLock(root, opts.onProgress);
|
|
249
250
|
try {
|
|
250
|
-
|
|
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 };
|
|
251
254
|
}
|
|
252
255
|
finally {
|
|
253
256
|
release();
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Algoritmos de grafo em memória para o cofre: Dijkstra, A* com landmarks (ALT) e PageRank personalizado.
|
|
3
|
+
* Custo da aresta = custo da relação + penalidade de hub (log do grau): o caminho preferido não passa por "Início" ou "Deus".
|
|
4
|
+
*/
|
|
5
|
+
class Heap {
|
|
6
|
+
k = [];
|
|
7
|
+
v = [];
|
|
8
|
+
get size() { return this.k.length; }
|
|
9
|
+
push(key, val) {
|
|
10
|
+
let i = this.k.length;
|
|
11
|
+
this.k.push(key);
|
|
12
|
+
this.v.push(val);
|
|
13
|
+
while (i > 0) {
|
|
14
|
+
const p = (i - 1) >> 1;
|
|
15
|
+
if (this.k[p] <= this.k[i])
|
|
16
|
+
break;
|
|
17
|
+
this.swap(i, p);
|
|
18
|
+
i = p;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
pop() {
|
|
22
|
+
const top = [this.k[0], this.v[0]];
|
|
23
|
+
const lk = this.k.pop(), lv = this.v.pop();
|
|
24
|
+
if (this.k.length) {
|
|
25
|
+
this.k[0] = lk;
|
|
26
|
+
this.v[0] = lv;
|
|
27
|
+
let i = 0;
|
|
28
|
+
for (;;) {
|
|
29
|
+
const l = 2 * i + 1, r = l + 1;
|
|
30
|
+
let m = i;
|
|
31
|
+
if (l < this.k.length && this.k[l] < this.k[m])
|
|
32
|
+
m = l;
|
|
33
|
+
if (r < this.k.length && this.k[r] < this.k[m])
|
|
34
|
+
m = r;
|
|
35
|
+
if (m === i)
|
|
36
|
+
break;
|
|
37
|
+
this.swap(i, m);
|
|
38
|
+
i = m;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return top;
|
|
42
|
+
}
|
|
43
|
+
swap(a, b) { [this.k[a], this.k[b]] = [this.k[b], this.k[a]]; [this.v[a], this.v[b]] = [this.v[b], this.v[a]]; }
|
|
44
|
+
}
|
|
45
|
+
export class WGraph {
|
|
46
|
+
n;
|
|
47
|
+
adj;
|
|
48
|
+
deg;
|
|
49
|
+
constructor(n, edges, hub = 0.25) {
|
|
50
|
+
this.n = n;
|
|
51
|
+
this.adj = Array.from({ length: n }, () => []);
|
|
52
|
+
this.deg = new Array(n).fill(0);
|
|
53
|
+
for (const [a, b] of edges) {
|
|
54
|
+
this.deg[a]++;
|
|
55
|
+
this.deg[b]++;
|
|
56
|
+
}
|
|
57
|
+
for (const [a, b, base] of edges) {
|
|
58
|
+
const w = base + hub * (Math.log2(1 + this.deg[a]) + Math.log2(1 + this.deg[b]));
|
|
59
|
+
this.adj[a].push({ to: b, w });
|
|
60
|
+
this.adj[b].push({ to: a, w });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
dijkstra(s) {
|
|
64
|
+
const dist = new Float64Array(this.n).fill(Infinity);
|
|
65
|
+
dist[s] = 0;
|
|
66
|
+
const h = new Heap();
|
|
67
|
+
h.push(0, s);
|
|
68
|
+
while (h.size) {
|
|
69
|
+
const [d, u] = h.pop();
|
|
70
|
+
if (d > dist[u])
|
|
71
|
+
continue;
|
|
72
|
+
for (const e of this.adj[u]) {
|
|
73
|
+
const nd = d + e.w;
|
|
74
|
+
if (nd < dist[e.to]) {
|
|
75
|
+
dist[e.to] = nd;
|
|
76
|
+
h.push(nd, e.to);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return dist;
|
|
81
|
+
}
|
|
82
|
+
/** Landmarks por ponto mais distante: distâncias pré-calculadas que dão a heurística admissível do A*. */
|
|
83
|
+
landmarks(k, from = 0) {
|
|
84
|
+
const out = [];
|
|
85
|
+
const mind = new Float64Array(this.n).fill(Infinity);
|
|
86
|
+
let cur = from;
|
|
87
|
+
for (let i = 0; i < k && cur >= 0; i++) {
|
|
88
|
+
const dist = this.dijkstra(cur);
|
|
89
|
+
out.push(dist);
|
|
90
|
+
let best = -1, bd = -1;
|
|
91
|
+
for (let v = 0; v < this.n; v++) {
|
|
92
|
+
mind[v] = Math.min(mind[v], dist[v]);
|
|
93
|
+
if (mind[v] !== Infinity && mind[v] > bd) {
|
|
94
|
+
bd = mind[v];
|
|
95
|
+
best = v;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
cur = best;
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
astar(s, t, lm) {
|
|
103
|
+
const hf = (v) => {
|
|
104
|
+
let m = 0;
|
|
105
|
+
for (const d of lm) {
|
|
106
|
+
const a = d[v], b = d[t];
|
|
107
|
+
if (a !== Infinity && b !== Infinity)
|
|
108
|
+
m = Math.max(m, Math.abs(a - b));
|
|
109
|
+
}
|
|
110
|
+
return m;
|
|
111
|
+
};
|
|
112
|
+
const g = new Float64Array(this.n).fill(Infinity);
|
|
113
|
+
const prev = new Int32Array(this.n).fill(-1);
|
|
114
|
+
g[s] = 0;
|
|
115
|
+
const h = new Heap();
|
|
116
|
+
h.push(hf(s), s);
|
|
117
|
+
const done = new Uint8Array(this.n);
|
|
118
|
+
let settled = 0;
|
|
119
|
+
while (h.size) {
|
|
120
|
+
const [, u] = h.pop();
|
|
121
|
+
if (done[u])
|
|
122
|
+
continue;
|
|
123
|
+
done[u] = 1;
|
|
124
|
+
settled++;
|
|
125
|
+
if (u === t) {
|
|
126
|
+
const path = [t];
|
|
127
|
+
while (path[0] !== s)
|
|
128
|
+
path.unshift(prev[path[0]]);
|
|
129
|
+
return { cost: g[t], settled, path };
|
|
130
|
+
}
|
|
131
|
+
for (const e of this.adj[u]) {
|
|
132
|
+
const ng = g[u] + e.w;
|
|
133
|
+
if (ng < g[e.to]) {
|
|
134
|
+
g[e.to] = ng;
|
|
135
|
+
prev[e.to] = u;
|
|
136
|
+
h.push(ng + hf(e.to), e.to);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { cost: Infinity, settled, path: [] };
|
|
141
|
+
}
|
|
142
|
+
/** PageRank personalizado: a massa parte das sementes e segue as arestas na proporção de 1/custo. */
|
|
143
|
+
ppr(seeds, alpha = 0.2, iters = 25) {
|
|
144
|
+
const tot = [...seeds.values()].reduce((a, b) => a + b, 0) || 1;
|
|
145
|
+
const p0 = new Float64Array(this.n);
|
|
146
|
+
for (const [k, v] of seeds)
|
|
147
|
+
p0[k] = v / tot;
|
|
148
|
+
const wsum = this.adj.map((l) => l.reduce((a, e) => a + 1 / e.w, 0));
|
|
149
|
+
let p = Float64Array.from(p0);
|
|
150
|
+
for (let it = 0; it < iters; it++) {
|
|
151
|
+
const q = new Float64Array(this.n);
|
|
152
|
+
for (let u = 0; u < this.n; u++) {
|
|
153
|
+
if (!p[u])
|
|
154
|
+
continue;
|
|
155
|
+
if (!wsum[u]) {
|
|
156
|
+
q[u] += p[u];
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
for (const e of this.adj[u])
|
|
160
|
+
q[e.to] += (1 - alpha) * p[u] * (1 / e.w) / wsum[u];
|
|
161
|
+
}
|
|
162
|
+
for (let i = 0; i < this.n; i++)
|
|
163
|
+
q[i] += alpha * p0[i];
|
|
164
|
+
p = q;
|
|
165
|
+
}
|
|
166
|
+
return p;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
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 " +
|
|
18
|
+
// formas longas e variantes usuais em português (as fichas do cofre usam "1Cor", "Hab" etc.)
|
|
19
|
+
"Gên Gen Êxo Exo Lev Núm Num Deut Jos Juí Jz Rute 1Sam 2Sam 1Re 2Re 1Cr 2Cr Esd Est Jó Sal Prov Ecl Cant Isa Jer Lam Eze Ezq Dan Ose Joe Amó Abd Jon Miq Naum Hab Sof Ageu Zac Mal " +
|
|
20
|
+
"Mat Mar Luc Joã Atos Rom 1Cor 2Cor Gál Efé Fil Col 1Tes 2Tes 1Tim 2Tim Tito Filem Heb Tia 1Ped 2Ped 1Joã 2Joã 3Joã Jud Apoc").split(" "));
|
|
21
|
+
/** "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). */
|
|
22
|
+
export function parsePassagens(txt) {
|
|
23
|
+
const out = [];
|
|
24
|
+
for (const m of txt.normalize("NFC").matchAll(/(?<![\p{L}\d])(\d?[A-Za-zÀ-ÿ]+)\s+(\d+)(?:\.(\d+)(?:-(\d+))?)?(?![\d])/gu)) {
|
|
25
|
+
const [, livro, cap, v1, v2] = m;
|
|
26
|
+
if (!LIVROS.has(livro))
|
|
27
|
+
continue;
|
|
28
|
+
if (v1 === undefined) {
|
|
29
|
+
out.push(`${livro} ${cap}`);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
for (let v = +v1; v <= +(v2 || v1) && v - +v1 < 60; v++)
|
|
33
|
+
out.push(`${livro} ${cap}.${v}`);
|
|
34
|
+
}
|
|
35
|
+
return [...new Set(out)];
|
|
36
|
+
}
|
|
37
|
+
/** Frontmatter simples (chave: valor, inclusive listas inline). Retorna também quantas linhas ele ocupa. */
|
|
38
|
+
export function splitFrontmatter(txt) {
|
|
39
|
+
const m = txt.match(/^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/);
|
|
40
|
+
if (!m)
|
|
41
|
+
return { fm: {}, corpo: txt, linhas: 0 };
|
|
42
|
+
const fm = {};
|
|
43
|
+
for (const l of m[1].split(/\r?\n/)) {
|
|
44
|
+
const k = l.match(/^([\p{L}\d_-]+):\s*(.*)$/u);
|
|
45
|
+
if (k)
|
|
46
|
+
fm[k[1]] = k[2].replace(/^["']|["']$/g, "");
|
|
47
|
+
}
|
|
48
|
+
return { fm, corpo: txt.slice(m[0].length), linhas: m[0].split("\n").length - 1 };
|
|
49
|
+
}
|
|
50
|
+
const wikilinks = (t) => [...t.matchAll(/\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]/g)].map((m) => m[1].trim()).filter(Boolean);
|
|
51
|
+
/** Tira blocos e trechos de código (os cabeçalhos e [[links]] dentro deles são exemplo, não estrutura). */
|
|
52
|
+
const semCodigo = (t) => t.replace(/^(```|~~~)[\s\S]*?^\1[^\n]*$/gm, "").replace(/`[^`\n]*`/g, "");
|
|
53
|
+
export function parseMarkdown(rel, src) {
|
|
54
|
+
const { fm, corpo, linhas: offset } = splitFrontmatter(src);
|
|
55
|
+
const h1 = corpo.match(/^#\s+(.+)$/m);
|
|
56
|
+
const tipo = (fm.tipo || rel.split("/")[0] || "").toLowerCase();
|
|
57
|
+
const secoes = [];
|
|
58
|
+
let atual = { nivel: 0, titulo: "(abertura)", buf: [], start: offset + 1 };
|
|
59
|
+
let ordem = 0, fence = null;
|
|
60
|
+
const lines = corpo.split("\n");
|
|
61
|
+
const fecha = (fim) => {
|
|
62
|
+
const texto = atual.buf.join("\n").trim();
|
|
63
|
+
if (texto || atual.nivel)
|
|
64
|
+
secoes.push({ id: `${rel}#${ordem}`, nivel: atual.nivel, titulo: atual.titulo, ordem: ordem++, texto, startLine: atual.start, endLine: Math.max(atual.start, fim) });
|
|
65
|
+
};
|
|
66
|
+
lines.forEach((raw, i) => {
|
|
67
|
+
const l = raw.replace(/\r$/, "");
|
|
68
|
+
const f = l.match(/^(```|~~~)/);
|
|
69
|
+
if (f)
|
|
70
|
+
fence = fence === null ? f[1] : fence === f[1] ? null : fence;
|
|
71
|
+
const h = fence === null && !f ? l.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/) : null;
|
|
72
|
+
if (h) {
|
|
73
|
+
fecha(offset + i);
|
|
74
|
+
atual = { nivel: h[1].length, titulo: h[2].trim(), buf: [], start: offset + i + 1 };
|
|
75
|
+
}
|
|
76
|
+
else
|
|
77
|
+
atual.buf.push(l);
|
|
78
|
+
});
|
|
79
|
+
fecha(offset + lines.length);
|
|
80
|
+
const links = [];
|
|
81
|
+
const modelo = rel.startsWith("Modelos/");
|
|
82
|
+
if (!modelo) {
|
|
83
|
+
for (const [k, v] of Object.entries(fm))
|
|
84
|
+
for (const a of wikilinks(v))
|
|
85
|
+
links.push({ tipo: k, alvo: a });
|
|
86
|
+
for (const a of wikilinks(semCodigo(corpo)))
|
|
87
|
+
links.push({ tipo: "menciona", alvo: a });
|
|
88
|
+
}
|
|
89
|
+
let passagens = [];
|
|
90
|
+
if (!modelo) {
|
|
91
|
+
if (fm.passagens)
|
|
92
|
+
passagens = parsePassagens(fm.passagens);
|
|
93
|
+
if (tipo === "tema") {
|
|
94
|
+
const bb = secoes.find((s) => /^Base bíblica/i.test(s.titulo));
|
|
95
|
+
if (bb)
|
|
96
|
+
passagens = passagens.concat(parsePassagens(bb.texto));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
path: rel, tipo, titulo: fm.titulo || h1?.[1].trim() || path.basename(rel).replace(MD_RE, ""),
|
|
101
|
+
ano: fm.ano || "", status: fm.status || "", tamanho: Buffer.byteLength(src), secoes, links, passagens: [...new Set(passagens)],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** Nome → caminho, comparando em NFC e sem diferenciar caixa (links do macOS vêm decompostos). Nome repetido: vale o 1º por ordem de caminho. */
|
|
105
|
+
export function linkResolver(paths) {
|
|
106
|
+
const nfc = (s) => s.normalize("NFC").toLowerCase();
|
|
107
|
+
const map = new Map();
|
|
108
|
+
const add = (k, p) => { const a = map.get(k) ?? map.set(k, []).get(k); if (!a.includes(p))
|
|
109
|
+
a.push(p); };
|
|
110
|
+
for (const p of [...paths].sort()) {
|
|
111
|
+
const noExt = p.replace(MD_RE, "");
|
|
112
|
+
add(nfc(p), p);
|
|
113
|
+
add(nfc(noExt), p);
|
|
114
|
+
add(nfc(path.posix.basename(p)), p);
|
|
115
|
+
add(nfc(path.posix.basename(noExt)), p);
|
|
116
|
+
}
|
|
117
|
+
return (alvo, de) => {
|
|
118
|
+
const c = map.get(nfc(alvo));
|
|
119
|
+
if (!c)
|
|
120
|
+
return null;
|
|
121
|
+
const top = de.split("/")[0];
|
|
122
|
+
return c.find((x) => x.split("/")[0] === top) ?? c[0]; // ambíguo: prefere a mesma pasta raiz
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function listFiles(root) {
|
|
126
|
+
if (process.env.BRITO_INDEX_IGNORED !== "1") {
|
|
127
|
+
const r = spawnSync("git", ["ls-files", "-co", "--exclude-standard", "-z"], { cwd: root, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
128
|
+
if (!r.error && r.status === 0)
|
|
129
|
+
return r.stdout.split("\0").filter(Boolean).filter((f) => !IGNORE_RE.test(f) && fs.existsSync(path.join(root, f)));
|
|
130
|
+
}
|
|
131
|
+
const out = [];
|
|
132
|
+
const walk = (dir) => {
|
|
133
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
134
|
+
if (e.name.startsWith(".") || IGNORE_RE.test(e.name))
|
|
135
|
+
continue;
|
|
136
|
+
const p = path.join(dir, e.name);
|
|
137
|
+
if (e.isDirectory())
|
|
138
|
+
walk(p);
|
|
139
|
+
else
|
|
140
|
+
out.push(path.relative(root, p).split(path.sep).join("/"));
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
walk(root);
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
/** 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. */
|
|
147
|
+
export async function indexSections(root, store, opts = {}) {
|
|
148
|
+
const files = listFiles(root).filter((f) => MD_RE.test(f) || META_RE.test(f));
|
|
149
|
+
if (!files.length) {
|
|
150
|
+
const had = Number((await store.run("MATCH (d:Doc) RETURN count(d) AS n"))[0].n);
|
|
151
|
+
if (had)
|
|
152
|
+
await store.run("MATCH (d:Doc) DETACH DELETE d");
|
|
153
|
+
return { docs: 0, secoes: 0, ligas: 0, citas: 0, added: 0, changed: 0, removed: had, unresolved: 0 };
|
|
154
|
+
}
|
|
155
|
+
if (opts.full) {
|
|
156
|
+
await store.run("MATCH (s:Secao) DETACH DELETE s");
|
|
157
|
+
await store.run("MATCH (d:Doc) DETACH DELETE d");
|
|
158
|
+
await store.run("MATCH (p:Passagem) DELETE p");
|
|
159
|
+
}
|
|
160
|
+
const stored = new Map();
|
|
161
|
+
for (const r of await store.run("MATCH (d:Doc) RETURN d.path AS p, d.hash AS h"))
|
|
162
|
+
stored.set(r.p, r.h ?? "");
|
|
163
|
+
const current = new Map();
|
|
164
|
+
for (const rel of files) {
|
|
165
|
+
const abs = path.join(root, rel);
|
|
166
|
+
try {
|
|
167
|
+
const st = fs.statSync(abs);
|
|
168
|
+
if (MD_RE.test(rel)) {
|
|
169
|
+
if (st.size > MAX_MD_BYTES)
|
|
170
|
+
continue;
|
|
171
|
+
const src = fs.readFileSync(abs, "utf8");
|
|
172
|
+
current.set(rel, { src, size: st.size, hash: crypto.createHash("sha1").update(src).digest("hex") + ":" + SECTIONS_VERSION });
|
|
173
|
+
}
|
|
174
|
+
else
|
|
175
|
+
current.set(rel, { size: st.size, hash: `meta:${st.size}:${SECTIONS_VERSION}` });
|
|
176
|
+
}
|
|
177
|
+
catch { /* ilegível: ignora */ }
|
|
178
|
+
}
|
|
179
|
+
const added = [], changed = [], removed = [];
|
|
180
|
+
for (const [p, { hash }] of current) {
|
|
181
|
+
const old = stored.get(p);
|
|
182
|
+
if (old === undefined)
|
|
183
|
+
added.push(p);
|
|
184
|
+
else if (old !== hash)
|
|
185
|
+
changed.push(p);
|
|
186
|
+
}
|
|
187
|
+
for (const p of stored.keys())
|
|
188
|
+
if (!current.has(p))
|
|
189
|
+
removed.push(p);
|
|
190
|
+
if (added.length + changed.length + removed.length)
|
|
191
|
+
opts.onProgress?.(`markdown: +${added.length} ~${changed.length} -${removed.length}`);
|
|
192
|
+
for (const p of [...removed, ...changed]) {
|
|
193
|
+
await store.run("MATCH (s:Secao {doc:$p}) DETACH DELETE s", { p });
|
|
194
|
+
await store.run("MATCH (d:Doc {path:$p}) DETACH DELETE d", { p });
|
|
195
|
+
}
|
|
196
|
+
const parsed = new Map();
|
|
197
|
+
for (const rel of [...added, ...changed]) {
|
|
198
|
+
const c = current.get(rel);
|
|
199
|
+
const doc = c.src !== undefined ? parseMarkdown(rel, c.src)
|
|
200
|
+
: { path: rel, tipo: path.extname(rel).slice(1).toLowerCase(), titulo: path.basename(rel), ano: "", status: "", tamanho: c.size, secoes: [], links: [], passagens: [] };
|
|
201
|
+
parsed.set(rel, doc);
|
|
202
|
+
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}`) });
|
|
203
|
+
for (const s of doc.secoes) {
|
|
204
|
+
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 });
|
|
205
|
+
}
|
|
206
|
+
for (const ref of doc.passagens) {
|
|
207
|
+
await store.run("MERGE (p:Passagem {ref:$ref})", { ref });
|
|
208
|
+
await store.run("MATCH (d:Doc {path:$d}), (p:Passagem {ref:$ref}) CREATE (d)-[:CITA]->(p)", { d: rel, ref });
|
|
209
|
+
}
|
|
210
|
+
await store.yieldIfWaited();
|
|
211
|
+
}
|
|
212
|
+
// LIGA: recalculada em memória a partir dos links crus guardados em cada Doc; aplica só a diferença.
|
|
213
|
+
const resolve = linkResolver(current.keys());
|
|
214
|
+
const want = new Set();
|
|
215
|
+
let unresolved = 0;
|
|
216
|
+
const raw = new Map();
|
|
217
|
+
for (const r of await store.run("MATCH (d:Doc) RETURN d.path AS p, d.links AS l"))
|
|
218
|
+
raw.set(r.p, r.l ?? []);
|
|
219
|
+
for (const [de, ls] of raw) {
|
|
220
|
+
for (const l of ls) {
|
|
221
|
+
const [tipo, alvo] = l.split("\t");
|
|
222
|
+
const para = resolve(alvo, de);
|
|
223
|
+
if (!para) {
|
|
224
|
+
unresolved++;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (para !== de)
|
|
228
|
+
want.add([de, para, tipo].join("\t"));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
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")));
|
|
232
|
+
for (const k of have)
|
|
233
|
+
if (!want.has(k)) {
|
|
234
|
+
const [a, b, t] = k.split("\t");
|
|
235
|
+
await store.run("MATCH (x:Doc {path:$a})-[r:LIGA {tipo:$t}]->(y:Doc {path:$b}) DELETE r", { a, b, t });
|
|
236
|
+
}
|
|
237
|
+
for (const k of want)
|
|
238
|
+
if (!have.has(k)) {
|
|
239
|
+
const [a, b, t] = k.split("\t");
|
|
240
|
+
await store.run("MATCH (x:Doc {path:$a}), (y:Doc {path:$b}) CREATE (x)-[:LIGA {tipo:$t}]->(y)", { a, b, t });
|
|
241
|
+
}
|
|
242
|
+
const n = async (q) => Number((await store.run(q))[0].n);
|
|
243
|
+
return {
|
|
244
|
+
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"),
|
|
245
|
+
added: added.length, changed: changed.length, removed: removed.length, unresolved,
|
|
246
|
+
};
|
|
247
|
+
}
|
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
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* O cofre como grafo em memória (Doc + Passagem; arestas LIGA e CITA) para pathfinding e expansão por PageRank.
|
|
3
|
+
* O corpo (texto das seções) NÃO fica aqui: só os nós e as arestas. Quem mostra o resultado busca o texto no banco.
|
|
4
|
+
*/
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import { WGraph } from "./pathfind.js";
|
|
7
|
+
import { fold } from "./vault.js";
|
|
8
|
+
const cache = new WeakMap();
|
|
9
|
+
export const HUB_DEGREE = 40;
|
|
10
|
+
const pair = (a, b) => (a < b ? `${a}|${b}` : `${b}|${a}`);
|
|
11
|
+
export const label = (v, i) => (i < v.nDocs ? v.paths[i] : `[${v.refs[i - v.nDocs]}]`);
|
|
12
|
+
export const relOf = (v, a, b) => v.rel.get(pair(a, b)) ?? "?";
|
|
13
|
+
export async function vaultGraph(store) {
|
|
14
|
+
const docs = (await store.run("MATCH (d:Doc) RETURN d.path AS p, d.titulo AS t, d.hash AS h ORDER BY p"));
|
|
15
|
+
const liga = (await store.run("MATCH (a:Doc)-[r:LIGA]->(b:Doc) RETURN a.path AS a, b.path AS b, r.tipo AS t"));
|
|
16
|
+
const cita = (await store.run("MATCH (d:Doc)-[:CITA]->(p:Passagem) RETURN d.path AS d, p.ref AS r"));
|
|
17
|
+
const sig = crypto.createHash("sha1").update(docs.map((d) => d.p + d.h).join("\n")).digest("hex") + `:${liga.length}:${cita.length}`;
|
|
18
|
+
const old = cache.get(store);
|
|
19
|
+
if (old && old.sig === sig)
|
|
20
|
+
return old;
|
|
21
|
+
const idx = new Map(docs.map((d, i) => [d.p, i]));
|
|
22
|
+
const refIdx = new Map();
|
|
23
|
+
for (const c of cita)
|
|
24
|
+
if (!refIdx.has(c.r))
|
|
25
|
+
refIdx.set(c.r, docs.length + refIdx.size);
|
|
26
|
+
const edges = new Map();
|
|
27
|
+
const rel = new Map();
|
|
28
|
+
const add = (a, b, cost, t) => {
|
|
29
|
+
const k = pair(a, b);
|
|
30
|
+
const o = edges.get(k);
|
|
31
|
+
if (!o || cost < o[2]) {
|
|
32
|
+
edges.set(k, [a, b, cost]);
|
|
33
|
+
rel.set(k, t);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
for (const l of liga) {
|
|
37
|
+
const a = idx.get(l.a), b = idx.get(l.b);
|
|
38
|
+
if (a !== undefined && b !== undefined && a !== b)
|
|
39
|
+
add(a, b, l.t === "menciona" ? 1.6 : 1.0, l.t);
|
|
40
|
+
}
|
|
41
|
+
for (const c of cita) {
|
|
42
|
+
const a = idx.get(c.d);
|
|
43
|
+
if (a !== undefined)
|
|
44
|
+
add(a, refIdx.get(c.r), 1.2, "cita");
|
|
45
|
+
}
|
|
46
|
+
const g = new WGraph(docs.length + refIdx.size, [...edges.values()]);
|
|
47
|
+
const first = g.deg.findIndex((d) => d > 0);
|
|
48
|
+
const v = {
|
|
49
|
+
g, lm: first < 0 ? [] : g.landmarks(8, first), nDocs: docs.length,
|
|
50
|
+
paths: docs.map((d) => d.p), titles: docs.map((d) => d.t), refs: [...refIdx.keys()], rel, sig,
|
|
51
|
+
};
|
|
52
|
+
cache.set(store, v);
|
|
53
|
+
return v;
|
|
54
|
+
}
|
|
55
|
+
/** Resolve um nome (caminho, título, nome de arquivo ou passagem "Rm 9.13") para nós. Exato primeiro, depois por inclusão. */
|
|
56
|
+
export function resolveEntity(v, q) {
|
|
57
|
+
const f = fold(q).trim();
|
|
58
|
+
if (!f)
|
|
59
|
+
return [];
|
|
60
|
+
const names = (i) => [fold(v.paths[i]), fold(v.paths[i].replace(/\.\w+$/, "")), fold(v.paths[i].split("/").pop().replace(/\.\w+$/, "")), fold(v.titles[i] ?? "")];
|
|
61
|
+
const exact = [];
|
|
62
|
+
for (let i = 0; i < v.nDocs; i++)
|
|
63
|
+
if (names(i).includes(f))
|
|
64
|
+
exact.push(i);
|
|
65
|
+
v.refs.forEach((r, k) => { if (fold(r) === f)
|
|
66
|
+
exact.push(v.nDocs + k); });
|
|
67
|
+
if (exact.length)
|
|
68
|
+
return exact;
|
|
69
|
+
const part = [];
|
|
70
|
+
for (let i = 0; i < v.nDocs; i++)
|
|
71
|
+
if (names(i).some((n) => n.length >= 3 && n.includes(f)))
|
|
72
|
+
part.push(i);
|
|
73
|
+
return part.sort((a, b) => v.paths[a].length - v.paths[b].length).slice(0, 5);
|
|
74
|
+
}
|
|
75
|
+
/** Entidades nomeadas na pergunta: títulos de nota que aparecem inteiros no texto (a mais longa vence a que ela contém). */
|
|
76
|
+
export function entitiesIn(v, query) {
|
|
77
|
+
const clean = (s) => fold(s).replace(/[^\p{L}\p{N}.]+/gu, " ").trim();
|
|
78
|
+
const q = ` ${clean(query)} `;
|
|
79
|
+
const hit = [];
|
|
80
|
+
for (let i = 0; i < v.nDocs; i++) {
|
|
81
|
+
const t = clean(v.titles[i] ?? "");
|
|
82
|
+
if (t.length >= 4 && q.includes(` ${t} `))
|
|
83
|
+
hit.push(i);
|
|
84
|
+
}
|
|
85
|
+
const keep = hit.filter((i) => !hit.some((j) => j !== i && clean(v.titles[j]).includes(clean(v.titles[i])) && clean(v.titles[j]).length > clean(v.titles[i]).length));
|
|
86
|
+
v.refs.forEach((r, k) => { if (q.includes(` ${fold(r)} `))
|
|
87
|
+
keep.push(v.nDocs + k); });
|
|
88
|
+
return keep;
|
|
89
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { vaultSearch } from "./vault.js";
|
|
2
|
+
import { searchSecoes } from "./vault.js";
|
|
3
|
+
import { HUB_DEGREE, entitiesIn, label, relOf, resolveEntity, vaultGraph } from "./vaultgraph.js";
|
|
4
|
+
const isNote = (v, i) => i < v.nDocs && v.paths[i].endsWith(".md") && !/^(Diário de estudo|Modelos)\//.test(v.paths[i]) && !v.paths[i].startsWith("00 ");
|
|
5
|
+
const clip = (s, n = 150) => { const f = s.replace(/\s+/g, " ").trim(); return f.length > n ? f.slice(0, n) + " …" : f; };
|
|
6
|
+
async function intro(store, doc) {
|
|
7
|
+
const rows = await store.run("MATCH (s:Secao {doc:$d}) RETURN s.texto AS x ORDER BY s.ordem LIMIT 6", { d: doc });
|
|
8
|
+
for (const r of rows) {
|
|
9
|
+
// pula seções que são só lista de links ou nota de página: pega a primeira com prosa
|
|
10
|
+
const prose = String(r.x).split("\n").filter((l) => !/^\s*[-*]?\s*(\[\[[^\]]*\]\]\s*)+$/.test(l) && !/^\s*Páginas\s*=/.test(l)).join(" ").replace(/\[\[([^\]|]*)(?:\|[^\]]*)?\]\]/g, "$1");
|
|
11
|
+
if (prose.replace(/\s+/g, " ").trim().length >= 40)
|
|
12
|
+
return clip(prose);
|
|
13
|
+
}
|
|
14
|
+
return "";
|
|
15
|
+
}
|
|
16
|
+
/** Por que `r` apareceu: ligação direta com uma semente ou um vizinho em comum com ela. */
|
|
17
|
+
function via(v, r, seeds) {
|
|
18
|
+
const S = new Set(seeds);
|
|
19
|
+
for (const e of v.g.adj[r])
|
|
20
|
+
if (S.has(e.to))
|
|
21
|
+
return `${label(v, e.to)} (${relOf(v, r, e.to)})`;
|
|
22
|
+
const nbS = new Map();
|
|
23
|
+
for (const s of seeds)
|
|
24
|
+
for (const e of v.g.adj[s])
|
|
25
|
+
if (!nbS.has(e.to))
|
|
26
|
+
nbS.set(e.to, s);
|
|
27
|
+
for (const e of v.g.adj[r])
|
|
28
|
+
if (nbS.has(e.to) && v.g.deg[e.to] <= HUB_DEGREE)
|
|
29
|
+
return `${label(v, nbS.get(e.to))} → ${label(v, e.to)} (${relOf(v, r, e.to)})`;
|
|
30
|
+
return "";
|
|
31
|
+
}
|
|
32
|
+
export async function vaultSearchGraph(root, store, query, opts = {}) {
|
|
33
|
+
const base = await vaultSearch(root, store, query, opts);
|
|
34
|
+
const v = await vaultGraph(store);
|
|
35
|
+
if (!v.nDocs)
|
|
36
|
+
return base;
|
|
37
|
+
const ents = entitiesIn(v, query);
|
|
38
|
+
const hits = await searchSecoes(store, query, { em: opts.em, limit: 10 });
|
|
39
|
+
const hitIdx = [];
|
|
40
|
+
for (const h of hits) {
|
|
41
|
+
const i = v.paths.indexOf(h.doc);
|
|
42
|
+
if (i >= 0 && !hitIdx.includes(i))
|
|
43
|
+
hitIdx.push(i);
|
|
44
|
+
}
|
|
45
|
+
const join = ents.length >= 2;
|
|
46
|
+
const seeds = join ? ents.slice(0, 4) : [...new Set([...ents, ...hitIdx.slice(0, 3)])];
|
|
47
|
+
if (!seeds.length)
|
|
48
|
+
return base;
|
|
49
|
+
const w = new Map();
|
|
50
|
+
if (join)
|
|
51
|
+
seeds.forEach((s) => w.set(s, 1));
|
|
52
|
+
else {
|
|
53
|
+
ents.forEach((e) => w.set(e, 2));
|
|
54
|
+
hitIdx.slice(0, 3).forEach((d, i) => w.set(d, (w.get(d) ?? 0) + 1 / (1 + i)));
|
|
55
|
+
}
|
|
56
|
+
const p = v.g.ppr(w);
|
|
57
|
+
const skip = new Set([...seeds, ...(join ? [] : hitIdx)]);
|
|
58
|
+
const cand = [];
|
|
59
|
+
for (let i = 0; i < v.nDocs; i++)
|
|
60
|
+
if (p[i] > 0 && !skip.has(i) && isNote(v, i) && v.g.deg[i] <= HUB_DEGREE * 2)
|
|
61
|
+
cand.push(i);
|
|
62
|
+
cand.sort((a, b) => p[b] - p[a]);
|
|
63
|
+
const top = cand.slice(0, opts.limit ?? 5);
|
|
64
|
+
if (!top.length)
|
|
65
|
+
return base;
|
|
66
|
+
const head = join ? `## Notas ligadas a ${seeds.map((s) => label(v, s)).join(" e ")} (junção no grafo)` : "## Relacionadas pelo grafo (não citam as palavras, mas se ligam às notas achadas)";
|
|
67
|
+
const lines = [head];
|
|
68
|
+
for (const r of top)
|
|
69
|
+
lines.push(`- ${v.paths[r]} | ${v.titles[r]}${via(v, r, seeds) ? ` | via ${via(v, r, seeds)}` : ""}`);
|
|
70
|
+
return join ? `${lines.join("\n")}\n\n${base}` : `${base}\n\n${lines.join("\n")}`;
|
|
71
|
+
}
|
|
72
|
+
/** Caminho de menor custo entre duas notas/passagens, com a relação de cada salto e o início do texto de cada nota. */
|
|
73
|
+
export async function vaultPath(store, de, para) {
|
|
74
|
+
const v = await vaultGraph(store);
|
|
75
|
+
const A = resolveEntity(v, de), B = resolveEntity(v, para);
|
|
76
|
+
if (!A.length)
|
|
77
|
+
return `não achei nada para "${de}" (use título, caminho ou passagem como "Rm 9.13")`;
|
|
78
|
+
if (!B.length)
|
|
79
|
+
return `não achei nada para "${para}" (use título, caminho ou passagem como "Rm 9.13")`;
|
|
80
|
+
const a = A[0], b = B[0];
|
|
81
|
+
const notes = [];
|
|
82
|
+
if (A.length > 1)
|
|
83
|
+
notes.push(`"${de}" casa com ${A.length} itens; usei ${label(v, a)} (outros: ${A.slice(1, 4).map((x) => label(v, x)).join(", ")})`);
|
|
84
|
+
if (B.length > 1)
|
|
85
|
+
notes.push(`"${para}" casa com ${B.length} itens; usei ${label(v, b)} (outros: ${B.slice(1, 4).map((x) => label(v, x)).join(", ")})`);
|
|
86
|
+
if (a === b)
|
|
87
|
+
return "origem e destino são o mesmo item";
|
|
88
|
+
const r = v.g.astar(a, b, v.lm);
|
|
89
|
+
if (!r.path.length)
|
|
90
|
+
return `sem caminho entre ${label(v, a)} e ${label(v, b)}`;
|
|
91
|
+
const out = [`Caminho de menor custo (${r.path.length - 1} saltos, custo ${r.cost.toFixed(2)}, ${r.settled} nós visitados):`];
|
|
92
|
+
for (let k = 0; k < r.path.length; k++) {
|
|
93
|
+
const n = r.path[k];
|
|
94
|
+
const text = n < v.nDocs ? await intro(store, v.paths[n]) : "";
|
|
95
|
+
out.push(`${k + 1}. ${label(v, n)}${n < v.nDocs && v.titles[n] ? ` — ${v.titles[n]}` : ""}${text ? `\n ${text}` : ""}`);
|
|
96
|
+
if (k < r.path.length - 1)
|
|
97
|
+
out.push(` ↓ ${relOf(v, n, r.path[k + 1])}`);
|
|
98
|
+
}
|
|
99
|
+
const hubs = r.path.slice(1, -1).filter((n) => v.g.deg[n] > HUB_DEGREE);
|
|
100
|
+
if (hubs.length)
|
|
101
|
+
out.push(`Aviso: o caminho passa por nó muito ligado (${hubs.map((n) => label(v, n)).join(", ")}); a relação pode ser fraca.`);
|
|
102
|
+
return [...notes, ...out].join("\n");
|
|
103
|
+
}
|
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";
|
|
@@ -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,30 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { defineTool } from "./registry.js";
|
|
3
|
+
import { vaultPath, vaultSearchGraph } from "../graph/vaultquery.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]]) as notas relacionadas pelo grafo (que não citam as palavras mas se ligam às achadas; com 2 entidades na pergunta, as notas ligadas às duas) 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 vaultSearchGraph(root, graph, query, { em, limit });
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
defineTool({
|
|
19
|
+
name: "vault_path",
|
|
20
|
+
concurrent: true,
|
|
21
|
+
readsGraph: true,
|
|
22
|
+
description: "Como duas notas ou passagens se ligam: caminho de menor custo no grafo do cofre (A*), com a relação de cada salto (autor, fonte, temas, menciona, cita) e o início do texto de cada nota. Evita nós muito ligados. USE para 'como X se relaciona com Y?'. Aceita título, caminho ou passagem ('Rm 9.13').",
|
|
23
|
+
schema: z.object({
|
|
24
|
+
de: z.string().describe("nota ou passagem de origem"),
|
|
25
|
+
para: z.string().describe("nota ou passagem de destino"),
|
|
26
|
+
}),
|
|
27
|
+
async run({ de, para }, { graph }) {
|
|
28
|
+
return vaultPath(graph, de, para);
|
|
29
|
+
},
|
|
30
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brito-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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": {
|