@northtek/overstory 0.1.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/LICENSE +201 -0
- package/README.md +115 -0
- package/dist/build/aggregate.d.ts +9 -0
- package/dist/build/aggregate.js +74 -0
- package/dist/build/builder.d.ts +52 -0
- package/dist/build/builder.js +223 -0
- package/dist/build/inmemory.d.ts +16 -0
- package/dist/build/inmemory.js +79 -0
- package/dist/build/pool.d.ts +3 -0
- package/dist/build/pool.js +16 -0
- package/dist/build/reflexion.d.ts +15 -0
- package/dist/build/reflexion.js +116 -0
- package/dist/build/summarize.d.ts +25 -0
- package/dist/build/summarize.js +146 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +323 -0
- package/dist/core/bm25.d.ts +19 -0
- package/dist/core/bm25.js +48 -0
- package/dist/core/chunk.d.ts +5 -0
- package/dist/core/chunk.js +91 -0
- package/dist/core/corpus.d.ts +21 -0
- package/dist/core/corpus.js +112 -0
- package/dist/core/gate.d.ts +25 -0
- package/dist/core/gate.js +155 -0
- package/dist/core/hash.d.ts +7 -0
- package/dist/core/hash.js +15 -0
- package/dist/core/store.d.ts +74 -0
- package/dist/core/store.js +66 -0
- package/dist/core/types.d.ts +96 -0
- package/dist/core/types.js +1 -0
- package/dist/fix/prompts.d.ts +4 -0
- package/dist/fix/prompts.js +124 -0
- package/dist/fix/scan.d.ts +18 -0
- package/dist/fix/scan.js +197 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +22 -0
- package/dist/llm/anthropic.d.ts +11 -0
- package/dist/llm/anthropic.js +46 -0
- package/dist/llm/mock.d.ts +5 -0
- package/dist/llm/mock.js +20 -0
- package/dist/llm/ollama.d.ts +11 -0
- package/dist/llm/ollama.js +49 -0
- package/dist/llm/provider.d.ts +16 -0
- package/dist/llm/provider.js +7 -0
- package/dist/llm/resolve.d.ts +14 -0
- package/dist/llm/resolve.js +24 -0
- package/dist/mcp/server.d.ts +7 -0
- package/dist/mcp/server.js +136 -0
- package/dist/query/ask.d.ts +53 -0
- package/dist/query/ask.js +127 -0
- package/dist/query/notarize.d.ts +40 -0
- package/dist/query/notarize.js +31 -0
- package/dist/registry/github.d.ts +28 -0
- package/dist/registry/github.js +113 -0
- package/dist/registry/publishClient.d.ts +25 -0
- package/dist/registry/publishClient.js +39 -0
- package/dist/registry/registry.d.ts +35 -0
- package/dist/registry/registry.js +57 -0
- package/dist/registry/repoTree.d.ts +9 -0
- package/dist/registry/repoTree.js +32 -0
- package/dist/serve/app.d.ts +4 -0
- package/dist/serve/app.js +809 -0
- package/dist/serve/server.d.ts +9 -0
- package/dist/serve/server.js +180 -0
- package/dist/serve/threads.d.ts +31 -0
- package/dist/serve/threads.js +69 -0
- package/dist/site/data.d.ts +58 -0
- package/dist/site/data.js +88 -0
- package/dist/site/generate.d.ts +21 -0
- package/dist/site/generate.js +827 -0
- package/package.json +64 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { buildTree, verifyExisting } from '../build/builder.js';
|
|
6
|
+
import { loadCorpus } from '../core/corpus.js';
|
|
7
|
+
import { loadTree, treePath } from '../core/store.js';
|
|
8
|
+
import { resolveProviders } from '../llm/resolve.js';
|
|
9
|
+
import { runStdioServer } from '../mcp/server.js';
|
|
10
|
+
import { serve } from '../serve/server.js';
|
|
11
|
+
import { ask } from '../query/ask.js';
|
|
12
|
+
import { buildSiteData } from '../site/data.js';
|
|
13
|
+
import { generateSiteHtml } from '../site/generate.js';
|
|
14
|
+
import { verifyTree } from '../core/gate.js';
|
|
15
|
+
const HELP = `overstory — a knowledge tree of your codebase where every claim carries a receipt.
|
|
16
|
+
Local-first: nothing leaves this machine.
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
overstory build [path] Build or refresh the tree (resumable; reuses unchanged files)
|
|
20
|
+
overstory serve [path] Open the app: ask your codebase, every answer notarized
|
|
21
|
+
overstory verify [path] Check every receipt against the current code (exit 1 if any fail)
|
|
22
|
+
overstory ask "question" [path] Answer from the tree with per-sentence receipts
|
|
23
|
+
overstory fix [path] Paste-ready fix prompts for your agent, grounded in receipts
|
|
24
|
+
overstory publish [path] Publish your tree to the registry (re-verified server-side)
|
|
25
|
+
overstory site [path] Export the shareable single-file explorer
|
|
26
|
+
overstory mcp [path] Serve MCP tools over stdio (map/search/node/verify)
|
|
27
|
+
|
|
28
|
+
Options:
|
|
29
|
+
--provider <auto|ollama|anthropic|none> LLM for summaries (default auto; none = extractive)
|
|
30
|
+
--model <name> Override the provider model
|
|
31
|
+
--rounds <n> Reflexion critique rounds (default 1)
|
|
32
|
+
--include <glob> Only include matching paths (repeatable)
|
|
33
|
+
--max-files <n> Corpus cap (default 5000)
|
|
34
|
+
--out <file> Output path for site
|
|
35
|
+
--port <n> Port for serve (default 7433)
|
|
36
|
+
--json Machine-readable output
|
|
37
|
+
`;
|
|
38
|
+
/** A mis-typed numeric flag must error, never silently disable the cap it configures. */
|
|
39
|
+
const numFlag = (raw, flag) => {
|
|
40
|
+
const n = Number(raw);
|
|
41
|
+
if (raw === undefined || !Number.isFinite(n) || n < 0) {
|
|
42
|
+
throw new Error(`${flag} requires a non-negative number (got "${raw ?? ''}")`);
|
|
43
|
+
}
|
|
44
|
+
return n;
|
|
45
|
+
};
|
|
46
|
+
const parseArgs = (argv) => {
|
|
47
|
+
const flags = { provider: 'auto', rounds: 1, include: [], json: false };
|
|
48
|
+
const positional = [];
|
|
49
|
+
for (let i = 0; i < argv.length; i++) {
|
|
50
|
+
const a = argv[i];
|
|
51
|
+
if (a === '--json')
|
|
52
|
+
flags.json = true;
|
|
53
|
+
else if (a === '--provider')
|
|
54
|
+
flags.provider = argv[++i] ?? 'auto';
|
|
55
|
+
else if (a === '--model')
|
|
56
|
+
flags.model = argv[++i];
|
|
57
|
+
else if (a === '--rounds')
|
|
58
|
+
flags.rounds = numFlag(argv[++i], '--rounds');
|
|
59
|
+
else if (a === '--include')
|
|
60
|
+
flags.include.push(argv[++i] ?? '');
|
|
61
|
+
else if (a === '--max-files')
|
|
62
|
+
flags.maxFiles = numFlag(argv[++i], '--max-files');
|
|
63
|
+
else if (a === '--out')
|
|
64
|
+
flags.out = argv[++i];
|
|
65
|
+
else if (a === '--port')
|
|
66
|
+
flags.port = numFlag(argv[++i], '--port');
|
|
67
|
+
else if (a === '--help' || a === '-h')
|
|
68
|
+
positional.push('help');
|
|
69
|
+
else
|
|
70
|
+
positional.push(a);
|
|
71
|
+
}
|
|
72
|
+
const cmd = positional.shift() ?? 'help';
|
|
73
|
+
return { cmd, positional, flags };
|
|
74
|
+
};
|
|
75
|
+
const log = (msg) => {
|
|
76
|
+
process.stderr.write(`${msg}\n`);
|
|
77
|
+
};
|
|
78
|
+
const resolveProvider = (flags) => resolveProviders({ provider: flags.provider, model: flags.model, onNotice: log });
|
|
79
|
+
const main = async () => {
|
|
80
|
+
const { cmd, positional, flags } = parseArgs(process.argv.slice(2));
|
|
81
|
+
const rootArg = positional[cmd === 'ask' ? 1 : 0];
|
|
82
|
+
const root = resolve(rootArg ?? '.');
|
|
83
|
+
if (cmd === 'help') {
|
|
84
|
+
process.stdout.write(HELP);
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
if (rootArg !== undefined && !existsSync(root)) {
|
|
88
|
+
log(cmd === 'ask'
|
|
89
|
+
? `path not found: ${root} — quote multi-word questions: overstory ask "your question here"`
|
|
90
|
+
: `path not found: ${root}`);
|
|
91
|
+
return 2;
|
|
92
|
+
}
|
|
93
|
+
if (cmd === 'build') {
|
|
94
|
+
const { provider, critic } = await resolveProvider(flags);
|
|
95
|
+
log(`overstory build — ${root}`);
|
|
96
|
+
log(provider ? `provider: ${provider.name} (reflexion rounds: ${flags.rounds})` : 'provider: extractive (no LLM)');
|
|
97
|
+
const started = Date.now();
|
|
98
|
+
let lastLine = '';
|
|
99
|
+
const result = await buildTree(root, {
|
|
100
|
+
provider,
|
|
101
|
+
critic,
|
|
102
|
+
reflexionRounds: flags.rounds,
|
|
103
|
+
include: flags.include.length ? flags.include : undefined,
|
|
104
|
+
maxFiles: flags.maxFiles,
|
|
105
|
+
onProgress: (e) => {
|
|
106
|
+
if (e.phase === 'leaves' && e.total) {
|
|
107
|
+
lastLine = ` leaves ${e.done}/${e.total} ${e.reused ? '(reused) ' : ''}${e.file ?? ''}`;
|
|
108
|
+
log(lastLine);
|
|
109
|
+
}
|
|
110
|
+
else if (e.phase === 'aggregate' && e.done === 1)
|
|
111
|
+
log(' rolling up directories…');
|
|
112
|
+
else if (e.phase === 'verify')
|
|
113
|
+
log(' gate sweep: verifying every receipt…');
|
|
114
|
+
else if (e.phase === 'warn')
|
|
115
|
+
log(` WARNING: ${e.message ?? 'unknown'}`);
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
const secs = ((Date.now() - started) / 1000).toFixed(1);
|
|
119
|
+
const pct = Math.round(result.verification.freshness * 100);
|
|
120
|
+
if (flags.json) {
|
|
121
|
+
process.stdout.write(JSON.stringify({
|
|
122
|
+
nodes: Object.keys(result.tree.nodes).length,
|
|
123
|
+
claims: result.verification.verdicts.size,
|
|
124
|
+
freshness: result.verification.freshness,
|
|
125
|
+
reusedLeaves: result.reusedLeaves,
|
|
126
|
+
rebuiltLeaves: result.rebuiltLeaves,
|
|
127
|
+
skipped: result.corpus.skipped,
|
|
128
|
+
seconds: Number(secs),
|
|
129
|
+
}) + '\n');
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
log(`done in ${secs}s — ${Object.keys(result.tree.nodes).length} nodes, ${result.verification.verdicts.size} claims, ${pct}% verified (${result.reusedLeaves} leaves reused, ${result.rebuiltLeaves} rebuilt)`);
|
|
133
|
+
log(`tree: ${treePath(root)}`);
|
|
134
|
+
log(`next: overstory site · overstory ask "what does this repo do?" · overstory mcp`);
|
|
135
|
+
}
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
if (cmd === 'verify') {
|
|
139
|
+
const result = await verifyExisting(root, { include: flags.include.length ? flags.include : undefined, maxFiles: flags.maxFiles });
|
|
140
|
+
if (!result) {
|
|
141
|
+
log('No tree found (or the tree file is invalid). Run: overstory build');
|
|
142
|
+
return 2;
|
|
143
|
+
}
|
|
144
|
+
const pct = Math.round(result.verification.freshness * 100);
|
|
145
|
+
if (flags.json) {
|
|
146
|
+
process.stdout.write(JSON.stringify({ freshness: result.verification.freshness, staleFiles: result.staleFiles }) + '\n');
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
log(`${pct}% of claims verified against the current code`);
|
|
150
|
+
if (result.staleFiles.length > 0) {
|
|
151
|
+
log(`stale evidence in: ${result.staleFiles.join(', ')}`);
|
|
152
|
+
log('run: overstory build (rebuilds only what changed)');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return result.verification.freshness === 1 ? 0 : 1;
|
|
156
|
+
}
|
|
157
|
+
if (cmd === 'ask') {
|
|
158
|
+
const question = positional[0];
|
|
159
|
+
if (!question) {
|
|
160
|
+
log('usage: overstory ask "your question" [path]');
|
|
161
|
+
return 2;
|
|
162
|
+
}
|
|
163
|
+
const tree = await loadTree(treePath(root));
|
|
164
|
+
if (!tree) {
|
|
165
|
+
log('No tree found. Run: overstory build');
|
|
166
|
+
return 2;
|
|
167
|
+
}
|
|
168
|
+
const { provider } = await resolveProvider(flags);
|
|
169
|
+
const corpus = await loadCorpus(root, tree.corpusOptions ?? {});
|
|
170
|
+
const result = await ask(question, tree, corpus, provider);
|
|
171
|
+
if (flags.json) {
|
|
172
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
process.stdout.write(`\n${result.question}\n\n`);
|
|
176
|
+
for (const s of result.sentences) {
|
|
177
|
+
const refs = s.refs.map((r) => r.replace(/^leaf:/u, '')).join(', ');
|
|
178
|
+
process.stdout.write(` [${s.verdict}] ${s.text}\n receipts: ${refs}\n`);
|
|
179
|
+
}
|
|
180
|
+
if (result.unverifiable.length > 0) {
|
|
181
|
+
process.stdout.write(`\n quarantined (no verifiable citation — do not trust):\n`);
|
|
182
|
+
for (const u of result.unverifiable)
|
|
183
|
+
process.stdout.write(` [UNVERIFIABLE] ${u}\n`);
|
|
184
|
+
}
|
|
185
|
+
process.stdout.write(`\n grounding: ${Math.round(result.grounding * 100)}% of sentences verified · mode: ${result.mode}\n`);
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
if (cmd === 'site') {
|
|
189
|
+
const tree = await loadTree(treePath(root));
|
|
190
|
+
if (!tree) {
|
|
191
|
+
log('No tree found. Run: overstory build');
|
|
192
|
+
return 2;
|
|
193
|
+
}
|
|
194
|
+
const corpus = await loadCorpus(root, tree.corpusOptions ?? {});
|
|
195
|
+
const verification = verifyTree(tree, corpus);
|
|
196
|
+
const html = generateSiteHtml(buildSiteData(tree, verification, corpus));
|
|
197
|
+
const out = flags.out ?? join(root, '.overstory', 'site.html');
|
|
198
|
+
await writeFile(out, html, 'utf8');
|
|
199
|
+
log(`explorer written: ${out} (${(html.length / 1024).toFixed(0)} KB, single file, works offline)`);
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
if (cmd === 'fix') {
|
|
203
|
+
const { scanFindings } = await import('../fix/scan.js');
|
|
204
|
+
const { findingsToMarkdown } = await import('../fix/prompts.js');
|
|
205
|
+
const tree = await loadTree(treePath(root));
|
|
206
|
+
if (!tree) {
|
|
207
|
+
log('No tree found. Run: overstory build (then fix)');
|
|
208
|
+
return 2;
|
|
209
|
+
}
|
|
210
|
+
const corpus = await loadCorpus(root, tree.corpusOptions ?? {});
|
|
211
|
+
const verification = verifyTree(tree, corpus);
|
|
212
|
+
// Scan the WHOLE repo, not the tree's build scope — a scope of src/** literally cannot
|
|
213
|
+
// see tests/, which turns "untested module" into a lie.
|
|
214
|
+
const scanCorpus = tree.corpusOptions?.include
|
|
215
|
+
? await loadCorpus(root, { maxFiles: tree.corpusOptions.maxFiles })
|
|
216
|
+
: corpus;
|
|
217
|
+
const findings = scanFindings(scanCorpus, tree, verification);
|
|
218
|
+
if (flags.json) {
|
|
219
|
+
process.stdout.write(JSON.stringify({ findings }) + '\n');
|
|
220
|
+
return 0;
|
|
221
|
+
}
|
|
222
|
+
if (findings.length === 0) {
|
|
223
|
+
log('No findings — this tree is clean by every deterministic check.');
|
|
224
|
+
return 0;
|
|
225
|
+
}
|
|
226
|
+
const md = findingsToMarkdown(findings, tree.name);
|
|
227
|
+
const out = flags.out ?? join(root, '.overstory', 'fixes.md');
|
|
228
|
+
await writeFile(out, md, 'utf8');
|
|
229
|
+
const sev = (n) => findings.filter((f) => f.severity === n).length;
|
|
230
|
+
log(`${findings.length} findings (${sev(1)} fix-first, ${sev(2)} soon, ${sev(3)} when convenient) — each rendered as a paste-ready, receipt-grounded prompt`);
|
|
231
|
+
for (const f of findings.slice(0, 10))
|
|
232
|
+
log(` [${f.severity === 1 ? '!' : f.severity === 2 ? '~' : '·'}] ${f.title}`);
|
|
233
|
+
if (findings.length > 10)
|
|
234
|
+
log(` … ${findings.length - 10} more`);
|
|
235
|
+
log(`prompts: ${out} (also in the app: overstory serve → Fixes)`);
|
|
236
|
+
return 0;
|
|
237
|
+
}
|
|
238
|
+
if (cmd === 'publish') {
|
|
239
|
+
const { DEFAULT_REGISTRY, checkPublished, detectGithubRepo } = await import('../registry/publishClient.js');
|
|
240
|
+
const tree = await loadTree(treePath(root));
|
|
241
|
+
if (!tree) {
|
|
242
|
+
log('No tree found. Run: overstory build (then publish)');
|
|
243
|
+
return 2;
|
|
244
|
+
}
|
|
245
|
+
// Local preflight: the registry will verify against GitHub; verify here first so the
|
|
246
|
+
// instructions we print are ones that will actually succeed.
|
|
247
|
+
const local = await verifyExisting(root);
|
|
248
|
+
if (local && local.verification.freshness < 1) {
|
|
249
|
+
log(`your tree is ${Math.round(local.verification.freshness * 100)}% verified locally — run overstory build first (stale: ${local.staleFiles.join(', ')})`);
|
|
250
|
+
return 1;
|
|
251
|
+
}
|
|
252
|
+
const repoFlagIdx = process.argv.indexOf('--repo');
|
|
253
|
+
const repoFlag = repoFlagIdx >= 0 ? process.argv[repoFlagIdx + 1] : undefined;
|
|
254
|
+
const parsed = repoFlag?.includes('/')
|
|
255
|
+
? { owner: repoFlag.split('/')[0], repo: repoFlag.split('/')[1] }
|
|
256
|
+
: await detectGithubRepo(root);
|
|
257
|
+
if (!parsed) {
|
|
258
|
+
log('Could not detect a GitHub remote. Pass --repo owner/name.');
|
|
259
|
+
return 2;
|
|
260
|
+
}
|
|
261
|
+
const registry = process.env.OVERSTORY_REGISTRY ?? DEFAULT_REGISTRY;
|
|
262
|
+
log('publishing is a git push: your repo is the database, the registry stores nothing.');
|
|
263
|
+
const result = await checkPublished(registry, parsed.owner, parsed.repo);
|
|
264
|
+
if (flags.json) {
|
|
265
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
266
|
+
return result.published && result.freshness === 1 ? 0 : 1;
|
|
267
|
+
}
|
|
268
|
+
if (!result.published) {
|
|
269
|
+
log('your tree is not in the repo yet. Commit and push it:');
|
|
270
|
+
log(' git add -f .overstory/tree.json');
|
|
271
|
+
log(' git commit -m "docs: overstory tree" && git push');
|
|
272
|
+
log('then run overstory publish again to confirm the registry sees it.');
|
|
273
|
+
return 1;
|
|
274
|
+
}
|
|
275
|
+
const pct = Math.round((result.freshness ?? 0) * 100);
|
|
276
|
+
log(`published (${result.source}) — ${pct}% verified against GitHub @ ${result.sha?.slice(0, 7)}`);
|
|
277
|
+
if (pct < 100)
|
|
278
|
+
log('note: your pushed code is ahead of your pushed tree — rebuild, recommit tree.json, push.');
|
|
279
|
+
if (result.url)
|
|
280
|
+
log(`hosted: ${result.url}`);
|
|
281
|
+
if (result.badge)
|
|
282
|
+
log(`badge: ${result.badge}`);
|
|
283
|
+
return pct === 100 ? 0 : 1;
|
|
284
|
+
}
|
|
285
|
+
if (cmd === 'serve') {
|
|
286
|
+
const tree = await loadTree(treePath(root));
|
|
287
|
+
if (!tree) {
|
|
288
|
+
log('No tree found. Run: overstory build (then serve)');
|
|
289
|
+
return 2;
|
|
290
|
+
}
|
|
291
|
+
const { provider } = await resolveProvider(flags);
|
|
292
|
+
await serve(root, {
|
|
293
|
+
provider,
|
|
294
|
+
port: flags.port,
|
|
295
|
+
onReady: (address) => {
|
|
296
|
+
log(`overstory is up: ${address}`);
|
|
297
|
+
log('ask your codebase — every answer is notarized against the code');
|
|
298
|
+
try {
|
|
299
|
+
// best-effort browser open; the URL above is the contract
|
|
300
|
+
import('node:child_process').then(({ spawn }) => spawn(process.platform === 'win32' ? 'cmd' : 'open', process.platform === 'win32' ? ['/c', 'start', '', address] : [address], { detached: true, stdio: 'ignore' }).unref());
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
/* fine — user opens the printed URL */
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
return 0;
|
|
308
|
+
}
|
|
309
|
+
if (cmd === 'mcp') {
|
|
310
|
+
await runStdioServer(root);
|
|
311
|
+
return 0;
|
|
312
|
+
}
|
|
313
|
+
process.stdout.write(HELP);
|
|
314
|
+
return 2;
|
|
315
|
+
};
|
|
316
|
+
// process.exitCode (not process.exit) lets in-flight handles drain — hard exits race
|
|
317
|
+
// undici/libuv teardown on Windows. The mcp command stays alive via its stdio transport.
|
|
318
|
+
main().then((code) => {
|
|
319
|
+
process.exitCode = code;
|
|
320
|
+
}, (err) => {
|
|
321
|
+
process.stderr.write(`overstory: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
322
|
+
process.exitCode = 1;
|
|
323
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface Bm25Doc {
|
|
2
|
+
id: string;
|
|
3
|
+
text: string;
|
|
4
|
+
}
|
|
5
|
+
export interface Bm25Hit {
|
|
6
|
+
id: string;
|
|
7
|
+
score: number;
|
|
8
|
+
}
|
|
9
|
+
export declare const tokenize: (text: string) => string[];
|
|
10
|
+
/** Small classic BM25 — no dependencies, deterministic, fast enough for tens of
|
|
11
|
+
* thousands of claims. */
|
|
12
|
+
export declare class Bm25Index {
|
|
13
|
+
private docs;
|
|
14
|
+
private df;
|
|
15
|
+
private totalLength;
|
|
16
|
+
constructor(docs?: Bm25Doc[]);
|
|
17
|
+
add(doc: Bm25Doc): void;
|
|
18
|
+
search(query: string, k?: number): Bm25Hit[];
|
|
19
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const K1 = 1.4;
|
|
2
|
+
const B = 0.75;
|
|
3
|
+
export const tokenize = (text) => text
|
|
4
|
+
.toLowerCase()
|
|
5
|
+
.split(/[^a-z0-9_]+/u)
|
|
6
|
+
.filter((t) => t.length >= 2);
|
|
7
|
+
/** Small classic BM25 — no dependencies, deterministic, fast enough for tens of
|
|
8
|
+
* thousands of claims. */
|
|
9
|
+
export class Bm25Index {
|
|
10
|
+
docs = new Map();
|
|
11
|
+
df = new Map();
|
|
12
|
+
totalLength = 0;
|
|
13
|
+
constructor(docs = []) {
|
|
14
|
+
for (const d of docs)
|
|
15
|
+
this.add(d);
|
|
16
|
+
}
|
|
17
|
+
add(doc) {
|
|
18
|
+
const tokens = tokenize(doc.text);
|
|
19
|
+
this.docs.set(doc.id, { tokens, length: tokens.length });
|
|
20
|
+
this.totalLength += tokens.length;
|
|
21
|
+
for (const t of new Set(tokens))
|
|
22
|
+
this.df.set(t, (this.df.get(t) ?? 0) + 1);
|
|
23
|
+
}
|
|
24
|
+
search(query, k = 10) {
|
|
25
|
+
const qTokens = [...new Set(tokenize(query))];
|
|
26
|
+
if (qTokens.length === 0 || this.docs.size === 0)
|
|
27
|
+
return [];
|
|
28
|
+
const n = this.docs.size;
|
|
29
|
+
const avgLen = this.totalLength / n;
|
|
30
|
+
const hits = [];
|
|
31
|
+
for (const [id, { tokens, length }] of this.docs) {
|
|
32
|
+
let score = 0;
|
|
33
|
+
for (const q of qTokens) {
|
|
34
|
+
const df = this.df.get(q);
|
|
35
|
+
if (!df)
|
|
36
|
+
continue;
|
|
37
|
+
const tf = tokens.filter((t) => t === q).length;
|
|
38
|
+
if (tf === 0)
|
|
39
|
+
continue;
|
|
40
|
+
const idf = Math.log(1 + (n - df + 0.5) / (df + 0.5));
|
|
41
|
+
score += idf * ((tf * (K1 + 1)) / (tf + K1 * (1 - B + B * (length / (avgLen || 1)))));
|
|
42
|
+
}
|
|
43
|
+
if (score > 0)
|
|
44
|
+
hits.push({ id, score });
|
|
45
|
+
}
|
|
46
|
+
return hits.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, k);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { SpanRef } from './types.js';
|
|
2
|
+
/** Structural chunker: whole small files; markdown by top-level headings; code by
|
|
3
|
+
* column-0 declarations; fixed windows as the honest fallback. Chunks are contiguous,
|
|
4
|
+
* 1-based, inclusive, and cover the file exactly. */
|
|
5
|
+
export declare const chunkFile: (file: string, raw: string) => SpanRef[];
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { normalizeText, sha256, splitLines } from './hash.js';
|
|
2
|
+
const SINGLE_CHUNK_MAX_LINES = 400;
|
|
3
|
+
const TARGET_CHUNK_LINES = 200;
|
|
4
|
+
const HARD_SEGMENT_MAX_LINES = 300;
|
|
5
|
+
const MARKDOWN_RE = /\.(md|mdx|markdown)$/iu;
|
|
6
|
+
const HEADING_RE = /^#{1,2} /u;
|
|
7
|
+
/** Top-level declaration starts across the common languages (column 0 only). */
|
|
8
|
+
const DECL_RE = /^(export\s|function\s|class\s|def\s|async def\s|fn\s|pub\s|impl\s|interface\s|type\s|const\s|let\s|var\s|struct\s|enum\s|mod\s|module\s|public\s|private\s|@\w)/u;
|
|
9
|
+
const spanFrom = (file, lines, startLine, endLine) => {
|
|
10
|
+
const text = lines.slice(startLine - 1, endLine).join('\n');
|
|
11
|
+
return { file, startLine, endLine, text, contentHash: sha256(text) };
|
|
12
|
+
};
|
|
13
|
+
/** Group boundary line numbers (1-based, ascending, first must be 1) into contiguous spans,
|
|
14
|
+
* packing adjacent segments up to the target size without ever splitting a segment. */
|
|
15
|
+
const packSegments = (file, lines, boundaries) => {
|
|
16
|
+
const chunks = [];
|
|
17
|
+
const total = lines.length;
|
|
18
|
+
const segments = boundaries.map((start, i) => ({
|
|
19
|
+
start,
|
|
20
|
+
end: i + 1 < boundaries.length ? boundaries[i + 1] - 1 : total,
|
|
21
|
+
}));
|
|
22
|
+
let current = null;
|
|
23
|
+
const flush = () => {
|
|
24
|
+
if (current)
|
|
25
|
+
chunks.push(spanFrom(file, lines, current.start, current.end));
|
|
26
|
+
current = null;
|
|
27
|
+
};
|
|
28
|
+
for (const seg of segments) {
|
|
29
|
+
const segLen = seg.end - seg.start + 1;
|
|
30
|
+
if (segLen > HARD_SEGMENT_MAX_LINES) {
|
|
31
|
+
// Oversized single segment: flush what we have, then window it so no chunk
|
|
32
|
+
// outgrows an LLM context.
|
|
33
|
+
flush();
|
|
34
|
+
for (let s = seg.start; s <= seg.end; s += HARD_SEGMENT_MAX_LINES) {
|
|
35
|
+
chunks.push(spanFrom(file, lines, s, Math.min(s + HARD_SEGMENT_MAX_LINES - 1, seg.end)));
|
|
36
|
+
}
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (!current) {
|
|
40
|
+
current = { ...seg };
|
|
41
|
+
}
|
|
42
|
+
else if (current.end - current.start + 1 + segLen <= TARGET_CHUNK_LINES) {
|
|
43
|
+
current.end = seg.end;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
flush();
|
|
47
|
+
current = { ...seg };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
flush();
|
|
51
|
+
return chunks;
|
|
52
|
+
};
|
|
53
|
+
/** Structural chunker: whole small files; markdown by top-level headings; code by
|
|
54
|
+
* column-0 declarations; fixed windows as the honest fallback. Chunks are contiguous,
|
|
55
|
+
* 1-based, inclusive, and cover the file exactly. */
|
|
56
|
+
export const chunkFile = (file, raw) => {
|
|
57
|
+
const norm = normalizeText(raw);
|
|
58
|
+
const lines = splitLines(norm);
|
|
59
|
+
const total = lines.length;
|
|
60
|
+
if (total === 0)
|
|
61
|
+
return [];
|
|
62
|
+
if (MARKDOWN_RE.test(file)) {
|
|
63
|
+
const headings = lines.reduce((acc, line, i) => {
|
|
64
|
+
if (HEADING_RE.test(line))
|
|
65
|
+
acc.push(i + 1);
|
|
66
|
+
return acc;
|
|
67
|
+
}, []);
|
|
68
|
+
if (headings.length > 1 || (headings.length === 1 && headings[0] !== 1)) {
|
|
69
|
+
const boundaries = headings[0] === 1 ? headings : [1, ...headings];
|
|
70
|
+
return packSegments(file, lines, boundaries).length <= 1
|
|
71
|
+
? boundaries.map((start, i) => spanFrom(file, lines, start, i + 1 < boundaries.length ? boundaries[i + 1] - 1 : total))
|
|
72
|
+
: boundaries.map((start, i) => spanFrom(file, lines, start, i + 1 < boundaries.length ? boundaries[i + 1] - 1 : total));
|
|
73
|
+
}
|
|
74
|
+
// fall through: unstructured markdown treated like generic text
|
|
75
|
+
}
|
|
76
|
+
if (total <= SINGLE_CHUNK_MAX_LINES)
|
|
77
|
+
return [spanFrom(file, lines, 1, total)];
|
|
78
|
+
const decls = lines.reduce((acc, line, i) => {
|
|
79
|
+
if (DECL_RE.test(line))
|
|
80
|
+
acc.push(i + 1);
|
|
81
|
+
return acc;
|
|
82
|
+
}, []);
|
|
83
|
+
if (decls.length > 1) {
|
|
84
|
+
const boundaries = decls[0] === 1 ? decls : [1, ...decls];
|
|
85
|
+
return packSegments(file, lines, boundaries);
|
|
86
|
+
}
|
|
87
|
+
const windows = [];
|
|
88
|
+
for (let s = 1; s <= total; s += TARGET_CHUNK_LINES)
|
|
89
|
+
windows.push(s);
|
|
90
|
+
return packSegments(file, lines, windows);
|
|
91
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { CorpusSnapshot } from './types.js';
|
|
2
|
+
export interface LoadOptions {
|
|
3
|
+
/** Minimal glob filters over posix-relative paths (`*`, `**`, `?`). */
|
|
4
|
+
include?: string[];
|
|
5
|
+
maxFileBytes?: number;
|
|
6
|
+
maxFiles?: number;
|
|
7
|
+
/** Use `git ls-files` for gitignore-aware listing when the root is a repo. Default true;
|
|
8
|
+
* always falls back to the walker if git is unavailable or errors. */
|
|
9
|
+
useGit?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface SkippedFile {
|
|
12
|
+
file: string;
|
|
13
|
+
reason: 'binary' | 'too-large' | 'unreadable' | 'file-cap';
|
|
14
|
+
}
|
|
15
|
+
export interface LoadedCorpus extends CorpusSnapshot {
|
|
16
|
+
skipped: SkippedFile[];
|
|
17
|
+
}
|
|
18
|
+
/** Load a corpus snapshot: gitignore-aware when possible, default excludes always,
|
|
19
|
+
* binaries and oversized files skipped with reasons, content normalized, order
|
|
20
|
+
* deterministic. */
|
|
21
|
+
export declare const loadCorpus: (root: string, opts?: LoadOptions) => Promise<LoadedCorpus>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { normalizeText, sha256, splitLines } from './hash.js';
|
|
6
|
+
const execFileP = promisify(execFile);
|
|
7
|
+
const DEFAULT_EXCLUDED_DIRS = new Set([
|
|
8
|
+
'node_modules', '.git', 'dist', 'build', 'out', 'target', 'vendor', 'coverage',
|
|
9
|
+
'.next', '.nuxt', '.overstory', '.cache', '__pycache__', '.venv', 'venv',
|
|
10
|
+
'.idea', '.vscode', '.turbo', '.pytest_cache',
|
|
11
|
+
]);
|
|
12
|
+
/** Machine-generated files that add noise, not knowledge. */
|
|
13
|
+
const EXCLUDED_FILENAMES = new Set([
|
|
14
|
+
'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lockb', 'Cargo.lock',
|
|
15
|
+
'poetry.lock', 'uv.lock', 'Gemfile.lock', 'composer.lock', 'go.sum',
|
|
16
|
+
]);
|
|
17
|
+
const BINARY_EXTENSIONS = /\.(png|jpe?g|gif|webp|ico|icns|pdf|zip|gz|tgz|bz2|7z|rar|exe|dll|so|dylib|wasm|pyd|class|jar|woff2?|ttf|otf|eot|mp[34]|mov|avi|sqlite|db|bin|pack|idx|lock)$/iu;
|
|
18
|
+
const globToRegex = (glob) => {
|
|
19
|
+
const part = (p) => p
|
|
20
|
+
.replace(/[.+^${}()|[\]\\]/gu, '\\$&')
|
|
21
|
+
.replace(/\*/gu, '[^/]*')
|
|
22
|
+
.replace(/\?/gu, '.');
|
|
23
|
+
const source = glob.split('**').map(part).join('.*');
|
|
24
|
+
return new RegExp(`^${source}$`, 'u');
|
|
25
|
+
};
|
|
26
|
+
const listViaGit = async (root) => {
|
|
27
|
+
try {
|
|
28
|
+
const { stdout } = await execFileP('git', ['ls-files', '-co', '--exclude-standard'], {
|
|
29
|
+
cwd: root,
|
|
30
|
+
timeout: 10_000,
|
|
31
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
32
|
+
});
|
|
33
|
+
const files = stdout.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
34
|
+
return files.length > 0 ? files : null;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const walk = async (root, rel = '') => {
|
|
41
|
+
const abs = rel === '' ? root : join(root, rel);
|
|
42
|
+
const entries = await readdir(abs, { withFileTypes: true });
|
|
43
|
+
const files = [];
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
if (entry.isSymbolicLink())
|
|
46
|
+
continue;
|
|
47
|
+
const childRel = rel === '' ? entry.name : `${rel}/${entry.name}`;
|
|
48
|
+
if (entry.isDirectory()) {
|
|
49
|
+
if (DEFAULT_EXCLUDED_DIRS.has(entry.name))
|
|
50
|
+
continue;
|
|
51
|
+
files.push(...(await walk(root, childRel)));
|
|
52
|
+
}
|
|
53
|
+
else if (entry.isFile()) {
|
|
54
|
+
files.push(childRel);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return files;
|
|
58
|
+
};
|
|
59
|
+
const looksBinary = (buf) => {
|
|
60
|
+
const probe = buf.subarray(0, 8000);
|
|
61
|
+
return probe.includes(0);
|
|
62
|
+
};
|
|
63
|
+
/** Load a corpus snapshot: gitignore-aware when possible, default excludes always,
|
|
64
|
+
* binaries and oversized files skipped with reasons, content normalized, order
|
|
65
|
+
* deterministic. */
|
|
66
|
+
export const loadCorpus = async (root, opts = {}) => {
|
|
67
|
+
// Number.isFinite (not ??): NaN from a mis-parsed flag must fall back to the safe
|
|
68
|
+
// default, never silently disable a cap.
|
|
69
|
+
const maxFileBytes = Number.isFinite(opts.maxFileBytes) ? opts.maxFileBytes : 1_000_000;
|
|
70
|
+
const maxFiles = Number.isFinite(opts.maxFiles) ? opts.maxFiles : 5_000;
|
|
71
|
+
const includeRes = opts.include?.map(globToRegex);
|
|
72
|
+
let candidates = (opts.useGit ?? true) ? await listViaGit(root) : null;
|
|
73
|
+
if (!candidates)
|
|
74
|
+
candidates = await walk(root);
|
|
75
|
+
candidates = candidates
|
|
76
|
+
.map((f) => f.replace(/\\/gu, '/'))
|
|
77
|
+
.filter((f) => !f.split('/').some((part) => DEFAULT_EXCLUDED_DIRS.has(part)))
|
|
78
|
+
.filter((f) => !EXCLUDED_FILENAMES.has(f.split('/').pop() ?? ''))
|
|
79
|
+
.sort();
|
|
80
|
+
const files = new Map();
|
|
81
|
+
const skipped = [];
|
|
82
|
+
for (const rel of candidates) {
|
|
83
|
+
if (includeRes && !includeRes.some((re) => re.test(rel)))
|
|
84
|
+
continue;
|
|
85
|
+
if (files.size >= maxFiles) {
|
|
86
|
+
skipped.push({ file: rel, reason: 'file-cap' });
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (BINARY_EXTENSIONS.test(rel)) {
|
|
90
|
+
skipped.push({ file: rel, reason: 'binary' });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const info = await stat(join(root, rel));
|
|
95
|
+
if (info.size > maxFileBytes) {
|
|
96
|
+
skipped.push({ file: rel, reason: 'too-large' });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const buf = await readFile(join(root, rel));
|
|
100
|
+
if (looksBinary(buf)) {
|
|
101
|
+
skipped.push({ file: rel, reason: 'binary' });
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const norm = normalizeText(buf.toString('utf8'));
|
|
105
|
+
files.set(rel, { hash: sha256(norm), lines: splitLines(norm) });
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
skipped.push({ file: rel, reason: 'unreadable' });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { root, files, skipped };
|
|
112
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Claim, ClaimVerification, CorpusSnapshot, SpanRef, SpanVerification, Tree, TreeVerification } from './types.js';
|
|
2
|
+
/** Builder-side helper: cite lines start..end (1-based, inclusive) of a file as it
|
|
3
|
+
* currently exists in the corpus. Throws if the file is absent — you cannot honestly
|
|
4
|
+
* cite what you cannot see. */
|
|
5
|
+
export declare const makeSpan: (file: string, startLine: number, endLine: number, corpus: CorpusSnapshot) => SpanRef;
|
|
6
|
+
/** Mechanical span verification against the live corpus. Assumes an internally
|
|
7
|
+
* consistent receipt (text/hash agreement is enforced upstream in verifyClaim).
|
|
8
|
+
* Self-heals when the cited text merely moved (edits above the span). */
|
|
9
|
+
export declare const verifySpan: (span: SpanRef, corpus: CorpusSnapshot) => SpanVerification;
|
|
10
|
+
/** Fail-closed claim verification. A claim is VERIFIED only when every citation
|
|
11
|
+
* verifies. Any internally inconsistent receipt (text/hash mismatch — a forgery or
|
|
12
|
+
* corruption) voids the claim's grounding entirely: UNGROUNDED. Interior claims
|
|
13
|
+
* (citing child claims) verify transitively; staleness propagates upward.
|
|
14
|
+
*
|
|
15
|
+
* `path` holds only the claims on the CURRENT recursion path (backtracked on return):
|
|
16
|
+
* true cycles fail closed, while duplicate citations of the same evidence — a common
|
|
17
|
+
* LLM habit — and diamond-shaped grounding verify correctly. Duplicate refs within one
|
|
18
|
+
* claim are verified once (evidence is idempotent). */
|
|
19
|
+
export declare const verifyClaim: (claim: Claim, tree: Tree, corpus: CorpusSnapshot, path?: Set<string>) => ClaimVerification;
|
|
20
|
+
/** Verify every claim in the tree. Freshness is the VERIFIED fraction (1.0 for an
|
|
21
|
+
* empty tree — nothing claimed, nothing wrong). */
|
|
22
|
+
export declare const verifyTree: (tree: Tree, corpus: CorpusSnapshot) => TreeVerification;
|
|
23
|
+
/** Write verdicts + healed span positions back into a (copied) tree. Healing updates only
|
|
24
|
+
* line numbers — text and hash are untouched, so the receipt itself never changes. */
|
|
25
|
+
export declare const applyVerification: (tree: Tree, verification: TreeVerification) => Tree;
|