@taurgis/bonsai 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +239 -0
- package/agents/agents/official-docs-researcher.agent.md +98 -0
- package/agents/instructions/web-research.instructions.md +29 -0
- package/agents/skills/web-research/SKILL.md +126 -0
- package/bin/cli.mjs +22 -0
- package/dist/base-command.d.ts +14 -0
- package/dist/base-command.js +56 -0
- package/dist/commands/config/base.d.ts +23 -0
- package/dist/commands/config/base.js +46 -0
- package/dist/commands/config/get.d.ts +20 -0
- package/dist/commands/config/get.js +48 -0
- package/dist/commands/config/list.d.ts +17 -0
- package/dist/commands/config/list.js +48 -0
- package/dist/commands/config/set.d.ts +31 -0
- package/dist/commands/config/set.js +94 -0
- package/dist/commands/config/unset.d.ts +21 -0
- package/dist/commands/config/unset.js +65 -0
- package/dist/commands/fetch.d.ts +38 -0
- package/dist/commands/fetch.js +311 -0
- package/dist/commands/import.d.ts +38 -0
- package/dist/commands/import.js +313 -0
- package/dist/commands/index.d.ts +35 -0
- package/dist/commands/index.js +306 -0
- package/dist/commands/inspect.d.ts +13 -0
- package/dist/commands/inspect.js +82 -0
- package/dist/commands/list.d.ts +25 -0
- package/dist/commands/list.js +143 -0
- package/dist/commands/prune.d.ts +24 -0
- package/dist/commands/prune.js +141 -0
- package/dist/commands/research/config/base.d.ts +23 -0
- package/dist/commands/research/config/base.js +46 -0
- package/dist/commands/research/config/get.d.ts +20 -0
- package/dist/commands/research/config/get.js +45 -0
- package/dist/commands/research/config/list.d.ts +17 -0
- package/dist/commands/research/config/list.js +42 -0
- package/dist/commands/research/config/set.d.ts +31 -0
- package/dist/commands/research/config/set.js +94 -0
- package/dist/commands/research/config/unset.d.ts +21 -0
- package/dist/commands/research/config/unset.js +59 -0
- package/dist/commands/research/import.d.ts +38 -0
- package/dist/commands/research/import.js +313 -0
- package/dist/commands/research/inspect.d.ts +13 -0
- package/dist/commands/research/inspect.js +86 -0
- package/dist/commands/research/list.d.ts +25 -0
- package/dist/commands/research/list.js +143 -0
- package/dist/commands/research/prune.d.ts +24 -0
- package/dist/commands/research/prune.js +141 -0
- package/dist/commands/research/search.d.ts +32 -0
- package/dist/commands/research/search.js +334 -0
- package/dist/commands/research/status.d.ts +17 -0
- package/dist/commands/research/status.js +108 -0
- package/dist/commands/research.d.ts +36 -0
- package/dist/commands/research.js +304 -0
- package/dist/commands/search.d.ts +32 -0
- package/dist/commands/search.js +334 -0
- package/dist/commands/status.d.ts +17 -0
- package/dist/commands/status.js +95 -0
- package/dist/commands.d.ts +24 -0
- package/dist/commands.js +24 -0
- package/dist/lib/config/index.d.ts +18 -0
- package/dist/lib/config/index.js +33 -0
- package/dist/lib/config/io.d.ts +8 -0
- package/dist/lib/config/io.js +75 -0
- package/dist/lib/config/resolve.d.ts +15 -0
- package/dist/lib/config/resolve.js +20 -0
- package/dist/lib/config/schema.d.ts +28 -0
- package/dist/lib/config/schema.js +32 -0
- package/dist/lib/research/artifact.d.ts +21 -0
- package/dist/lib/research/artifact.js +217 -0
- package/dist/lib/research/browser.d.ts +74 -0
- package/dist/lib/research/browser.js +359 -0
- package/dist/lib/research/cache-key.d.ts +5 -0
- package/dist/lib/research/cache-key.js +10 -0
- package/dist/lib/research/capture.d.ts +29 -0
- package/dist/lib/research/capture.js +181 -0
- package/dist/lib/research/compress.d.ts +5 -0
- package/dist/lib/research/compress.js +17 -0
- package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.d.ts +1 -0
- package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.js +2 -0
- package/dist/lib/research/docs/capabilities.d.ts +43 -0
- package/dist/lib/research/docs/capabilities.js +9 -0
- package/dist/lib/research/docs/clean-dom.d.ts +4 -0
- package/dist/lib/research/docs/clean-dom.js +41 -0
- package/dist/lib/research/docs/code-blocks.d.ts +5 -0
- package/dist/lib/research/docs/code-blocks.js +69 -0
- package/dist/lib/research/docs/detect.d.ts +6 -0
- package/dist/lib/research/docs/detect.js +239 -0
- package/dist/lib/research/docs/machine-readable.d.ts +21 -0
- package/dist/lib/research/docs/machine-readable.js +73 -0
- package/dist/lib/research/docs/markdown-source.d.ts +17 -0
- package/dist/lib/research/docs/markdown-source.js +194 -0
- package/dist/lib/research/docs/quality-gates.d.ts +9 -0
- package/dist/lib/research/docs/quality-gates.js +65 -0
- package/dist/lib/research/docs/remote-search-runner.d.ts +21 -0
- package/dist/lib/research/docs/remote-search-runner.js +54 -0
- package/dist/lib/research/docs/remote-search.d.ts +14 -0
- package/dist/lib/research/docs/remote-search.js +101 -0
- package/dist/lib/research/docs/rsc.d.ts +10 -0
- package/dist/lib/research/docs/rsc.js +30 -0
- package/dist/lib/research/docs/search-index.d.ts +13 -0
- package/dist/lib/research/docs/search-index.js +176 -0
- package/dist/lib/research/docs/section-artifacts.d.ts +12 -0
- package/dist/lib/research/docs/section-artifacts.js +75 -0
- package/dist/lib/research/docs/sections.d.ts +12 -0
- package/dist/lib/research/docs/sections.js +67 -0
- package/dist/lib/research/docs/source-map.d.ts +19 -0
- package/dist/lib/research/docs/source-map.js +87 -0
- package/dist/lib/research/docs/validate.d.ts +15 -0
- package/dist/lib/research/docs/validate.js +63 -0
- package/dist/lib/research/extract.d.ts +11 -0
- package/dist/lib/research/extract.js +83 -0
- package/dist/lib/research/fetcher.d.ts +29 -0
- package/dist/lib/research/fetcher.js +192 -0
- package/dist/lib/research/freshness.d.ts +21 -0
- package/dist/lib/research/freshness.js +82 -0
- package/dist/lib/research/markdown.d.ts +6 -0
- package/dist/lib/research/markdown.js +66 -0
- package/dist/lib/research/regression.d.ts +23 -0
- package/dist/lib/research/regression.js +93 -0
- package/dist/lib/research/resolve-target.d.ts +18 -0
- package/dist/lib/research/resolve-target.js +20 -0
- package/dist/lib/research/revalidate.d.ts +32 -0
- package/dist/lib/research/revalidate.js +152 -0
- package/dist/lib/research/schema.d.ts +47 -0
- package/dist/lib/research/schema.js +1 -0
- package/dist/lib/research/secret-scan.d.ts +5 -0
- package/dist/lib/research/secret-scan.js +37 -0
- package/dist/lib/research/secure-write.d.ts +16 -0
- package/dist/lib/research/secure-write.js +17 -0
- package/dist/lib/research/storage.d.ts +52 -0
- package/dist/lib/research/storage.js +182 -0
- package/dist/lib/research/store-roots.d.ts +29 -0
- package/dist/lib/research/store-roots.js +28 -0
- package/dist/lib/research/token-estimate.d.ts +5 -0
- package/dist/lib/research/token-estimate.js +7 -0
- package/dist/lib/research/url.d.ts +24 -0
- package/dist/lib/research/url.js +121 -0
- package/dist/sites/index.d.ts +4 -0
- package/dist/sites/index.js +28 -0
- package/dist/sites/salesforce/browser-search.d.ts +7 -0
- package/dist/sites/salesforce/browser-search.js +82 -0
- package/dist/sites/salesforce/coveo.d.ts +23 -0
- package/dist/sites/salesforce/coveo.js +161 -0
- package/dist/sites/salesforce/fetch-page.d.ts +6 -0
- package/dist/sites/salesforce/fetch-page.js +42 -0
- package/dist/sites/salesforce/index.d.ts +2 -0
- package/dist/sites/salesforce/index.js +16 -0
- package/dist/sites/salesforce/token.d.ts +26 -0
- package/dist/sites/salesforce/token.js +154 -0
- package/dist/sites/salesforce-developer/fetch-page.d.ts +3 -0
- package/dist/sites/salesforce-developer/fetch-page.js +18 -0
- package/dist/sites/salesforce-developer/index.d.ts +2 -0
- package/dist/sites/salesforce-developer/index.js +12 -0
- package/dist/sites/salesforce-doc-fetch.d.ts +14 -0
- package/dist/sites/salesforce-doc-fetch.js +321 -0
- package/dist/sites/types.d.ts +27 -0
- package/dist/sites/types.js +1 -0
- package/oclif.manifest.json +845 -0
- package/package.json +78 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
function queryTerms(query) {
|
|
2
|
+
return query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
3
|
+
}
|
|
4
|
+
// Score = title term hits (heavy) + body term hits (light). Zero means no match (dropped).
|
|
5
|
+
function scoreText(title, body, terms) {
|
|
6
|
+
const t = title.toLowerCase();
|
|
7
|
+
const b = body.toLowerCase();
|
|
8
|
+
let score = 0;
|
|
9
|
+
for (const term of terms) {
|
|
10
|
+
if (t.includes(term))
|
|
11
|
+
score += 10;
|
|
12
|
+
const bodyHits = b.split(term).length - 1;
|
|
13
|
+
score += Math.min(bodyHits, 5);
|
|
14
|
+
}
|
|
15
|
+
return score;
|
|
16
|
+
}
|
|
17
|
+
function snippetFrom(text, terms) {
|
|
18
|
+
const lower = text.toLowerCase();
|
|
19
|
+
let idx = 0;
|
|
20
|
+
for (const term of terms) {
|
|
21
|
+
const found = lower.indexOf(term);
|
|
22
|
+
if (found !== -1) {
|
|
23
|
+
idx = found;
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const start = Math.max(0, idx - 40);
|
|
28
|
+
return text
|
|
29
|
+
.slice(start, start + 150)
|
|
30
|
+
.replace(/\s+/g, ' ')
|
|
31
|
+
.trim();
|
|
32
|
+
}
|
|
33
|
+
function splitAnchor(location) {
|
|
34
|
+
const hashIdx = location.indexOf('#');
|
|
35
|
+
if (hashIdx === -1)
|
|
36
|
+
return { path: location };
|
|
37
|
+
return { path: location.slice(0, hashIdx), anchor: location.slice(hashIdx + 1) || undefined };
|
|
38
|
+
}
|
|
39
|
+
function resolve(base, relative) {
|
|
40
|
+
try {
|
|
41
|
+
return new URL(relative, base).toString();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// MkDocs / Material for MkDocs: { docs: [ { location: "page/#anchor", title, text } ] }.
|
|
48
|
+
// Locations are relative to the site root (the index sits at <root>/search/search_index.json).
|
|
49
|
+
export function searchMkDocsIndex(body, indexUrl, query) {
|
|
50
|
+
const data = JSON.parse(body);
|
|
51
|
+
if (!Array.isArray(data.docs))
|
|
52
|
+
throw new Error('MkDocs index missing "docs" array');
|
|
53
|
+
const root = resolve(indexUrl, '../') ?? indexUrl;
|
|
54
|
+
const terms = queryTerms(query);
|
|
55
|
+
const results = [];
|
|
56
|
+
for (const doc of data.docs) {
|
|
57
|
+
if (!doc.location && doc.location !== '')
|
|
58
|
+
continue;
|
|
59
|
+
const { path, anchor } = splitAnchor(doc.location ?? '');
|
|
60
|
+
const url = resolve(root, path);
|
|
61
|
+
if (!url)
|
|
62
|
+
continue;
|
|
63
|
+
const score = scoreText(doc.title ?? '', doc.text ?? '', terms);
|
|
64
|
+
if (score <= 0)
|
|
65
|
+
continue;
|
|
66
|
+
results.push({
|
|
67
|
+
title: doc.title ?? path,
|
|
68
|
+
url,
|
|
69
|
+
anchor,
|
|
70
|
+
snippet: snippetFrom(doc.text ?? '', terms),
|
|
71
|
+
provider: 'mkdocs-local',
|
|
72
|
+
score,
|
|
73
|
+
indexUrl,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return results.sort((a, b) => b.score - a.score);
|
|
77
|
+
}
|
|
78
|
+
// Just the Docs: { "0": { title, content, url|relUrl }, "1": {...} } keyed by stringified ints.
|
|
79
|
+
export function searchJekyllIndex(body, indexUrl, query) {
|
|
80
|
+
const data = JSON.parse(body);
|
|
81
|
+
if (typeof data !== 'object' || data === null)
|
|
82
|
+
throw new Error('Jekyll index is not an object');
|
|
83
|
+
const terms = queryTerms(query);
|
|
84
|
+
const results = [];
|
|
85
|
+
for (const entry of Object.values(data)) {
|
|
86
|
+
const rel = entry.url ?? entry.relUrl;
|
|
87
|
+
if (!rel)
|
|
88
|
+
continue;
|
|
89
|
+
const { path, anchor } = splitAnchor(rel);
|
|
90
|
+
const url = resolve(indexUrl, path);
|
|
91
|
+
if (!url)
|
|
92
|
+
continue;
|
|
93
|
+
const score = scoreText(entry.title ?? '', entry.content ?? '', terms);
|
|
94
|
+
if (score <= 0)
|
|
95
|
+
continue;
|
|
96
|
+
results.push({
|
|
97
|
+
title: entry.title ?? path,
|
|
98
|
+
url,
|
|
99
|
+
anchor,
|
|
100
|
+
snippet: snippetFrom(entry.content ?? '', terms),
|
|
101
|
+
provider: 'jekyll-json',
|
|
102
|
+
score,
|
|
103
|
+
indexUrl,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return results.sort((a, b) => b.score - a.score);
|
|
107
|
+
}
|
|
108
|
+
// Extracts the object literal from `Search.setIndex({...})` as inert data and JSON.parses it —
|
|
109
|
+
// never eval. Anchors on the `setIndex(` call so leading comments/whitespace can't shift the slice;
|
|
110
|
+
// falls back to first-`{`/last-`}` for index variants that assign the object differently.
|
|
111
|
+
function parseSphinxObject(js) {
|
|
112
|
+
const call = js.indexOf('setIndex(');
|
|
113
|
+
const start = call !== -1 ? js.indexOf('{', call) : js.indexOf('{');
|
|
114
|
+
const end = js.lastIndexOf('}');
|
|
115
|
+
if (start === -1 || end <= start)
|
|
116
|
+
throw new Error('Sphinx index has no object literal');
|
|
117
|
+
return JSON.parse(js.slice(start, end + 1));
|
|
118
|
+
}
|
|
119
|
+
function collectDocIndices(map, terms) {
|
|
120
|
+
const hits = new Map();
|
|
121
|
+
if (!map)
|
|
122
|
+
return hits;
|
|
123
|
+
for (const [word, posting] of Object.entries(map)) {
|
|
124
|
+
const lower = word.toLowerCase();
|
|
125
|
+
if (!terms.some((t) => lower.includes(t)))
|
|
126
|
+
continue;
|
|
127
|
+
const docs = Array.isArray(posting) ? posting : [posting];
|
|
128
|
+
for (const d of docs)
|
|
129
|
+
hits.set(d, (hits.get(d) ?? 0) + 1);
|
|
130
|
+
}
|
|
131
|
+
return hits;
|
|
132
|
+
}
|
|
133
|
+
// Scores each Sphinx doc index by term hits: title-term hits and exact title matches weigh 10×,
|
|
134
|
+
// body-term hits 1×. Kept separate so the search function stays a flat parse → score → format.
|
|
135
|
+
function scoreSphinxDocs(index, terms, titles) {
|
|
136
|
+
const scores = new Map();
|
|
137
|
+
for (const [doc, count] of collectDocIndices(index.titleterms, terms)) {
|
|
138
|
+
scores.set(doc, (scores.get(doc) ?? 0) + count * 10);
|
|
139
|
+
}
|
|
140
|
+
for (const [doc, count] of collectDocIndices(index.terms, terms)) {
|
|
141
|
+
scores.set(doc, (scores.get(doc) ?? 0) + count);
|
|
142
|
+
}
|
|
143
|
+
for (let i = 0; i < titles.length; i++) {
|
|
144
|
+
if (terms.some((t) => (titles[i] ?? '').toLowerCase().includes(t))) {
|
|
145
|
+
scores.set(i, (scores.get(i) ?? 0) + 10);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return scores;
|
|
149
|
+
}
|
|
150
|
+
// Sphinx searchindex.js. Page URLs are docname + ".html" relative to the index's directory.
|
|
151
|
+
export function searchSphinxIndex(js, indexUrl, query) {
|
|
152
|
+
const index = parseSphinxObject(js);
|
|
153
|
+
const docnames = index.docnames ?? [];
|
|
154
|
+
const titles = index.titles ?? [];
|
|
155
|
+
if (docnames.length === 0)
|
|
156
|
+
throw new Error('Sphinx index has no docnames');
|
|
157
|
+
const terms = queryTerms(query);
|
|
158
|
+
const scores = scoreSphinxDocs(index, terms, titles);
|
|
159
|
+
const results = [];
|
|
160
|
+
for (const [doc, score] of scores) {
|
|
161
|
+
const name = docnames[doc];
|
|
162
|
+
if (!name || score <= 0)
|
|
163
|
+
continue;
|
|
164
|
+
const url = resolve(indexUrl, `${name}.html`);
|
|
165
|
+
if (!url)
|
|
166
|
+
continue;
|
|
167
|
+
results.push({
|
|
168
|
+
title: titles[doc] ?? name,
|
|
169
|
+
url,
|
|
170
|
+
provider: 'sphinx-searchindex',
|
|
171
|
+
score,
|
|
172
|
+
indexUrl,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return results.sort((a, b) => b.score - a.score);
|
|
176
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ResearchArtifact } from '../schema.js';
|
|
2
|
+
/**
|
|
3
|
+
* Derives section child artifacts from a parent page artifact. Returns [] when the page is short
|
|
4
|
+
* or has too few headings to chunk.
|
|
5
|
+
*/
|
|
6
|
+
export declare function buildSectionArtifacts(parent: ResearchArtifact, currentTime: Date): ResearchArtifact[];
|
|
7
|
+
/**
|
|
8
|
+
* Regenerates a page's section artifacts: writes fresh children and archives any prior child whose
|
|
9
|
+
* heading no longer exists, so a revalidated parent never leaves orphaned sections active. Returns
|
|
10
|
+
* the number of active section children written.
|
|
11
|
+
*/
|
|
12
|
+
export declare function persistSectionArtifacts(dataDir: string, parent: ResearchArtifact, currentTime: Date): number;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { compressMarkdown } from '../compress.js';
|
|
4
|
+
import { estimateTokens } from '../token-estimate.js';
|
|
5
|
+
import { scanCacheDir, writeArtifact } from '../storage.js';
|
|
6
|
+
import { splitMarkdownSections } from './sections.js';
|
|
7
|
+
// Builds and persists section-level child artifacts for a long page (T-22). Children are keyed by
|
|
8
|
+
// sha256(parentKey#anchor) so the key stays hex (storage requires it), link back via
|
|
9
|
+
// parent_cache_key, and carry the parent's freshness policy. The page artifact is untouched and
|
|
10
|
+
// remains the provenance/revalidation anchor.
|
|
11
|
+
// Only split pages large enough to be awkward as one blob (~Node url.html territory).
|
|
12
|
+
const SECTION_TOKEN_THRESHOLD = 6000;
|
|
13
|
+
function childKey(parentKey, anchor) {
|
|
14
|
+
return createHash('sha256').update(`${parentKey}#${anchor}`).digest('hex');
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Derives section child artifacts from a parent page artifact. Returns [] when the page is short
|
|
18
|
+
* or has too few headings to chunk.
|
|
19
|
+
*/
|
|
20
|
+
export function buildSectionArtifacts(parent, currentTime) {
|
|
21
|
+
if (estimateTokens(parent.detailed) <= SECTION_TOKEN_THRESHOLD)
|
|
22
|
+
return [];
|
|
23
|
+
const sections = splitMarkdownSections(parent.detailed);
|
|
24
|
+
const pm = parent.metadata;
|
|
25
|
+
return sections.map((section) => {
|
|
26
|
+
const key = childKey(pm.cache_key, section.anchor);
|
|
27
|
+
const detailed = section.content;
|
|
28
|
+
const compressed = compressMarkdown(detailed);
|
|
29
|
+
return {
|
|
30
|
+
metadata: {
|
|
31
|
+
...pm,
|
|
32
|
+
artifact_type: 'section',
|
|
33
|
+
source_url: `${pm.source_url}#${section.anchor}`,
|
|
34
|
+
source_urls: [`${pm.source_url}#${section.anchor}`],
|
|
35
|
+
cache_key: key,
|
|
36
|
+
content_hash: createHash('sha256').update(detailed).digest('hex'),
|
|
37
|
+
token_estimate: {
|
|
38
|
+
compressed: estimateTokens(compressed),
|
|
39
|
+
detailed: estimateTokens(detailed),
|
|
40
|
+
},
|
|
41
|
+
parent_cache_key: pm.cache_key,
|
|
42
|
+
section_anchor: section.anchor,
|
|
43
|
+
section_heading_path: section.headingPath,
|
|
44
|
+
},
|
|
45
|
+
summary: section.headingPath,
|
|
46
|
+
compressed,
|
|
47
|
+
detailed,
|
|
48
|
+
provenance: `Section "${section.headingPath}" of ${pm.source_url} (parent ${pm.cache_key})`,
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
function activeChildrenOf(dataDir, parentKey) {
|
|
53
|
+
return scanCacheDir(join(dataDir, 'research'), (artifact) => artifact.metadata.parent_cache_key === parentKey && artifact.metadata.status === 'active'
|
|
54
|
+
? artifact
|
|
55
|
+
: null);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Regenerates a page's section artifacts: writes fresh children and archives any prior child whose
|
|
59
|
+
* heading no longer exists, so a revalidated parent never leaves orphaned sections active. Returns
|
|
60
|
+
* the number of active section children written.
|
|
61
|
+
*/
|
|
62
|
+
export function persistSectionArtifacts(dataDir, parent, currentTime) {
|
|
63
|
+
const children = buildSectionArtifacts(parent, currentTime);
|
|
64
|
+
const freshKeys = new Set(children.map((c) => c.metadata.cache_key));
|
|
65
|
+
for (const orphan of activeChildrenOf(dataDir, parent.metadata.cache_key)) {
|
|
66
|
+
if (!freshKeys.has(orphan.metadata.cache_key)) {
|
|
67
|
+
orphan.metadata.status = 'archived';
|
|
68
|
+
writeArtifact(dataDir, orphan.metadata.cache_key, orphan);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for (const child of children) {
|
|
72
|
+
writeArtifact(dataDir, child.metadata.cache_key, child);
|
|
73
|
+
}
|
|
74
|
+
return children.length;
|
|
75
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface SectionChunk {
|
|
2
|
+
anchor: string;
|
|
3
|
+
headingPath: string;
|
|
4
|
+
level: number;
|
|
5
|
+
content: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function slugifyHeading(text: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Splits Markdown into H2/H3 section chunks. Returns an empty array when the document has fewer
|
|
10
|
+
* than two such headings (nothing meaningful to chunk).
|
|
11
|
+
*/
|
|
12
|
+
export declare function splitMarkdownSections(markdown: string): SectionChunk[];
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Splits a long detailed-Markdown document into section chunks at H2/H3 boundaries (T-22), so a
|
|
2
|
+
// big API reference (e.g. Node `url.html`, ~17.5k tokens) becomes addressable section artifacts.
|
|
3
|
+
// Pure string work: no I/O, no artifact construction. H4+ headings stay within their parent chunk.
|
|
4
|
+
const HEADING_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
|
|
5
|
+
// GitHub-style anchor slug: lowercase, drop punctuation, spaces -> hyphens.
|
|
6
|
+
export function slugifyHeading(text) {
|
|
7
|
+
return text
|
|
8
|
+
.toLowerCase()
|
|
9
|
+
.replace(/`/g, '')
|
|
10
|
+
.replace(/[^\w\s-]/g, '')
|
|
11
|
+
.trim()
|
|
12
|
+
.replace(/\s+/g, '-')
|
|
13
|
+
.replace(/-+/g, '-');
|
|
14
|
+
}
|
|
15
|
+
function findHeadings(lines) {
|
|
16
|
+
const out = [];
|
|
17
|
+
let inFence = false;
|
|
18
|
+
for (let i = 0; i < lines.length; i++) {
|
|
19
|
+
if (/^\s*```/.test(lines[i]))
|
|
20
|
+
inFence = !inFence;
|
|
21
|
+
if (inFence)
|
|
22
|
+
continue;
|
|
23
|
+
const match = lines[i].match(HEADING_RE);
|
|
24
|
+
if (match)
|
|
25
|
+
out.push({ index: i, level: match[1].length, text: match[2].trim() });
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
// Builds the breadcrumb for a section from the nearest H1/H2 ancestors preceding it.
|
|
30
|
+
function breadcrumb(headings, current) {
|
|
31
|
+
const path = [];
|
|
32
|
+
const currentLevel = headings[current].level;
|
|
33
|
+
let needed = currentLevel - 1;
|
|
34
|
+
for (let i = current - 1; i >= 0 && needed >= 1; i--) {
|
|
35
|
+
if (headings[i].level === needed) {
|
|
36
|
+
path.unshift(headings[i].text);
|
|
37
|
+
needed--;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
path.push(headings[current].text);
|
|
41
|
+
return path.join(' > ');
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Splits Markdown into H2/H3 section chunks. Returns an empty array when the document has fewer
|
|
45
|
+
* than two such headings (nothing meaningful to chunk).
|
|
46
|
+
*/
|
|
47
|
+
export function splitMarkdownSections(markdown) {
|
|
48
|
+
const lines = markdown.split('\n');
|
|
49
|
+
const headings = findHeadings(lines);
|
|
50
|
+
const chunkStarts = headings.filter((h) => h.level === 2 || h.level === 3);
|
|
51
|
+
if (chunkStarts.length < 2)
|
|
52
|
+
return [];
|
|
53
|
+
const chunks = [];
|
|
54
|
+
for (let i = 0; i < chunkStarts.length; i++) {
|
|
55
|
+
const start = chunkStarts[i];
|
|
56
|
+
const end = chunkStarts[i + 1]?.index ?? lines.length;
|
|
57
|
+
const content = lines.slice(start.index, end).join('\n').trim();
|
|
58
|
+
const headingIdx = headings.indexOf(start);
|
|
59
|
+
chunks.push({
|
|
60
|
+
anchor: slugifyHeading(start.text),
|
|
61
|
+
headingPath: breadcrumb(headings, headingIdx),
|
|
62
|
+
level: start.level,
|
|
63
|
+
content,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return chunks;
|
|
67
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface GithubSource {
|
|
2
|
+
repository: string;
|
|
3
|
+
branch: string;
|
|
4
|
+
path: string;
|
|
5
|
+
rawUrl: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Converts a GitHub `edit`/`blob` URL into a raw.githubusercontent.com URL. Returns null for any
|
|
9
|
+
* URL that is not a recognized GitHub edit/blob link, so callers never fabricate a raw URL.
|
|
10
|
+
*/
|
|
11
|
+
export declare function parseGithubSourceLink(href: string): GithubSource | null;
|
|
12
|
+
export declare function mapNodeApiSource(url: string): GithubSource | null;
|
|
13
|
+
export declare function mapMdnSource(url: string): GithubSource | null;
|
|
14
|
+
export declare function vitepressRouteMarkdown(url: string): string | null;
|
|
15
|
+
/**
|
|
16
|
+
* Tries every known URL->source mapper in turn. Returns the first candidate, or null. The result
|
|
17
|
+
* is a CANDIDATE; it must be validated by fetching before being recorded as a verified source.
|
|
18
|
+
*/
|
|
19
|
+
export declare function mapUrlToSource(url: string): GithubSource | null;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Maps a rendered documentation page to its public Markdown/MDX source. Every mapper returns a
|
|
2
|
+
// candidate URL only; callers MUST validate it with a real fetch before trusting it (T-19). A
|
|
3
|
+
// repository link alone is never "supported source mapping". All inputs are untrusted.
|
|
4
|
+
const GITHUB_EDIT_BLOB = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/(?:edit|blob)\/([^/]+)\/(.+)$/i;
|
|
5
|
+
/**
|
|
6
|
+
* Converts a GitHub `edit`/`blob` URL into a raw.githubusercontent.com URL. Returns null for any
|
|
7
|
+
* URL that is not a recognized GitHub edit/blob link, so callers never fabricate a raw URL.
|
|
8
|
+
*/
|
|
9
|
+
export function parseGithubSourceLink(href) {
|
|
10
|
+
const match = href.match(GITHUB_EDIT_BLOB);
|
|
11
|
+
if (!match)
|
|
12
|
+
return null;
|
|
13
|
+
const [, owner, repo, branch, rawPath] = match;
|
|
14
|
+
// Strip query/hash; a source path never legitimately contains them.
|
|
15
|
+
const path = rawPath.split(/[?#]/)[0];
|
|
16
|
+
if (!path || path.includes('..'))
|
|
17
|
+
return null;
|
|
18
|
+
return {
|
|
19
|
+
repository: `${owner}/${repo}`,
|
|
20
|
+
branch: branch,
|
|
21
|
+
path,
|
|
22
|
+
rawUrl: `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
// Node.js API docs: https://nodejs.org/api/url.html -> nodejs/node/doc/api/url.md on the release
|
|
26
|
+
// line. Deterministic and fixture-proven; mapping is advertised only after a successful fetch.
|
|
27
|
+
const NODE_API = /^https?:\/\/nodejs\.org\/(?:[a-z-]+\/)?(?:docs\/)?(?:latest(?:-v\d+\.x)?\/)?api\/([a-z_]+)\.html/i;
|
|
28
|
+
export function mapNodeApiSource(url) {
|
|
29
|
+
const match = url.match(NODE_API);
|
|
30
|
+
if (!match)
|
|
31
|
+
return null;
|
|
32
|
+
const name = match[1];
|
|
33
|
+
const path = `doc/api/${name}.md`;
|
|
34
|
+
return {
|
|
35
|
+
repository: 'nodejs/node',
|
|
36
|
+
branch: 'main',
|
|
37
|
+
path,
|
|
38
|
+
rawUrl: `https://raw.githubusercontent.com/nodejs/node/main/${path}`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/... -> mdn/content
|
|
42
|
+
// files/en-us/web/javascript/.../index.md (content paths are lowercased).
|
|
43
|
+
const MDN_DOCS = /^https?:\/\/developer\.mozilla\.org\/([a-zA-Z-]+)\/docs\/(.+?)\/?$/;
|
|
44
|
+
export function mapMdnSource(url) {
|
|
45
|
+
const match = url.match(MDN_DOCS);
|
|
46
|
+
if (!match)
|
|
47
|
+
return null;
|
|
48
|
+
const locale = match[1].toLowerCase();
|
|
49
|
+
const slug = match[2].split(/[?#]/)[0].toLowerCase();
|
|
50
|
+
if (slug.includes('..'))
|
|
51
|
+
return null;
|
|
52
|
+
const path = `files/${locale}/${slug}/index.md`;
|
|
53
|
+
return {
|
|
54
|
+
repository: 'mdn/content',
|
|
55
|
+
branch: 'main',
|
|
56
|
+
path,
|
|
57
|
+
rawUrl: `https://raw.githubusercontent.com/mdn/content/main/${path}`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
// VitePress route Markdown: a docs page at /guide/intro.html (or /guide/intro) also serves
|
|
61
|
+
// /guide/intro.md. Returns the candidate `.md` route URL; validate before use (T-24/T-19).
|
|
62
|
+
export function vitepressRouteMarkdown(url) {
|
|
63
|
+
let parsed;
|
|
64
|
+
try {
|
|
65
|
+
parsed = new URL(url);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
let path = parsed.pathname;
|
|
71
|
+
if (path.endsWith('/'))
|
|
72
|
+
path += 'index';
|
|
73
|
+
path = path.replace(/\.html$/, '');
|
|
74
|
+
if (path.endsWith('.md'))
|
|
75
|
+
return null;
|
|
76
|
+
parsed.pathname = `${path}.md`;
|
|
77
|
+
parsed.search = '';
|
|
78
|
+
parsed.hash = '';
|
|
79
|
+
return parsed.toString();
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Tries every known URL->source mapper in turn. Returns the first candidate, or null. The result
|
|
83
|
+
* is a CANDIDATE; it must be validated by fetching before being recorded as a verified source.
|
|
84
|
+
*/
|
|
85
|
+
export function mapUrlToSource(url) {
|
|
86
|
+
return mapNodeApiSource(url) ?? mapMdnSource(url);
|
|
87
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface ArtifactValidation {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
reason?: string;
|
|
4
|
+
}
|
|
5
|
+
/** True when short text reads like an error/soft-404 page rather than real content. */
|
|
6
|
+
export declare function looksLikeErrorPage(text: string): boolean;
|
|
7
|
+
/** Same docs origin guard: a probed artifact must live on the same hostname as the page. */
|
|
8
|
+
export declare function isSameDocsOrigin(candidateUrl: string, baseUrl: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Validates that an `llms.txt` or route-Markdown body is usable: non-empty, not an HTML page, and
|
|
11
|
+
* not a soft error page. HTML returned for a `.md`/`.txt` probe is the most common false positive.
|
|
12
|
+
*/
|
|
13
|
+
export declare function validateTextArtifact(body: string): ArtifactValidation;
|
|
14
|
+
/** Validates a JSON search-index body parses and is a non-empty object/array. */
|
|
15
|
+
export declare function validateJsonArtifact(body: string): ArtifactValidation;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { looksLikeHtml } from '../fetcher.js';
|
|
2
|
+
const ERROR_PAGE_PATTERNS = [
|
|
3
|
+
/\b404\b/,
|
|
4
|
+
/page\s*(?:not\s*found|doesn'?t\s*exist|can'?t\s*be\s*found)/i,
|
|
5
|
+
/does(?:n'?t| not)\s*exist/i,
|
|
6
|
+
/\bnot\s*found\b/i,
|
|
7
|
+
/there\s*isn'?t\s*a\s*github\s*pages\s*site\s*here/i,
|
|
8
|
+
/site\s*not\s*found/i,
|
|
9
|
+
];
|
|
10
|
+
/** True when short text reads like an error/soft-404 page rather than real content. */
|
|
11
|
+
export function looksLikeErrorPage(text) {
|
|
12
|
+
const trimmed = text.trim();
|
|
13
|
+
// Only treat short bodies as errors; a long article mentioning "404" once is fine.
|
|
14
|
+
if (trimmed.length > 1500)
|
|
15
|
+
return false;
|
|
16
|
+
return ERROR_PAGE_PATTERNS.some((re) => re.test(trimmed));
|
|
17
|
+
}
|
|
18
|
+
/** Same docs origin guard: a probed artifact must live on the same hostname as the page. */
|
|
19
|
+
export function isSameDocsOrigin(candidateUrl, baseUrl) {
|
|
20
|
+
try {
|
|
21
|
+
return new URL(candidateUrl).hostname === new URL(baseUrl).hostname;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const MIN_BODY_CHARS = 10;
|
|
28
|
+
/**
|
|
29
|
+
* Validates that an `llms.txt` or route-Markdown body is usable: non-empty, not an HTML page, and
|
|
30
|
+
* not a soft error page. HTML returned for a `.md`/`.txt` probe is the most common false positive.
|
|
31
|
+
*/
|
|
32
|
+
export function validateTextArtifact(body) {
|
|
33
|
+
const trimmed = body.trim();
|
|
34
|
+
if (trimmed.length < MIN_BODY_CHARS)
|
|
35
|
+
return { ok: false, reason: 'empty or near-empty body' };
|
|
36
|
+
if (looksLikeHtml(trimmed))
|
|
37
|
+
return { ok: false, reason: 'body is HTML, not text/markdown' };
|
|
38
|
+
if (looksLikeErrorPage(trimmed))
|
|
39
|
+
return { ok: false, reason: 'body looks like an error page' };
|
|
40
|
+
return { ok: true };
|
|
41
|
+
}
|
|
42
|
+
/** Validates a JSON search-index body parses and is a non-empty object/array. */
|
|
43
|
+
export function validateJsonArtifact(body) {
|
|
44
|
+
const trimmed = body.trim();
|
|
45
|
+
if (trimmed.length < MIN_BODY_CHARS)
|
|
46
|
+
return { ok: false, reason: 'empty body' };
|
|
47
|
+
if (looksLikeHtml(trimmed))
|
|
48
|
+
return { ok: false, reason: 'body is HTML, not JSON' };
|
|
49
|
+
let parsed;
|
|
50
|
+
try {
|
|
51
|
+
parsed = JSON.parse(trimmed);
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
return { ok: false, reason: `invalid JSON: ${err.message}` };
|
|
55
|
+
}
|
|
56
|
+
if (parsed === null || typeof parsed !== 'object') {
|
|
57
|
+
return { ok: false, reason: 'JSON is not an object or array' };
|
|
58
|
+
}
|
|
59
|
+
if (Array.isArray(parsed) ? parsed.length === 0 : Object.keys(parsed).length === 0) {
|
|
60
|
+
return { ok: false, reason: 'JSON is empty' };
|
|
61
|
+
}
|
|
62
|
+
return { ok: true };
|
|
63
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ExtractionResult {
|
|
2
|
+
title: string;
|
|
3
|
+
detailedMarkdown: string;
|
|
4
|
+
confidence: 'high' | 'medium' | 'low';
|
|
5
|
+
qualityNotes: string[];
|
|
6
|
+
isIndexHub?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Extracts readerable content from an HTML document, sanitizes it, and converts it to detailed Markdown.
|
|
10
|
+
*/
|
|
11
|
+
export declare function extractHtmlContent(html: string, finalUrl: string): ExtractionResult;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { parseHTML } from 'linkedom';
|
|
2
|
+
import { Readability } from '@mozilla/readability';
|
|
3
|
+
import { htmlToMarkdown } from './markdown.js';
|
|
4
|
+
import { normalizeCodeBlocks } from './docs/code-blocks.js';
|
|
5
|
+
import { cleanDocsChrome } from './docs/clean-dom.js';
|
|
6
|
+
import { analyzeMarkdownQuality } from './docs/quality-gates.js';
|
|
7
|
+
function resolveRelativeLinks(document, finalUrl) {
|
|
8
|
+
const base = new URL(finalUrl);
|
|
9
|
+
const links = document.querySelectorAll('a');
|
|
10
|
+
for (const link of links) {
|
|
11
|
+
const href = link.getAttribute('href');
|
|
12
|
+
if (!href)
|
|
13
|
+
continue;
|
|
14
|
+
try {
|
|
15
|
+
const resolved = new URL(href, base).toString();
|
|
16
|
+
if (resolved.startsWith('javascript:')) {
|
|
17
|
+
link.removeAttribute('href');
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
link.setAttribute('href', resolved);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Ignore resolution errors for invalid link formats
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function cleanUnsafeElements(document) {
|
|
29
|
+
const scripts = document.querySelectorAll('script, style, iframe, noscript');
|
|
30
|
+
for (const script of scripts) {
|
|
31
|
+
script.remove();
|
|
32
|
+
}
|
|
33
|
+
const allElements = document.querySelectorAll('*');
|
|
34
|
+
for (const el of allElements) {
|
|
35
|
+
for (const attr of Array.from(el.attributes)) {
|
|
36
|
+
if (attr.name.startsWith('on')) {
|
|
37
|
+
el.removeAttribute(attr.name);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function determineConfidence(textLength) {
|
|
43
|
+
const notes = ['readability extracted main article'];
|
|
44
|
+
let confidence = 'high';
|
|
45
|
+
if (textLength < 500) {
|
|
46
|
+
confidence = 'low';
|
|
47
|
+
notes.push('warning: extracted content is very short (less than 500 characters)');
|
|
48
|
+
}
|
|
49
|
+
else if (textLength < 2000) {
|
|
50
|
+
confidence = 'medium';
|
|
51
|
+
}
|
|
52
|
+
return { confidence, notes };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Extracts readerable content from an HTML document, sanitizes it, and converts it to detailed Markdown.
|
|
56
|
+
*/
|
|
57
|
+
export function extractHtmlContent(html, finalUrl) {
|
|
58
|
+
const { document } = parseHTML(html);
|
|
59
|
+
resolveRelativeLinks(document, finalUrl);
|
|
60
|
+
cleanUnsafeElements(document);
|
|
61
|
+
// Strip decorative docs chrome and base64 images, then rebuild highlighted code blocks, before
|
|
62
|
+
// Readability runs so per-line spans don't collapse (T-21, T-17).
|
|
63
|
+
cleanDocsChrome(document);
|
|
64
|
+
normalizeCodeBlocks(document);
|
|
65
|
+
// keepClasses preserves the `language-x` class on <code> so Turndown emits a fenced language tag.
|
|
66
|
+
const reader = new Readability(document, { keepClasses: true });
|
|
67
|
+
const article = reader.parse();
|
|
68
|
+
if (!article || !article.content) {
|
|
69
|
+
throw new Error('Content extraction failed: The target page could not be parsed by the readability engine. ' +
|
|
70
|
+
'Hint: The "import" command lets you import pre-cleaned, agent-supplied research notes directly.');
|
|
71
|
+
}
|
|
72
|
+
const detailedMarkdown = htmlToMarkdown(article.content);
|
|
73
|
+
const textLength = article.textContent ? article.textContent.trim().length : 0;
|
|
74
|
+
const { confidence, notes } = determineConfidence(textLength);
|
|
75
|
+
const quality = analyzeMarkdownQuality(detailedMarkdown, article.title || '');
|
|
76
|
+
return {
|
|
77
|
+
title: article.title || 'Untitled',
|
|
78
|
+
detailedMarkdown,
|
|
79
|
+
confidence,
|
|
80
|
+
qualityNotes: [...notes, ...quality.codes],
|
|
81
|
+
isIndexHub: quality.isIndexHub,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface FetchOptions {
|
|
2
|
+
timeoutMs?: number;
|
|
3
|
+
bodyLimitBytes?: number;
|
|
4
|
+
maxRedirects?: number;
|
|
5
|
+
headers?: Record<string, string>;
|
|
6
|
+
}
|
|
7
|
+
export interface FetchResult {
|
|
8
|
+
status: number;
|
|
9
|
+
contentType: string | null;
|
|
10
|
+
etag: string | null;
|
|
11
|
+
lastModified: string | null;
|
|
12
|
+
finalUrl: string;
|
|
13
|
+
responseSize: number;
|
|
14
|
+
content: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function looksLikeHtml(text: string): boolean;
|
|
17
|
+
export declare function checkDnsSafety(hostname: string): Promise<void>;
|
|
18
|
+
export declare function fetchStaticHtml(url: string, options?: FetchOptions): Promise<FetchResult>;
|
|
19
|
+
/**
|
|
20
|
+
* Fetches a non-HTML text/data resource (llms.txt, route Markdown, search index) with the same
|
|
21
|
+
* DNS, redirect, timeout, and body-size safety as fetchStaticHtml. Does not enforce a content
|
|
22
|
+
* type; the caller must validate the returned body is the artifact kind it expected.
|
|
23
|
+
*/
|
|
24
|
+
export declare function fetchText(url: string, options?: FetchOptions): Promise<FetchResult>;
|
|
25
|
+
/**
|
|
26
|
+
* Sends a JSON POST and returns the response body text. Used by remote search connectors (e.g.
|
|
27
|
+
* Algolia DocSearch). Enforces the same DNS safety, timeout, and body-size limits as GET fetches.
|
|
28
|
+
*/
|
|
29
|
+
export declare function postJson(url: string, body: unknown, headers?: Record<string, string>, options?: FetchOptions): Promise<string>;
|