@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.
Files changed (71) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +115 -0
  3. package/dist/build/aggregate.d.ts +9 -0
  4. package/dist/build/aggregate.js +74 -0
  5. package/dist/build/builder.d.ts +52 -0
  6. package/dist/build/builder.js +223 -0
  7. package/dist/build/inmemory.d.ts +16 -0
  8. package/dist/build/inmemory.js +79 -0
  9. package/dist/build/pool.d.ts +3 -0
  10. package/dist/build/pool.js +16 -0
  11. package/dist/build/reflexion.d.ts +15 -0
  12. package/dist/build/reflexion.js +116 -0
  13. package/dist/build/summarize.d.ts +25 -0
  14. package/dist/build/summarize.js +146 -0
  15. package/dist/cli/index.d.ts +2 -0
  16. package/dist/cli/index.js +323 -0
  17. package/dist/core/bm25.d.ts +19 -0
  18. package/dist/core/bm25.js +48 -0
  19. package/dist/core/chunk.d.ts +5 -0
  20. package/dist/core/chunk.js +91 -0
  21. package/dist/core/corpus.d.ts +21 -0
  22. package/dist/core/corpus.js +112 -0
  23. package/dist/core/gate.d.ts +25 -0
  24. package/dist/core/gate.js +155 -0
  25. package/dist/core/hash.d.ts +7 -0
  26. package/dist/core/hash.js +15 -0
  27. package/dist/core/store.d.ts +74 -0
  28. package/dist/core/store.js +66 -0
  29. package/dist/core/types.d.ts +96 -0
  30. package/dist/core/types.js +1 -0
  31. package/dist/fix/prompts.d.ts +4 -0
  32. package/dist/fix/prompts.js +124 -0
  33. package/dist/fix/scan.d.ts +18 -0
  34. package/dist/fix/scan.js +197 -0
  35. package/dist/index.d.ts +28 -0
  36. package/dist/index.js +22 -0
  37. package/dist/llm/anthropic.d.ts +11 -0
  38. package/dist/llm/anthropic.js +46 -0
  39. package/dist/llm/mock.d.ts +5 -0
  40. package/dist/llm/mock.js +20 -0
  41. package/dist/llm/ollama.d.ts +11 -0
  42. package/dist/llm/ollama.js +49 -0
  43. package/dist/llm/provider.d.ts +16 -0
  44. package/dist/llm/provider.js +7 -0
  45. package/dist/llm/resolve.d.ts +14 -0
  46. package/dist/llm/resolve.js +24 -0
  47. package/dist/mcp/server.d.ts +7 -0
  48. package/dist/mcp/server.js +136 -0
  49. package/dist/query/ask.d.ts +53 -0
  50. package/dist/query/ask.js +127 -0
  51. package/dist/query/notarize.d.ts +40 -0
  52. package/dist/query/notarize.js +31 -0
  53. package/dist/registry/github.d.ts +28 -0
  54. package/dist/registry/github.js +113 -0
  55. package/dist/registry/publishClient.d.ts +25 -0
  56. package/dist/registry/publishClient.js +39 -0
  57. package/dist/registry/registry.d.ts +35 -0
  58. package/dist/registry/registry.js +57 -0
  59. package/dist/registry/repoTree.d.ts +9 -0
  60. package/dist/registry/repoTree.js +32 -0
  61. package/dist/serve/app.d.ts +4 -0
  62. package/dist/serve/app.js +809 -0
  63. package/dist/serve/server.d.ts +9 -0
  64. package/dist/serve/server.js +180 -0
  65. package/dist/serve/threads.d.ts +31 -0
  66. package/dist/serve/threads.js +69 -0
  67. package/dist/site/data.d.ts +58 -0
  68. package/dist/site/data.js +88 -0
  69. package/dist/site/generate.d.ts +21 -0
  70. package/dist/site/generate.js +827 -0
  71. package/package.json +64 -0
