@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,223 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
import { loadCorpus } from '../core/corpus.js';
|
|
4
|
+
import { applyVerification, verifyTree } from '../core/gate.js';
|
|
5
|
+
import { loadTree, partialPath, saveTree, treePath } from '../core/store.js';
|
|
6
|
+
import { aggregateClaims } from './aggregate.js';
|
|
7
|
+
import { mapPool } from './pool.js';
|
|
8
|
+
import { refineClaims } from './reflexion.js';
|
|
9
|
+
import { summarizeFile } from './summarize.js';
|
|
10
|
+
const lockPath = (root) => join(root, '.overstory', 'build.lock');
|
|
11
|
+
const pidAlive = (pid) => {
|
|
12
|
+
try {
|
|
13
|
+
process.kill(pid, 0);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
catch (err) {
|
|
17
|
+
return err.code === 'EPERM';
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
/** Concurrent builds on one root are last-writer-wins corruption — refuse, loudly.
|
|
21
|
+
* A lock owned by a dead process is stale and reclaimed. */
|
|
22
|
+
const acquireLock = async (root) => {
|
|
23
|
+
const path = lockPath(root);
|
|
24
|
+
await mkdir(dirname(path), { recursive: true });
|
|
25
|
+
try {
|
|
26
|
+
const existing = JSON.parse(await readFile(path, 'utf8'));
|
|
27
|
+
if (existing.pid && pidAlive(existing.pid)) {
|
|
28
|
+
throw new Error(`another overstory build (pid ${existing.pid}) is already running for this root; wait for it, or delete ${path} if that process is gone`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
if (err instanceof Error && err.message.startsWith('another overstory build'))
|
|
33
|
+
throw err;
|
|
34
|
+
// missing or unreadable lock file: free to claim
|
|
35
|
+
}
|
|
36
|
+
await writeFile(path, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }), 'utf8');
|
|
37
|
+
};
|
|
38
|
+
export const dirOf = (file) => {
|
|
39
|
+
const d = dirname(file).replace(/\\/gu, '/');
|
|
40
|
+
return d === '.' ? '' : d;
|
|
41
|
+
};
|
|
42
|
+
/** All ancestor dir paths for a set of files, deepest first (so children exist before
|
|
43
|
+
* their parent aggregates). */
|
|
44
|
+
export const dirPaths = (files) => {
|
|
45
|
+
const dirs = new Set();
|
|
46
|
+
for (const f of files) {
|
|
47
|
+
let d = dirOf(f);
|
|
48
|
+
while (d !== '') {
|
|
49
|
+
dirs.add(d);
|
|
50
|
+
d = dirOf(d);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return [...dirs].sort((a, b) => b.split('/').length - a.split('/').length || a.localeCompare(b));
|
|
54
|
+
};
|
|
55
|
+
/** Build (or refresh — same operation) the knowledge tree. Reuses any previous leaf whose
|
|
56
|
+
* source hash still matches, upgrades extractive leaves when an LLM provider is available,
|
|
57
|
+
* checkpoints progress after every leaf, and finishes with a full gate sweep. A killed
|
|
58
|
+
* build resumes from its partial checkpoint on the next run. */
|
|
59
|
+
export const buildTree = async (root, opts = {}) => {
|
|
60
|
+
const provider = opts.provider ?? null;
|
|
61
|
+
const critic = opts.critic ?? provider;
|
|
62
|
+
const reflexionRounds = Number.isFinite(opts.reflexionRounds) ? opts.reflexionRounds : 1;
|
|
63
|
+
const emit = opts.onProgress ?? (() => { });
|
|
64
|
+
await acquireLock(root);
|
|
65
|
+
try {
|
|
66
|
+
return await buildTreeLocked(root, opts, provider, critic, reflexionRounds, emit);
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
await rm(lockPath(root), { force: true });
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const buildTreeLocked = async (root, opts, provider, critic, reflexionRounds, emit) => {
|
|
73
|
+
emit({ phase: 'scan' });
|
|
74
|
+
const corpusOptions = {
|
|
75
|
+
...(opts.include ? { include: opts.include } : {}),
|
|
76
|
+
...(Number.isFinite(opts.maxFiles) ? { maxFiles: opts.maxFiles } : {}),
|
|
77
|
+
};
|
|
78
|
+
const corpus = await loadCorpus(root, corpusOptions);
|
|
79
|
+
const files = [...corpus.files.keys()];
|
|
80
|
+
const previous = (await loadTree(partialPath(root))) ?? (await loadTree(treePath(root)));
|
|
81
|
+
const nodes = {};
|
|
82
|
+
let reusedLeaves = 0;
|
|
83
|
+
let rebuiltLeaves = 0;
|
|
84
|
+
let done = 0;
|
|
85
|
+
let checkpointFailures = 0;
|
|
86
|
+
let checkpointChain = Promise.resolve();
|
|
87
|
+
const checkpoint = () => {
|
|
88
|
+
const snapshot = {
|
|
89
|
+
version: 1,
|
|
90
|
+
name: opts.name ?? basename(root),
|
|
91
|
+
root: 'root',
|
|
92
|
+
nodes: { ...nodes },
|
|
93
|
+
corpusFiles: Object.fromEntries(files.map((f) => [f, { hash: corpus.files.get(f).hash, lines: corpus.files.get(f).lines.length }])),
|
|
94
|
+
...(Object.keys(corpusOptions).length ? { corpusOptions } : {}),
|
|
95
|
+
builtAt: new Date().toISOString(),
|
|
96
|
+
generator: opts.generator ?? '@northtek/overstory',
|
|
97
|
+
};
|
|
98
|
+
checkpointChain = checkpointChain
|
|
99
|
+
.then(() => saveTree(partialPath(root), snapshot))
|
|
100
|
+
.catch((err) => {
|
|
101
|
+
checkpointFailures += 1;
|
|
102
|
+
emit({
|
|
103
|
+
phase: 'warn',
|
|
104
|
+
message: `checkpoint write failed (#${checkpointFailures}): ${err instanceof Error ? err.message : String(err)} — a killed build will NOT resume from this point`,
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
await mapPool(files, provider?.concurrency ?? 8, async (file) => {
|
|
109
|
+
const id = `leaf:${file}`;
|
|
110
|
+
const hash = corpus.files.get(file).hash;
|
|
111
|
+
const prior = previous?.nodes[id];
|
|
112
|
+
const reusable = prior && prior.sourceHash === hash && (prior.builtWith === 'llm' || !provider);
|
|
113
|
+
if (reusable) {
|
|
114
|
+
nodes[id] = prior;
|
|
115
|
+
reusedLeaves += 1;
|
|
116
|
+
emit({ phase: 'leaves', done: ++done, total: files.length, file, reused: true });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const { claims: raw } = await summarizeFile(provider, file, corpus, id);
|
|
120
|
+
let claims = raw;
|
|
121
|
+
if (provider && critic && reflexionRounds > 0 && claims.length > 0) {
|
|
122
|
+
const refined = await refineClaims(critic, file, claims, corpus, { maxRounds: reflexionRounds });
|
|
123
|
+
claims = refined.claims;
|
|
124
|
+
}
|
|
125
|
+
nodes[id] = {
|
|
126
|
+
id,
|
|
127
|
+
kind: 'leaf',
|
|
128
|
+
path: file,
|
|
129
|
+
title: basename(file),
|
|
130
|
+
summary: claims[0]?.text ?? '',
|
|
131
|
+
claims,
|
|
132
|
+
childIds: [],
|
|
133
|
+
sourceHash: hash,
|
|
134
|
+
builtWith: provider ? 'llm' : 'extractive',
|
|
135
|
+
provider: provider?.name,
|
|
136
|
+
builtAt: new Date().toISOString(),
|
|
137
|
+
};
|
|
138
|
+
rebuiltLeaves += 1;
|
|
139
|
+
emit({ phase: 'leaves', done: ++done, total: files.length, file, reused: false });
|
|
140
|
+
checkpoint();
|
|
141
|
+
});
|
|
142
|
+
await checkpointChain;
|
|
143
|
+
// Interior nodes, deepest first; the root last. Dirs are cheap relative to leaves and are
|
|
144
|
+
// always rebuilt so roll-ups never describe stale children.
|
|
145
|
+
const dirs = dirPaths(files);
|
|
146
|
+
const childrenOf = (dir) => {
|
|
147
|
+
const leaves = files.filter((f) => dirOf(f) === dir).map((f) => nodes[`leaf:${f}`]);
|
|
148
|
+
const subdirs = dirs.filter((d) => dirOf(d) === dir && d !== dir).map((d) => nodes[`dir:${d}`]);
|
|
149
|
+
return [...subdirs, ...leaves].filter(Boolean);
|
|
150
|
+
};
|
|
151
|
+
let aggDone = 0;
|
|
152
|
+
for (const dir of dirs) {
|
|
153
|
+
const children = childrenOf(dir);
|
|
154
|
+
const id = `dir:${dir}`;
|
|
155
|
+
const claims = await aggregateClaims(provider, dir, children, id);
|
|
156
|
+
nodes[id] = {
|
|
157
|
+
id,
|
|
158
|
+
kind: 'dir',
|
|
159
|
+
path: dir,
|
|
160
|
+
title: dir.split('/').pop() ?? dir,
|
|
161
|
+
summary: claims[0]?.text ?? '',
|
|
162
|
+
claims,
|
|
163
|
+
childIds: children.map((c) => c.id),
|
|
164
|
+
builtWith: provider ? 'llm' : 'extractive',
|
|
165
|
+
provider: provider?.name,
|
|
166
|
+
builtAt: new Date().toISOString(),
|
|
167
|
+
};
|
|
168
|
+
emit({ phase: 'aggregate', done: ++aggDone, total: dirs.length + 1 });
|
|
169
|
+
}
|
|
170
|
+
const rootChildren = childrenOf('');
|
|
171
|
+
const name = opts.name ?? basename(root);
|
|
172
|
+
const rootClaims = await aggregateClaims(provider, name, rootChildren, 'root');
|
|
173
|
+
nodes['root'] = {
|
|
174
|
+
id: 'root',
|
|
175
|
+
kind: 'root',
|
|
176
|
+
path: '',
|
|
177
|
+
title: name,
|
|
178
|
+
summary: rootClaims[0]?.text ?? '',
|
|
179
|
+
claims: rootClaims,
|
|
180
|
+
childIds: rootChildren.map((c) => c.id),
|
|
181
|
+
builtWith: provider ? 'llm' : 'extractive',
|
|
182
|
+
provider: provider?.name,
|
|
183
|
+
builtAt: new Date().toISOString(),
|
|
184
|
+
};
|
|
185
|
+
emit({ phase: 'aggregate', done: dirs.length + 1, total: dirs.length + 1 });
|
|
186
|
+
const tree = {
|
|
187
|
+
version: 1,
|
|
188
|
+
name,
|
|
189
|
+
root: 'root',
|
|
190
|
+
nodes,
|
|
191
|
+
corpusFiles: Object.fromEntries(files.map((f) => [f, { hash: corpus.files.get(f).hash, lines: corpus.files.get(f).lines.length }])),
|
|
192
|
+
...(Object.keys(corpusOptions).length ? { corpusOptions } : {}),
|
|
193
|
+
builtAt: new Date().toISOString(),
|
|
194
|
+
generator: opts.generator ?? '@northtek/overstory',
|
|
195
|
+
};
|
|
196
|
+
emit({ phase: 'verify' });
|
|
197
|
+
const verification = verifyTree(tree, corpus);
|
|
198
|
+
const finalTree = applyVerification(tree, verification);
|
|
199
|
+
emit({ phase: 'save' });
|
|
200
|
+
await saveTree(treePath(root), finalTree);
|
|
201
|
+
await rm(partialPath(root), { force: true });
|
|
202
|
+
return { tree: finalTree, verification, corpus, reusedLeaves, rebuiltLeaves, checkpointFailures };
|
|
203
|
+
};
|
|
204
|
+
/** Read-only verification of an existing tree against the live corpus. Applies verdicts and
|
|
205
|
+
* healed positions in-memory; never writes. Null when no tree exists. The corpus is loaded
|
|
206
|
+
* with the OPTIONS THE TREE WAS BUILT WITH (caller may override) — verifying against a
|
|
207
|
+
* differently-scoped corpus yields wrong verdicts. */
|
|
208
|
+
export const verifyExisting = async (root, opts = {}) => {
|
|
209
|
+
const stored = await loadTree(treePath(root));
|
|
210
|
+
if (!stored)
|
|
211
|
+
return null;
|
|
212
|
+
const corpus = await loadCorpus(root, {
|
|
213
|
+
include: opts.include ?? stored.corpusOptions?.include,
|
|
214
|
+
maxFiles: opts.maxFiles ?? stored.corpusOptions?.maxFiles,
|
|
215
|
+
});
|
|
216
|
+
const verification = verifyTree(stored, corpus);
|
|
217
|
+
const tree = applyVerification(stored, verification);
|
|
218
|
+
const staleFiles = [...new Set(Object.values(tree.nodes)
|
|
219
|
+
.filter((n) => n.kind === 'leaf')
|
|
220
|
+
.filter((n) => n.claims.some((c) => c.verdict === 'STALE' || c.verdict === 'OUT_OF_CORPUS'))
|
|
221
|
+
.map((n) => n.path))];
|
|
222
|
+
return { tree, verification, staleFiles };
|
|
223
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { LoadedCorpus } from '../core/corpus.js';
|
|
2
|
+
import type { Tree, TreeVerification } from '../core/types.js';
|
|
3
|
+
import type { ChatProvider } from '../llm/provider.js';
|
|
4
|
+
export interface InMemoryBuildOptions {
|
|
5
|
+
name: string;
|
|
6
|
+
provider?: ChatProvider | null;
|
|
7
|
+
generator?: string;
|
|
8
|
+
}
|
|
9
|
+
/** Build a complete verified tree from an in-memory corpus — no disk, no locks, no
|
|
10
|
+
* checkpoints. This is the registry's engine (serverless, deterministic when provider is
|
|
11
|
+
* null); the disk builder in builder.ts keeps its resume/checkpoint machinery for long
|
|
12
|
+
* local LLM builds. */
|
|
13
|
+
export declare const buildTreeFromCorpus: (corpus: LoadedCorpus, opts: InMemoryBuildOptions) => Promise<{
|
|
14
|
+
tree: Tree;
|
|
15
|
+
verification: TreeVerification;
|
|
16
|
+
}>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import { applyVerification, verifyTree } from '../core/gate.js';
|
|
3
|
+
import { aggregateClaims } from './aggregate.js';
|
|
4
|
+
import { dirOf, dirPaths } from './builder.js';
|
|
5
|
+
import { summarizeFile } from './summarize.js';
|
|
6
|
+
/** Build a complete verified tree from an in-memory corpus — no disk, no locks, no
|
|
7
|
+
* checkpoints. This is the registry's engine (serverless, deterministic when provider is
|
|
8
|
+
* null); the disk builder in builder.ts keeps its resume/checkpoint machinery for long
|
|
9
|
+
* local LLM builds. */
|
|
10
|
+
export const buildTreeFromCorpus = async (corpus, opts) => {
|
|
11
|
+
const provider = opts.provider ?? null;
|
|
12
|
+
const files = [...corpus.files.keys()];
|
|
13
|
+
const nodes = {};
|
|
14
|
+
for (const file of files) {
|
|
15
|
+
const id = `leaf:${file}`;
|
|
16
|
+
const { claims } = await summarizeFile(provider, file, corpus, id);
|
|
17
|
+
nodes[id] = {
|
|
18
|
+
id,
|
|
19
|
+
kind: 'leaf',
|
|
20
|
+
path: file,
|
|
21
|
+
title: basename(file),
|
|
22
|
+
summary: claims[0]?.text ?? '',
|
|
23
|
+
claims,
|
|
24
|
+
childIds: [],
|
|
25
|
+
sourceHash: corpus.files.get(file).hash,
|
|
26
|
+
builtWith: provider ? 'llm' : 'extractive',
|
|
27
|
+
provider: provider?.name,
|
|
28
|
+
builtAt: new Date().toISOString(),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const dirs = dirPaths(files);
|
|
32
|
+
const childrenOf = (dir) => {
|
|
33
|
+
const leaves = files.filter((f) => dirOf(f) === dir).map((f) => nodes[`leaf:${f}`]);
|
|
34
|
+
const subdirs = dirs.filter((d) => dirOf(d) === dir && d !== dir).map((d) => nodes[`dir:${d}`]);
|
|
35
|
+
return [...subdirs, ...leaves].filter(Boolean);
|
|
36
|
+
};
|
|
37
|
+
for (const dir of dirs) {
|
|
38
|
+
const children = childrenOf(dir);
|
|
39
|
+
const id = `dir:${dir}`;
|
|
40
|
+
const claims = await aggregateClaims(provider, dir, children, id);
|
|
41
|
+
nodes[id] = {
|
|
42
|
+
id,
|
|
43
|
+
kind: 'dir',
|
|
44
|
+
path: dir,
|
|
45
|
+
title: dir.split('/').pop() ?? dir,
|
|
46
|
+
summary: claims[0]?.text ?? '',
|
|
47
|
+
claims,
|
|
48
|
+
childIds: children.map((c) => c.id),
|
|
49
|
+
builtWith: provider ? 'llm' : 'extractive',
|
|
50
|
+
provider: provider?.name,
|
|
51
|
+
builtAt: new Date().toISOString(),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const rootChildren = childrenOf('');
|
|
55
|
+
const rootClaims = await aggregateClaims(provider, opts.name, rootChildren, 'root');
|
|
56
|
+
nodes['root'] = {
|
|
57
|
+
id: 'root',
|
|
58
|
+
kind: 'root',
|
|
59
|
+
path: '',
|
|
60
|
+
title: opts.name,
|
|
61
|
+
summary: rootClaims[0]?.text ?? '',
|
|
62
|
+
claims: rootClaims,
|
|
63
|
+
childIds: rootChildren.map((c) => c.id),
|
|
64
|
+
builtWith: provider ? 'llm' : 'extractive',
|
|
65
|
+
provider: provider?.name,
|
|
66
|
+
builtAt: new Date().toISOString(),
|
|
67
|
+
};
|
|
68
|
+
const tree = {
|
|
69
|
+
version: 1,
|
|
70
|
+
name: opts.name,
|
|
71
|
+
root: 'root',
|
|
72
|
+
nodes,
|
|
73
|
+
corpusFiles: Object.fromEntries(files.map((f) => [f, { hash: corpus.files.get(f).hash, lines: corpus.files.get(f).lines.length }])),
|
|
74
|
+
builtAt: new Date().toISOString(),
|
|
75
|
+
generator: opts.generator ?? '@northtek/overstory',
|
|
76
|
+
};
|
|
77
|
+
const verification = verifyTree(tree, corpus);
|
|
78
|
+
return { tree: applyVerification(tree, verification), verification };
|
|
79
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Ordered concurrency-limited map. Item errors reject the whole pool — callers make
|
|
2
|
+
* per-item work non-throwing (fallbacks) before pooling. */
|
|
3
|
+
export const mapPool = async (items, limit, fn) => {
|
|
4
|
+
const results = new Array(items.length);
|
|
5
|
+
let next = 0;
|
|
6
|
+
const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
|
|
7
|
+
for (;;) {
|
|
8
|
+
const i = next++;
|
|
9
|
+
if (i >= items.length)
|
|
10
|
+
return;
|
|
11
|
+
results[i] = await fn(items[i], i);
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
await Promise.all(workers);
|
|
15
|
+
return results;
|
|
16
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Claim, CorpusSnapshot } from '../core/types.js';
|
|
2
|
+
import type { ChatProvider } from '../llm/provider.js';
|
|
3
|
+
export interface ReflexionOptions {
|
|
4
|
+
maxRounds?: number;
|
|
5
|
+
}
|
|
6
|
+
export interface ReflexionResult {
|
|
7
|
+
claims: Claim[];
|
|
8
|
+
rounds: number;
|
|
9
|
+
stop: 'converged' | 'max-rounds' | 'no-progress' | 'critique-failed';
|
|
10
|
+
}
|
|
11
|
+
/** Reflexion refinement (Shinn 2023 shape, RAIN stop discipline): critique -> apply
|
|
12
|
+
* revisions -> re-critique revised claims. UNSUPPORTED claims without a revision stay
|
|
13
|
+
* visible and flagged (abstention over confident prose, never silent deletion). A failed
|
|
14
|
+
* critique leaves faithfulness 'unchecked' — honest, not upgraded. */
|
|
15
|
+
export declare const refineClaims: (provider: ChatProvider, file: string, claims: Claim[], corpus: CorpusSnapshot, opts?: ReflexionOptions) => Promise<ReflexionResult>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { makeSpan } from '../core/gate.js';
|
|
3
|
+
import { numberedLines } from './summarize.js';
|
|
4
|
+
// Local models emit `null` and empty strings for omitted fields — nullish(), never
|
|
5
|
+
// optional()-only, or the whole critique silently dies at the parse (live-verified failure
|
|
6
|
+
// mode: qwen2.5:14b returns "revisedStartLine": null on SUPPORTED claims).
|
|
7
|
+
const critiqueSchema = z.object({
|
|
8
|
+
claims: z.array(z.object({
|
|
9
|
+
id: z.string(),
|
|
10
|
+
verdict: z.enum(['SUPPORTED', 'UNSUPPORTED']),
|
|
11
|
+
revisedText: z.string().nullish(),
|
|
12
|
+
revisedStartLine: z.number().int().min(1).nullish(),
|
|
13
|
+
revisedEndLine: z.number().int().min(1).nullish(),
|
|
14
|
+
})),
|
|
15
|
+
missing: z
|
|
16
|
+
.array(z.object({ text: z.string().nullish(), startLine: z.number().int().min(1).nullish(), endLine: z.number().int().min(1).nullish() }))
|
|
17
|
+
.nullish(),
|
|
18
|
+
});
|
|
19
|
+
const CRITIQUE_SYSTEM = `You are an adversarial fact-checker for a provenance-gated knowledge tree. Your job is to REFUTE claims: a claim is SUPPORTED only if a skeptical reader would agree the cited lines directly state or straightforwardly imply it. Judge only against the cited lines shown — not general knowledge. Flag overreach, speculation, and misattribution. Do not reward fluent wording.`;
|
|
20
|
+
const critiquePrompt = (file, sourceLines, claims) => {
|
|
21
|
+
const claimBlock = claims
|
|
22
|
+
.map((c) => {
|
|
23
|
+
const spans = c.citations
|
|
24
|
+
.filter((x) => x.kind === 'span')
|
|
25
|
+
.map((x) => `lines ${x.span.startLine}-${x.span.endLine}`)
|
|
26
|
+
.join(', ');
|
|
27
|
+
return `- id=${c.id} [cites ${spans || 'NOTHING'}]: ${c.text}`;
|
|
28
|
+
})
|
|
29
|
+
.join('\n');
|
|
30
|
+
return `Fact-check each claim against the source. For UNSUPPORTED claims, provide a corrected revisedText + revised line range when a defensible correction exists; otherwise leave it flagged. List up to 3 important facts the claims MISS (with their line ranges).
|
|
31
|
+
|
|
32
|
+
Return ONLY JSON: {"claims":[{"id":"...","verdict":"SUPPORTED"|"UNSUPPORTED","revisedText":"...?","revisedStartLine":N?,"revisedEndLine":N?}],"missing":[{"text":"...","startLine":N,"endLine":N}]}
|
|
33
|
+
|
|
34
|
+
CLAIMS:
|
|
35
|
+
${claimBlock}
|
|
36
|
+
|
|
37
|
+
SOURCE ${file}:
|
|
38
|
+
${numberedLines(sourceLines, 1)}`;
|
|
39
|
+
};
|
|
40
|
+
/** Reflexion refinement (Shinn 2023 shape, RAIN stop discipline): critique -> apply
|
|
41
|
+
* revisions -> re-critique revised claims. UNSUPPORTED claims without a revision stay
|
|
42
|
+
* visible and flagged (abstention over confident prose, never silent deletion). A failed
|
|
43
|
+
* critique leaves faithfulness 'unchecked' — honest, not upgraded. */
|
|
44
|
+
export const refineClaims = async (provider, file, claims, corpus, opts = {}) => {
|
|
45
|
+
const maxRounds = opts.maxRounds ?? 1;
|
|
46
|
+
const entry = corpus.files.get(file);
|
|
47
|
+
if (!entry || claims.length === 0)
|
|
48
|
+
return { claims, rounds: 0, stop: 'converged' };
|
|
49
|
+
let working = claims.map((c) => ({ ...c }));
|
|
50
|
+
let previousUnchecked = -1;
|
|
51
|
+
let rounds = 0;
|
|
52
|
+
for (let round = 0; round < maxRounds; round++) {
|
|
53
|
+
const targets = working.filter((c) => c.faithfulness !== 'supported');
|
|
54
|
+
if (targets.length === 0)
|
|
55
|
+
return { claims: working, rounds, stop: 'converged' };
|
|
56
|
+
let parsed = null;
|
|
57
|
+
for (let attempt = 0; attempt < 2 && !parsed; attempt++) {
|
|
58
|
+
try {
|
|
59
|
+
const raw = await provider.chat(attempt === 0
|
|
60
|
+
? critiquePrompt(file, entry.lines, targets)
|
|
61
|
+
: `${critiquePrompt(file, entry.lines, targets)}\n\nYour previous output was not valid JSON for the requested shape. Return ONLY the JSON object.`, { json: true, system: CRITIQUE_SYSTEM });
|
|
62
|
+
const first = raw.indexOf('{');
|
|
63
|
+
const last = raw.lastIndexOf('}');
|
|
64
|
+
parsed = critiqueSchema.parse(JSON.parse(first >= 0 ? raw.slice(first, last + 1) : raw));
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
parsed = null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (!parsed)
|
|
71
|
+
return { claims: working, rounds, stop: 'critique-failed' };
|
|
72
|
+
rounds += 1;
|
|
73
|
+
const byId = new Map(parsed.claims.map((v) => [v.id, v]));
|
|
74
|
+
const total = entry.lines.length;
|
|
75
|
+
working = working.map((c) => {
|
|
76
|
+
const v = byId.get(c.id);
|
|
77
|
+
if (!v || c.faithfulness === 'supported')
|
|
78
|
+
return c;
|
|
79
|
+
if (v.verdict === 'SUPPORTED')
|
|
80
|
+
return { ...c, faithfulness: 'supported' };
|
|
81
|
+
const revised = v.revisedText?.trim();
|
|
82
|
+
if (revised && revised.length >= 3 && v.revisedStartLine && v.revisedEndLine && v.revisedStartLine <= total) {
|
|
83
|
+
return {
|
|
84
|
+
...c,
|
|
85
|
+
text: revised,
|
|
86
|
+
citations: [
|
|
87
|
+
{ kind: 'span', span: makeSpan(file, v.revisedStartLine, Math.min(v.revisedEndLine, total), corpus) },
|
|
88
|
+
],
|
|
89
|
+
faithfulness: 'unchecked', // revision must survive its own critique
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return { ...c, faithfulness: 'unsupported' };
|
|
93
|
+
});
|
|
94
|
+
for (const m of parsed.missing ?? []) {
|
|
95
|
+
const text = m.text?.trim();
|
|
96
|
+
if (!text || text.length < 3 || !m.startLine || !m.endLine || m.startLine > total)
|
|
97
|
+
continue;
|
|
98
|
+
const span = makeSpan(file, m.startLine, Math.min(m.endLine, total), corpus);
|
|
99
|
+
if (span.text.trim().length === 0)
|
|
100
|
+
continue; // a blank line grounds nothing
|
|
101
|
+
working.push({
|
|
102
|
+
id: `${claims[0].id.split('#')[0]}#m${working.length}`,
|
|
103
|
+
text,
|
|
104
|
+
citations: [{ kind: 'span', span }],
|
|
105
|
+
faithfulness: 'unchecked',
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const unchecked = working.filter((c) => c.faithfulness === 'unchecked').length;
|
|
109
|
+
if (unchecked === 0)
|
|
110
|
+
return { claims: working, rounds, stop: 'converged' };
|
|
111
|
+
if (unchecked === previousUnchecked)
|
|
112
|
+
return { claims: working, rounds, stop: 'no-progress' };
|
|
113
|
+
previousUnchecked = unchecked;
|
|
114
|
+
}
|
|
115
|
+
return { claims: working, rounds, stop: 'max-rounds' };
|
|
116
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { Claim, CorpusSnapshot, SpanRef } from '../core/types.js';
|
|
3
|
+
import type { ChatProvider } from '../llm/provider.js';
|
|
4
|
+
declare const claimDraftSchema: z.ZodObject<{
|
|
5
|
+
text: z.ZodString;
|
|
6
|
+
startLine: z.ZodNumber;
|
|
7
|
+
endLine: z.ZodNumber;
|
|
8
|
+
}, z.core.$strip>;
|
|
9
|
+
export type ClaimDraft = z.infer<typeof claimDraftSchema>;
|
|
10
|
+
export declare const numberedLines: (lines: string[], startLine: number) => string;
|
|
11
|
+
/** Parse provider output into claim drafts. Tolerates prose-wrapped JSON. Throws on failure. */
|
|
12
|
+
export declare const parseClaimDrafts: (raw: string) => ClaimDraft[];
|
|
13
|
+
/** Deterministic no-LLM claims: declarations, headings, or an honest content statement.
|
|
14
|
+
* Supported-by-construction (text is derived mechanically from the cited lines). */
|
|
15
|
+
export declare const extractiveClaims: (file: string, span: SpanRef, corpus: CorpusSnapshot) => Omit<Claim, 'id'>[];
|
|
16
|
+
export interface SummarizeResult {
|
|
17
|
+
claims: Claim[];
|
|
18
|
+
/** True when any chunk fell back from LLM to extractive. */
|
|
19
|
+
degraded: boolean;
|
|
20
|
+
}
|
|
21
|
+
/** Summarize one file into claims. Provider null = pure extractive. LLM chunks that fail
|
|
22
|
+
* JSON parsing get ONE stricter retry, then fall back to extractive for that chunk — a
|
|
23
|
+
* build never dies on a bad response. */
|
|
24
|
+
export declare const summarizeFile: (provider: ChatProvider | null, file: string, corpus: CorpusSnapshot, idPrefix: string) => Promise<SummarizeResult>;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { chunkFile } from '../core/chunk.js';
|
|
3
|
+
import { makeSpan } from '../core/gate.js';
|
|
4
|
+
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)/u;
|
|
5
|
+
const HEADING_RE = /^(#{1,3}) (.+)$/u;
|
|
6
|
+
const claimDraftSchema = z.object({
|
|
7
|
+
text: z.string().min(3),
|
|
8
|
+
startLine: z.number().int().min(1),
|
|
9
|
+
endLine: z.number().int().min(1),
|
|
10
|
+
});
|
|
11
|
+
const claimsSchema = z.object({ claims: z.array(claimDraftSchema).min(1).max(12) });
|
|
12
|
+
export const numberedLines = (lines, startLine) => lines.map((l, i) => `${startLine + i}: ${l}`).join('\n');
|
|
13
|
+
const SUMMARIZE_SYSTEM = `You are a precise code/document summarization engine inside a provenance-gated knowledge tree. Every claim you emit will be mechanically verified against the cited lines and audited by humans clicking the receipt. Emit only claims the cited lines directly support. Never speculate.`;
|
|
14
|
+
const summarizePrompt = (file, span) => {
|
|
15
|
+
const lines = span.text.split('\n');
|
|
16
|
+
return `Summarize this ${file.endsWith('.md') ? 'documentation section' : 'source code'} into 3-8 atomic factual claims. Each claim must be one self-contained sentence a reader could verify by reading ONLY the cited lines.
|
|
17
|
+
|
|
18
|
+
Return ONLY JSON: {"claims":[{"text":"...","startLine":N,"endLine":N}]}
|
|
19
|
+
Rules: cite the NARROWEST line range that supports the claim; startLine/endLine are the absolute line numbers shown; no claim without its lines.
|
|
20
|
+
|
|
21
|
+
FILE ${file} (lines ${span.startLine}-${span.endLine}):
|
|
22
|
+
${numberedLines(lines, span.startLine)}`;
|
|
23
|
+
};
|
|
24
|
+
/** Parse provider output into claim drafts. Tolerates prose-wrapped JSON. Throws on failure. */
|
|
25
|
+
export const parseClaimDrafts = (raw) => {
|
|
26
|
+
const attempt = (s) => {
|
|
27
|
+
try {
|
|
28
|
+
return claimsSchema.parse(JSON.parse(s)).claims;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const direct = attempt(raw);
|
|
35
|
+
if (direct)
|
|
36
|
+
return direct;
|
|
37
|
+
const first = raw.indexOf('{');
|
|
38
|
+
const last = raw.lastIndexOf('}');
|
|
39
|
+
if (first >= 0 && last > first) {
|
|
40
|
+
const inner = attempt(raw.slice(first, last + 1));
|
|
41
|
+
if (inner)
|
|
42
|
+
return inner;
|
|
43
|
+
}
|
|
44
|
+
throw new Error('unparseable claims JSON');
|
|
45
|
+
};
|
|
46
|
+
/** Deterministic no-LLM claims: declarations, headings, or an honest content statement.
|
|
47
|
+
* Supported-by-construction (text is derived mechanically from the cited lines). */
|
|
48
|
+
export const extractiveClaims = (file, span, corpus) => {
|
|
49
|
+
const lines = span.text.split('\n');
|
|
50
|
+
const claims = [];
|
|
51
|
+
for (let i = 0; i < lines.length && claims.length < 6; i++) {
|
|
52
|
+
const abs = span.startLine + i;
|
|
53
|
+
const heading = HEADING_RE.exec(lines[i]);
|
|
54
|
+
if (heading) {
|
|
55
|
+
// Cite the whole section (to the next heading or span end, capped) so the
|
|
56
|
+
// receipt shows the evidence, not just the headline.
|
|
57
|
+
let end = i + 1;
|
|
58
|
+
while (end < lines.length && !HEADING_RE.test(lines[end]) && end - i < 12)
|
|
59
|
+
end++;
|
|
60
|
+
claims.push({
|
|
61
|
+
text: `Documents "${heading[2].trim()}".`,
|
|
62
|
+
citations: [{ kind: 'span', span: makeSpan(file, abs, span.startLine + end - 1, corpus) }],
|
|
63
|
+
faithfulness: 'supported',
|
|
64
|
+
});
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (DECL_RE.test(lines[i])) {
|
|
68
|
+
claims.push({
|
|
69
|
+
text: `Declares \`${lines[i].trim().replace(/\s*[{(=].*$/u, '').slice(0, 100)}\`.`,
|
|
70
|
+
citations: [{ kind: 'span', span: makeSpan(file, abs, abs, corpus) }],
|
|
71
|
+
faithfulness: 'supported',
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (claims.length === 0) {
|
|
76
|
+
const firstLine = lines.find((l) => l.trim().length > 0) ?? '';
|
|
77
|
+
claims.push({
|
|
78
|
+
text: `Contains ${lines.length} lines beginning "${firstLine.trim().slice(0, 60)}".`,
|
|
79
|
+
citations: [{ kind: 'span', span: makeSpan(file, span.startLine, span.endLine, corpus) }],
|
|
80
|
+
faithfulness: 'supported',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return claims;
|
|
84
|
+
};
|
|
85
|
+
/** Summarize one file into claims. Provider null = pure extractive. LLM chunks that fail
|
|
86
|
+
* JSON parsing get ONE stricter retry, then fall back to extractive for that chunk — a
|
|
87
|
+
* build never dies on a bad response. */
|
|
88
|
+
export const summarizeFile = async (provider, file, corpus, idPrefix) => {
|
|
89
|
+
const entry = corpus.files.get(file);
|
|
90
|
+
if (!entry)
|
|
91
|
+
throw new Error(`summarizeFile: ${file} not in corpus`);
|
|
92
|
+
// An empty (or whitespace-only) file has nothing to claim. Emitting a claim over empty
|
|
93
|
+
// content would be voided by the gate anyway (empty receipts are forgeries by definition)
|
|
94
|
+
// — so don't create it. The explorer's "file is empty" state covers the rendering.
|
|
95
|
+
if (entry.lines.every((l) => l.trim() === ''))
|
|
96
|
+
return { claims: [], degraded: false };
|
|
97
|
+
const spans = chunkFile(file, entry.lines.join('\n'));
|
|
98
|
+
const out = [];
|
|
99
|
+
let degraded = false;
|
|
100
|
+
for (const span of spans) {
|
|
101
|
+
if (!provider) {
|
|
102
|
+
out.push(...extractiveClaims(file, span, corpus));
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
let drafts = null;
|
|
106
|
+
for (let attempt = 0; attempt < 2 && !drafts; attempt++) {
|
|
107
|
+
try {
|
|
108
|
+
const raw = await provider.chat(attempt === 0
|
|
109
|
+
? summarizePrompt(file, span)
|
|
110
|
+
: `${summarizePrompt(file, span)}\n\nYour previous output was not valid JSON. Return ONLY the JSON object, nothing else.`, { json: true, system: SUMMARIZE_SYSTEM });
|
|
111
|
+
drafts = parseClaimDrafts(raw);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
drafts = null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (!drafts) {
|
|
118
|
+
degraded = true;
|
|
119
|
+
out.push(...extractiveClaims(file, span, corpus));
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const total = entry.lines.length;
|
|
123
|
+
const before = out.length;
|
|
124
|
+
for (const d of drafts) {
|
|
125
|
+
if (d.startLine > total || d.endLine < d.startLine)
|
|
126
|
+
continue; // hallucinated range: drop
|
|
127
|
+
const span = makeSpan(file, d.startLine, Math.min(d.endLine, total), corpus);
|
|
128
|
+
if (span.text.trim().length === 0)
|
|
129
|
+
continue; // cites a blank line: can never verify, drop now
|
|
130
|
+
out.push({
|
|
131
|
+
text: d.text.trim(),
|
|
132
|
+
citations: [{ kind: 'span', span }],
|
|
133
|
+
faithfulness: 'unchecked',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (out.length === before) {
|
|
137
|
+
// Every draft had a hallucinated range — reject the batch loudly, not silently.
|
|
138
|
+
degraded = true;
|
|
139
|
+
out.push(...extractiveClaims(file, span, corpus));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
claims: out.map((c, i) => ({ ...c, id: `${idPrefix}#${i}` })),
|
|
144
|
+
degraded,
|
|
145
|
+
};
|
|
146
|
+
};
|