@@ -0,0 +1,197 @@
1
+ const CODE_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|py|rs|go|java|rb|c|cc|cpp|h|hpp|cs|swift|kt)$/iu;
2
+ const TEST_PATH = /(^|\/)(tests?|__tests__|spec)(\/|$)|\.(test|spec)\.[jt]sx?$|_test\.(py|go)$|test_[^/]*\.py$/iu;
3
+ /** True when the match position on this line sits inside a string literal (odd number of
4
+ * quote characters before it) — kills the scanner-reads-its-own-example false positives. */
5
+ const insideString = (line, index) => {
6
+ const prefix = line.slice(0, index);
7
+ for (const q of ["'", '"', '`']) {
8
+ if ((prefix.split(q).length - 1) % 2 === 1)
9
+ return true;
10
+ }
11
+ return false;
12
+ };
13
+ const receipt = (file, lines, line, span = 0) => ({
14
+ file,
15
+ startLine: line,
16
+ endLine: Math.min(line + span, lines.length),
17
+ text: lines.slice(line - 1, Math.min(line + span, lines.length)).join('\n'),
18
+ });
19
+ /** Deterministic issue scanner — no LLM anywhere. Every finding carries receipts (the
20
+ * exact lines), so the generated prompts are grounded, not vibes. Caps keep output usable. */
21
+ export const scanFindings = (corpus, tree, verification) => {
22
+ const findings = [];
23
+ const perKindCap = 8;
24
+ const count = (kind) => findings.filter((f) => f.kind === kind).length;
25
+ const files = [...corpus.files.entries()];
26
+ const codeFiles = files.filter(([f]) => CODE_EXT.test(f) && !TEST_PATH.test(f));
27
+ // 1. TODO / FIXME / HACK comments — each one is a spec the author already wrote.
28
+ for (const [file, { lines }] of files) {
29
+ if (count('todo-comment') >= perKindCap)
30
+ break;
31
+ for (let i = 0; i < lines.length && count('todo-comment') < perKindCap; i++) {
32
+ const m = /(?:\/\/|#|\/\*|<!--)\s*(TODO|FIXME|HACK|XXX)\b[:\s-]*(.*)/u.exec(lines[i]);
33
+ if (m && !insideString(lines[i], m.index)) {
34
+ findings.push({
35
+ kind: 'todo-comment',
36
+ severity: 2,
37
+ title: `${m[1]} in ${file}:${i + 1}`,
38
+ detail: m[2].trim().replace(/\*\/\s*$|-->\s*$/u, '').trim() || 'unspecified',
39
+ receipts: [receipt(file, lines, Math.max(1, i - 1), 4)],
40
+ });
41
+ }
42
+ }
43
+ }
44
+ // 2. Stale claims that live in DOC leaves — the doc now lies about the code.
45
+ for (const node of Object.values(tree.nodes)) {
46
+ if (node.kind !== 'leaf' || !/\.(md|mdx|markdown|rst|txt)$/iu.test(node.path))
47
+ continue;
48
+ for (const claim of node.claims) {
49
+ if (count('stale-doc-claim') >= perKindCap)
50
+ break;
51
+ const verdict = verification.verdicts.get(claim.id);
52
+ if (verdict === 'STALE' || verdict === 'OUT_OF_CORPUS') {
53
+ const spans = claim.citations.filter((c) => c.kind === 'span');
54
+ findings.push({
55
+ kind: 'stale-doc-claim',
56
+ severity: 1,
57
+ title: `Documentation drifted: ${node.path}`,
58
+ detail: `The claim "${claim.text.slice(0, 140)}" no longer verifies (${verdict}).`,
59
+ receipts: spans.map((s) => ({ file: s.span.file, startLine: s.span.startLine, endLine: s.span.endLine, text: s.span.text.slice(0, 600) })),
60
+ });
61
+ }
62
+ }
63
+ }
64
+ // 3. Oversized files.
65
+ for (const [file, { lines }] of codeFiles) {
66
+ if (count('oversized-file') >= 5)
67
+ break;
68
+ if (lines.length > 500) {
69
+ findings.push({
70
+ kind: 'oversized-file',
71
+ severity: 3,
72
+ title: `${file} is ${lines.length} lines`,
73
+ detail: 'Large files hide structure and resist review; split along its natural seams.',
74
+ receipts: [receipt(file, lines, 1, 0)],
75
+ });
76
+ }
77
+ }
78
+ // 4. Empty catch blocks — swallowed errors.
79
+ for (const [file, { lines }] of codeFiles) {
80
+ if (count('empty-catch') >= perKindCap)
81
+ break;
82
+ const joined = lines.join('\n');
83
+ const re = /catch\s*(?:\([^)]*\))?\s*\{\s*\}/gu;
84
+ let m;
85
+ while ((m = re.exec(joined)) && count('empty-catch') < perKindCap) {
86
+ const line = joined.slice(0, m.index).split('\n').length;
87
+ const lineStart = joined.lastIndexOf('\n', m.index) + 1;
88
+ if (insideString(joined.slice(lineStart, m.index + 1), m.index - lineStart))
89
+ continue;
90
+ findings.push({
91
+ kind: 'empty-catch',
92
+ severity: 1,
93
+ title: `Swallowed error in ${file}:${line}`,
94
+ detail: 'An empty catch hides failures from users and operators alike.',
95
+ receipts: [receipt(file, lines, Math.max(1, line - 2), 5)],
96
+ });
97
+ }
98
+ }
99
+ // 5. Debug leftovers in non-test code.
100
+ for (const [file, { lines }] of codeFiles) {
101
+ if (count('debug-leftover') >= perKindCap)
102
+ break;
103
+ for (let i = 0; i < lines.length && count('debug-leftover') < perKindCap; i++) {
104
+ if (/^\s*console\.(log|debug)\(/u.test(lines[i]) || (/\.py$/iu.test(file) && /^\s*print\(/u.test(lines[i]))) {
105
+ findings.push({
106
+ kind: 'debug-leftover',
107
+ severity: 3,
108
+ title: `Debug output in ${file}:${i + 1}`,
109
+ detail: 'Leftover debug logging in non-test code.',
110
+ receipts: [receipt(file, lines, i + 1, 0)],
111
+ });
112
+ }
113
+ }
114
+ }
115
+ // 6. TypeScript escape hatches.
116
+ for (const [file, { lines }] of codeFiles.filter(([f]) => /\.tsx?$/iu.test(f))) {
117
+ if (count('ts-escape-hatch') >= perKindCap)
118
+ break;
119
+ for (let i = 0; i < lines.length && count('ts-escape-hatch') < perKindCap; i++) {
120
+ const m = /@ts-ignore|@ts-expect-error|\bas any\b|eslint-disable(?!-next-line \S)/u.exec(lines[i]);
121
+ if (m && !insideString(lines[i], m.index)) {
122
+ findings.push({
123
+ kind: 'ts-escape-hatch',
124
+ severity: 2,
125
+ title: `Type-safety escape hatch in ${file}:${i + 1}`,
126
+ detail: 'Each escape hatch is a place the compiler can no longer help.',
127
+ receipts: [receipt(file, lines, Math.max(1, i - 1), 3)],
128
+ });
129
+ }
130
+ }
131
+ }
132
+ // 7. Modules with exports but no matching test file.
133
+ const testBasenames = files.filter(([f]) => TEST_PATH.test(f)).map(([f]) => f.toLowerCase());
134
+ for (const [file, { lines }] of codeFiles) {
135
+ if (count('untested-module') >= 6)
136
+ break;
137
+ const base = (file.split('/').pop() ?? '').replace(/\.[^.]+$/u, '').toLowerCase();
138
+ if (base.length < 3)
139
+ continue;
140
+ const exportLines = [];
141
+ let runtimeExports = 0;
142
+ lines.forEach((l, i) => {
143
+ if (/^export\s|^module\.exports|^def [a-z_]+|^class [A-Z]/u.test(l)) {
144
+ exportLines.push(i + 1);
145
+ // Types and barrel re-exports carry no runtime behavior of their own.
146
+ if (!/^export\s+(type|interface|\{|\*)/u.test(l))
147
+ runtimeExports += 1;
148
+ }
149
+ });
150
+ if (exportLines.length >= 2 && runtimeExports >= 2 && !testBasenames.some((t) => t.includes(base))) {
151
+ findings.push({
152
+ kind: 'untested-module',
153
+ severity: 2,
154
+ title: `${file} has ${exportLines.length} exports and no matching test file`,
155
+ detail: 'Logic-bearing exports without tests regress silently.',
156
+ receipts: exportLines.slice(0, 5).map((l) => receipt(file, lines, l, 0)),
157
+ });
158
+ }
159
+ }
160
+ // 8. Dirs with several code files and no README — grounded in the tree's own claims.
161
+ for (const node of Object.values(tree.nodes)) {
162
+ if (count('undocumented-dir') >= 5)
163
+ break;
164
+ if (node.kind !== 'dir')
165
+ continue;
166
+ const kids = node.childIds.filter((id) => tree.nodes[id]?.kind === 'leaf');
167
+ const codeKids = kids.filter((id) => CODE_EXT.test(tree.nodes[id].path));
168
+ const hasReadme = kids.some((id) => /readme\.md$/iu.test(tree.nodes[id].path));
169
+ if (codeKids.length >= 3 && !hasReadme) {
170
+ findings.push({
171
+ kind: 'undocumented-dir',
172
+ severity: 3,
173
+ title: `${node.path}/ has ${codeKids.length} code files and no README`,
174
+ detail: `What the tree already knows about it: ${node.claims.slice(0, 2).map((c) => c.text).join(' ')}`.slice(0, 240),
175
+ receipts: [],
176
+ });
177
+ }
178
+ }
179
+ // 9. Claims the semantic critique flagged — the code confused its own summarizer.
180
+ for (const node of Object.values(tree.nodes)) {
181
+ if (count('unsupported-claim') >= perKindCap)
182
+ break;
183
+ for (const claim of node.claims) {
184
+ if (claim.faithfulness === 'unsupported' && count('unsupported-claim') < perKindCap) {
185
+ const spans = claim.citations.filter((c) => c.kind === 'span');
186
+ findings.push({
187
+ kind: 'unsupported-claim',
188
+ severity: 2,
189
+ title: `Code and its own summary disagree: ${node.path}`,
190
+ detail: `The critique judged "${claim.text.slice(0, 120)}" unsupported by the cited lines — the code is unclear or the behavior surprising.`,
191
+ receipts: spans.map((s) => ({ file: s.span.file, startLine: s.span.startLine, endLine: s.span.endLine, text: s.span.text.slice(0, 600) })),
192
+ });
193
+ }
194
+ }
195
+ }
196
+ return findings.sort((a, b) => a.severity - b.severity).slice(0, 30);
197
+ };
@@ -0,0 +1,28 @@
1
+ export * from './core/types.js';
2
+ export { normalizeText, sha256, splitLines } from './core/hash.js';
3
+ export { chunkFile } from './core/chunk.js';
4
+ export { loadCorpus } from './core/corpus.js';
5
+ export type { LoadOptions, LoadedCorpus, SkippedFile } from './core/corpus.js';
6
+ export { makeSpan, verifySpan, verifyClaim, verifyTree, applyVerification } from './core/gate.js';
7
+ export { loadTree, saveTree, treePath } from './core/store.js';
8
+ export { Bm25Index, tokenize } from './core/bm25.js';
9
+ export { buildTree, verifyExisting } from './build/builder.js';
10
+ export type { BuildOptions, BuildResult, ProgressEvent, VerifyResult } from './build/builder.js';
11
+ export { refineClaims } from './build/reflexion.js';
12
+ export { summarizeFile, extractiveClaims, parseClaimDrafts } from './build/summarize.js';
13
+ export { ask } from './query/ask.js';
14
+ export type { AskResult, AnswerSentence, EvidenceItem } from './query/ask.js';
15
+ export { ollamaProvider, ollamaReachable } from './llm/ollama.js';
16
+ export { anthropicProvider } from './llm/anthropic.js';
17
+ export { mockProvider } from './llm/mock.js';
18
+ export type { ChatProvider, ChatOptions } from './llm/provider.js';
19
+ export { createOverstoryServer, runStdioServer } from './mcp/server.js';
20
+ export { buildSiteData } from './site/data.js';
21
+ export { generateSiteHtml } from './site/generate.js';
22
+ export { buildTreeFromCorpus } from './build/inmemory.js';
23
+ export { fetchGithubSnapshot, snapshotFromTarball, readTar } from './registry/github.js';
24
+ export type { GithubSnapshot, FetchOptions } from './registry/github.js';
25
+ export { instantTree, adjudicatePublish, reverify } from './registry/registry.js';
26
+ export type { PublishVerdict } from './registry/registry.js';
27
+ export { parseGithubRemote, checkPublished, DEFAULT_REGISTRY } from './registry/publishClient.js';
28
+ export { fetchRepoTree } from './registry/repoTree.js';
package/dist/index.js ADDED
@@ -0,0 +1,22 @@
1
+ export * from './core/types.js';
2
+ export { normalizeText, sha256, splitLines } from './core/hash.js';
3
+ export { chunkFile } from './core/chunk.js';
4
+ export { loadCorpus } from './core/corpus.js';
5
+ export { makeSpan, verifySpan, verifyClaim, verifyTree, applyVerification } from './core/gate.js';
6
+ export { loadTree, saveTree, treePath } from './core/store.js';
7
+ export { Bm25Index, tokenize } from './core/bm25.js';
8
+ export { buildTree, verifyExisting } from './build/builder.js';
9
+ export { refineClaims } from './build/reflexion.js';
10
+ export { summarizeFile, extractiveClaims, parseClaimDrafts } from './build/summarize.js';
11
+ export { ask } from './query/ask.js';
12
+ export { ollamaProvider, ollamaReachable } from './llm/ollama.js';
13
+ export { anthropicProvider } from './llm/anthropic.js';
14
+ export { mockProvider } from './llm/mock.js';
15
+ export { createOverstoryServer, runStdioServer } from './mcp/server.js';
16
+ export { buildSiteData } from './site/data.js';
17
+ export { generateSiteHtml } from './site/generate.js';
18
+ export { buildTreeFromCorpus } from './build/inmemory.js';
19
+ export { fetchGithubSnapshot, snapshotFromTarball, readTar } from './registry/github.js';
20
+ export { instantTree, adjudicatePublish, reverify } from './registry/registry.js';
21
+ export { parseGithubRemote, checkPublished, DEFAULT_REGISTRY } from './registry/publishClient.js';
22
+ export { fetchRepoTree } from './registry/repoTree.js';
@@ -0,0 +1,11 @@
1
+ import { ChatProvider } from './provider.js';
2
+ export interface AnthropicConfig {
3
+ apiKey?: string;
4
+ model?: string;
5
+ concurrency?: number;
6
+ timeoutMs?: number;
7
+ baseUrl?: string;
8
+ }
9
+ /** Direct Messages API via fetch — no SDK dependency. Default model is the cheap tier;
10
+ * the Reflexion critique step passes its own stronger model ("generate cheap, judge dear"). */
11
+ export declare const anthropicProvider: (config?: AnthropicConfig) => ChatProvider;
@@ -0,0 +1,46 @@
1
+ import { ProviderError } from './provider.js';
2
+ /** Direct Messages API via fetch — no SDK dependency. Default model is the cheap tier;
3
+ * the Reflexion critique step passes its own stronger model ("generate cheap, judge dear"). */
4
+ export const anthropicProvider = (config = {}) => {
5
+ const apiKey = config.apiKey ?? process.env.ANTHROPIC_API_KEY;
6
+ const model = config.model ?? process.env.OVERSTORY_ANTHROPIC_MODEL ?? 'claude-haiku-4-5-20251001';
7
+ const baseUrl = config.baseUrl ?? 'https://api.anthropic.com';
8
+ if (!apiKey)
9
+ throw new ProviderError('anthropic', 'ANTHROPIC_API_KEY is not set');
10
+ return {
11
+ name: `anthropic:${model}`,
12
+ concurrency: config.concurrency ?? 6,
13
+ async chat(prompt, opts = {}) {
14
+ let res;
15
+ try {
16
+ res = await fetch(`${baseUrl}/v1/messages`, {
17
+ method: 'POST',
18
+ signal: AbortSignal.timeout(config.timeoutMs ?? 120_000),
19
+ headers: {
20
+ 'content-type': 'application/json',
21
+ 'x-api-key': apiKey,
22
+ 'anthropic-version': '2023-06-01',
23
+ },
24
+ body: JSON.stringify({
25
+ model,
26
+ max_tokens: opts.maxTokens ?? 2000,
27
+ ...(opts.system ? { system: opts.system } : {}),
28
+ messages: [{ role: 'user', content: prompt }],
29
+ }),
30
+ });
31
+ }
32
+ catch (cause) {
33
+ throw new ProviderError(`anthropic:${model}`, 'request failed', cause);
34
+ }
35
+ if (!res.ok) {
36
+ const text = await res.text().catch(() => '');
37
+ throw new ProviderError(`anthropic:${model}`, `HTTP ${res.status}: ${text.slice(0, 300)}`);
38
+ }
39
+ const body = (await res.json());
40
+ const text = body.content?.find((b) => b.type === 'text')?.text;
41
+ if (typeof text !== 'string')
42
+ throw new ProviderError(`anthropic:${model}`, 'empty response');
43
+ return text;
44
+ },
45
+ };
46
+ };
@@ -0,0 +1,5 @@
1
+ import { ChatOptions, ChatProvider } from './provider.js';
2
+ /** Test double: answers from a queue or a handler. Records every prompt. */
3
+ export declare const mockProvider: (responses: string[] | ((prompt: string, opts?: ChatOptions) => string)) => ChatProvider & {
4
+ prompts: string[];
5
+ };
@@ -0,0 +1,20 @@
1
+ /** Test double: answers from a queue or a handler. Records every prompt. */
2
+ export const mockProvider = (responses) => {
3
+ const queue = Array.isArray(responses) ? [...responses] : null;
4
+ const prompts = [];
5
+ return {
6
+ name: 'mock',
7
+ concurrency: 8,
8
+ prompts,
9
+ async chat(prompt, opts) {
10
+ prompts.push(prompt);
11
+ if (queue) {
12
+ const next = queue.shift();
13
+ if (next === undefined)
14
+ throw new Error('mockProvider: response queue exhausted');
15
+ return next;
16
+ }
17
+ return responses(prompt, opts);
18
+ },
19
+ };
20
+ };
@@ -0,0 +1,11 @@
1
+ import { ChatProvider } from './provider.js';
2
+ export interface OllamaConfig {
3
+ baseUrl?: string;
4
+ model?: string;
5
+ /** Single-GPU hosts queue anyway; 2 keeps the pipe full without thrash. */
6
+ concurrency?: number;
7
+ timeoutMs?: number;
8
+ numCtx?: number;
9
+ }
10
+ export declare const ollamaProvider: (config?: OllamaConfig) => ChatProvider;
11
+ export declare const ollamaReachable: (baseUrl?: string) => Promise<boolean>;
@@ -0,0 +1,49 @@
1
+ import { ProviderError } from './provider.js';
2
+ export const ollamaProvider = (config = {}) => {
3
+ const baseUrl = config.baseUrl ?? process.env.OVERSTORY_OLLAMA_URL ?? 'http://localhost:11434';
4
+ const model = config.model ?? process.env.OVERSTORY_OLLAMA_MODEL ?? 'qwen2.5:14b';
5
+ const timeoutMs = config.timeoutMs ?? 240_000;
6
+ return {
7
+ name: `ollama:${model}`,
8
+ concurrency: config.concurrency ?? 2,
9
+ async chat(prompt, opts = {}) {
10
+ const messages = [];
11
+ if (opts.system)
12
+ messages.push({ role: 'system', content: opts.system });
13
+ messages.push({ role: 'user', content: prompt });
14
+ let res;
15
+ try {
16
+ res = await fetch(`${baseUrl}/api/chat`, {
17
+ method: 'POST',
18
+ signal: AbortSignal.timeout(timeoutMs),
19
+ body: JSON.stringify({
20
+ model,
21
+ stream: false,
22
+ ...(opts.json ? { format: 'json' } : {}),
23
+ options: { num_ctx: config.numCtx ?? 8192, temperature: 0.2 },
24
+ messages,
25
+ }),
26
+ });
27
+ }
28
+ catch (cause) {
29
+ throw new ProviderError(`ollama:${model}`, 'request failed (is Ollama running?)', cause);
30
+ }
31
+ if (!res.ok)
32
+ throw new ProviderError(`ollama:${model}`, `HTTP ${res.status}`);
33
+ const body = (await res.json());
34
+ const content = body.message?.content;
35
+ if (typeof content !== 'string')
36
+ throw new ProviderError(`ollama:${model}`, 'empty response');
37
+ return content;
38
+ },
39
+ };
40
+ };
41
+ export const ollamaReachable = async (baseUrl = 'http://localhost:11434') => {
42
+ try {
43
+ const res = await fetch(`${baseUrl}/api/tags`, { signal: AbortSignal.timeout(3_000) });
44
+ return res.ok;
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ };
@@ -0,0 +1,16 @@
1
+ export interface ChatOptions {
2
+ /** Ask the provider to emit strict JSON (native JSON mode where supported). */
3
+ json?: boolean;
4
+ system?: string;
5
+ maxTokens?: number;
6
+ }
7
+ export interface ChatProvider {
8
+ name: string;
9
+ /** How many concurrent requests this provider handles well. */
10
+ concurrency: number;
11
+ chat(prompt: string, opts?: ChatOptions): Promise<string>;
12
+ }
13
+ export declare class ProviderError extends Error {
14
+ readonly cause?: unknown;
15
+ constructor(provider: string, message: string, cause?: unknown);
16
+ }
@@ -0,0 +1,7 @@
1
+ export class ProviderError extends Error {
2
+ cause;
3
+ constructor(provider, message, cause) {
4
+ super(`[${provider}] ${message}`);
5
+ this.cause = cause;
6
+ }
7
+ }
@@ -0,0 +1,14 @@
1
+ import type { ChatProvider } from './provider.js';
2
+ export interface ResolveOptions {
3
+ /** 'auto' | 'ollama' | 'anthropic' | 'none' */
4
+ provider?: string;
5
+ model?: string;
6
+ onNotice?: (message: string) => void;
7
+ }
8
+ export interface ResolvedProviders {
9
+ provider: ChatProvider | null;
10
+ critic: ChatProvider | null;
11
+ }
12
+ /** Shared provider resolution for CLI + serve: explicit choice wins; auto prefers the
13
+ * Anthropic key, then local Ollama, then honest extractive. */
14
+ export declare const resolveProviders: (opts?: ResolveOptions) => Promise<ResolvedProviders>;
@@ -0,0 +1,24 @@
1
+ import { anthropicProvider } from './anthropic.js';
2
+ import { ollamaProvider, ollamaReachable } from './ollama.js';
3
+ /** Shared provider resolution for CLI + serve: explicit choice wins; auto prefers the
4
+ * Anthropic key, then local Ollama, then honest extractive. */
5
+ export const resolveProviders = async (opts = {}) => {
6
+ const wanted = opts.provider ?? 'auto';
7
+ if (wanted === 'none')
8
+ return { provider: null, critic: null };
9
+ if (wanted === 'anthropic' || (wanted === 'auto' && process.env.ANTHROPIC_API_KEY)) {
10
+ const provider = anthropicProvider({ model: opts.model });
11
+ const critic = anthropicProvider({ model: process.env.OVERSTORY_CRITIC_MODEL ?? 'claude-sonnet-5' });
12
+ return { provider, critic };
13
+ }
14
+ if (wanted === 'ollama' || wanted === 'auto') {
15
+ if (await ollamaReachable()) {
16
+ const provider = ollamaProvider({ model: opts.model });
17
+ return { provider, critic: provider };
18
+ }
19
+ if (wanted === 'ollama')
20
+ throw new Error('Ollama is not reachable at localhost:11434. Start it, or use --provider none.');
21
+ opts.onNotice?.('No LLM available (no ANTHROPIC_API_KEY, Ollama unreachable) — answers fall back to verified evidence claims.');
22
+ }
23
+ return { provider: null, critic: null };
24
+ };
@@ -0,0 +1,7 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ /** MCP server over a built tree. The host agent (Claude Code, Cursor, ...) does the
3
+ * thinking; these tools return evidence with receipts and mechanically check the host's
4
+ * own citations. Verification here is provenance + freshness — deliberately never a
5
+ * claim of semantic truth. */
6
+ export declare const createOverstoryServer: (root: string) => McpServer;
7
+ export declare const runStdioServer: (root: string) => Promise<void>;
@@ -0,0 +1,136 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { z } from 'zod';
3
+ import { loadCorpus } from '../core/corpus.js';
4
+ import { verifyTree } from '../core/gate.js';
5
+ import { loadTree, treePath } from '../core/store.js';
6
+ import { buildClaimIndex } from '../query/ask.js';
7
+ import { notarizeClaims } from '../query/notarize.js';
8
+ const json = (value) => ({ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] });
9
+ const jsonError = (message) => ({ ...json({ error: message }), isError: true });
10
+ const NEEDS_BUILD = 'No knowledge tree found. Run `npx @northtek/overstory build` in the project first.';
11
+ /** MCP server over a built tree. The host agent (Claude Code, Cursor, ...) does the
12
+ * thinking; these tools return evidence with receipts and mechanically check the host's
13
+ * own citations. Verification here is provenance + freshness — deliberately never a
14
+ * claim of semantic truth. */
15
+ export const createOverstoryServer = (root) => {
16
+ const server = new McpServer({ name: 'overstory', version: '0.1.0' });
17
+ const load = async () => {
18
+ const tree = await loadTree(treePath(root));
19
+ if (!tree)
20
+ return null;
21
+ // Verify against the corpus the tree was BUILT with — a differently-scoped corpus
22
+ // produces wrong verdicts (files outside the default caps would read OUT_OF_CORPUS).
23
+ const corpus = await loadCorpus(root, tree.corpusOptions ?? {});
24
+ return { tree, corpus };
25
+ };
26
+ server.registerTool('overstory_map', {
27
+ description: 'Overview of the knowledge tree for this repo: root claims, top-level structure, and live freshness (fraction of claims whose receipts still verify against the current code).',
28
+ inputSchema: {},
29
+ }, async () => {
30
+ const loaded = await load();
31
+ if (!loaded)
32
+ return jsonError(NEEDS_BUILD);
33
+ const { tree, corpus } = loaded;
34
+ const verification = verifyTree(tree, corpus);
35
+ const rootNode = tree.nodes[tree.root];
36
+ return json({
37
+ name: tree.name,
38
+ builtAt: tree.builtAt,
39
+ freshness: Number(verification.freshness.toFixed(3)),
40
+ rootClaims: rootNode.claims.map((c) => ({ id: c.id, text: c.text, verdict: verification.verdicts.get(c.id), faithfulness: c.faithfulness })),
41
+ children: rootNode.childIds.map((id) => {
42
+ const n = tree.nodes[id];
43
+ const counts = verification.byNode.get(id);
44
+ return { id, path: n.path, kind: n.kind, summary: n.summary, claims: counts?.total ?? 0, verified: counts?.verified ?? 0 };
45
+ }),
46
+ });
47
+ });
48
+ server.registerTool('overstory_node', {
49
+ description: 'One tree node (by node id like "leaf:src/gate.ts" / "dir:src", or by file/dir path) with all its claims, verdicts, faithfulness tiers, and source receipts (file:line spans).',
50
+ inputSchema: { idOrPath: z.string().describe('Node id or repo-relative path') },
51
+ }, async ({ idOrPath }) => {
52
+ const loaded = await load();
53
+ if (!loaded)
54
+ return jsonError(NEEDS_BUILD);
55
+ const { tree, corpus } = loaded;
56
+ const node = (Object.hasOwn(tree.nodes, idOrPath) ? tree.nodes[idOrPath] : undefined) ??
57
+ Object.values(tree.nodes).find((n) => n.path === idOrPath || n.path === idOrPath.replace(/\\/gu, '/'));
58
+ if (!node)
59
+ return jsonError(`No node "${idOrPath}". Try overstory_map or overstory_search first.`);
60
+ const verification = verifyTree(tree, corpus);
61
+ return json({
62
+ id: node.id,
63
+ kind: node.kind,
64
+ path: node.path,
65
+ builtWith: node.builtWith,
66
+ childIds: node.childIds,
67
+ claims: node.claims.map((c) => ({
68
+ id: c.id,
69
+ text: c.text,
70
+ verdict: verification.verdicts.get(c.id),
71
+ faithfulness: c.faithfulness,
72
+ receipts: c.citations.map((cit) => cit.kind === 'span'
73
+ ? { file: cit.span.file, lines: `${cit.span.startLine}-${cit.span.endLine}`, text: cit.span.text.slice(0, 500) }
74
+ : { claimRef: `${cit.ref.nodeId} :: ${cit.ref.claimId}` }),
75
+ })),
76
+ });
77
+ });
78
+ server.registerTool('overstory_search', {
79
+ description: 'Search the tree claims (BM25) for evidence relevant to a question. Returns claims with ids, verdicts, and source spans — cite these ids in answers, then check yourself with overstory_verify.',
80
+ inputSchema: {
81
+ query: z.string().describe('Search query'),
82
+ k: z.number().int().min(1).max(25).optional().describe('Max results, default 8'),
83
+ },
84
+ }, async ({ query, k }) => {
85
+ const loaded = await load();
86
+ if (!loaded)
87
+ return jsonError(NEEDS_BUILD);
88
+ const { tree, corpus } = loaded;
89
+ const verification = verifyTree(tree, corpus);
90
+ const { index, byId } = buildClaimIndex(tree);
91
+ const hits = index.search(query, k ?? 8);
92
+ return json(hits.map(({ id, score }) => {
93
+ const { claim, nodeId } = byId.get(id);
94
+ return {
95
+ claimId: id,
96
+ node: nodeId,
97
+ path: tree.nodes[nodeId].path,
98
+ text: claim.text,
99
+ verdict: verification.verdicts.get(id),
100
+ faithfulness: claim.faithfulness,
101
+ score: Number(score.toFixed(3)),
102
+ spans: claim.citations
103
+ .filter((c) => c.kind === 'span')
104
+ .map((c) => `${c.span.file}:${c.span.startLine}-${c.span.endLine}`),
105
+ };
106
+ }));
107
+ });
108
+ server.registerTool('overstory_verify', {
109
+ description: 'Notarize YOUR drafted answer about this repo. Submit each claim with its file:line citations; returns per-claim verdicts — RESOLVED (citation exists; receipt text returned so you can self-check it truly supports your claim), OUT_OF_CORPUS (file/lines do not exist — fix before answering), UNGROUNDED (no citations) — plus corroborating verified tree claims when found. This checks provenance mechanically; it does not certify truth.',
110
+ inputSchema: {
111
+ claims: z
112
+ .array(z.object({
113
+ text: z.string().describe('One atomic claim from your draft'),
114
+ citations: z
115
+ .array(z.object({ file: z.string(), startLine: z.number().int().min(1), endLine: z.number().int().min(1) }))
116
+ .describe('file:line evidence for this claim'),
117
+ }))
118
+ .min(1)
119
+ .max(30),
120
+ },
121
+ }, async ({ claims }) => {
122
+ const loaded = await load();
123
+ if (!loaded)
124
+ return jsonError(NEEDS_BUILD);
125
+ const { tree, corpus } = loaded;
126
+ const verification = verifyTree(tree, corpus);
127
+ const { index, byId } = buildClaimIndex(tree);
128
+ return json(notarizeClaims(claims, tree, corpus, verification, index, byId));
129
+ });
130
+ return server;
131
+ };
132
+ export const runStdioServer = async (root) => {
133
+ const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
134
+ const server = createOverstoryServer(root);
135
+ await server.connect(new StdioServerTransport());
136
+ };