@taurgis/bonsai 1.0.2 → 1.0.4
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 +10 -4
- package/agents/instructions/web-research.instructions.md +21 -8
- package/dist/commands/fetch.js +17 -11
- package/dist/commands/import.js +13 -5
- package/dist/commands/search.js +10 -8
- package/dist/lib/config/index.d.ts +8 -3
- package/dist/lib/config/index.js +19 -4
- package/dist/lib/config/resolve.d.ts +14 -1
- package/dist/lib/config/resolve.js +19 -1
- package/dist/lib/config/schema.d.ts +3 -0
- package/dist/lib/config/schema.js +11 -1
- package/dist/lib/research/artifact.d.ts +13 -2
- package/dist/lib/research/artifact.js +27 -7
- package/dist/lib/research/compress.d.ts +19 -0
- package/dist/lib/research/compress.js +31 -0
- package/dist/lib/research/docs/code-blocks.js +24 -0
- package/dist/lib/research/docs/section-artifacts.d.ts +3 -2
- package/dist/lib/research/docs/section-artifacts.js +5 -5
- package/dist/lib/research/docs/validate.js +3 -0
- package/dist/lib/research/keywords.d.ts +17 -0
- package/dist/lib/research/keywords.js +220 -0
- package/dist/lib/research/revalidate.d.ts +3 -1
- package/dist/lib/research/revalidate.js +38 -4
- package/dist/lib/research/search-index.d.ts +18 -0
- package/dist/lib/research/search-index.js +109 -0
- package/dist/lib/research/storage.d.ts +1 -0
- package/dist/lib/research/storage.js +1 -1
- package/dist/lib/research/summarize.d.ts +17 -0
- package/dist/lib/research/summarize.js +400 -0
- package/oclif.manifest.json +1 -1
- package/package.json +2 -1
|
@@ -42,6 +42,12 @@ function reconstructLines(code) {
|
|
|
42
42
|
function removeCopyButtons(document) {
|
|
43
43
|
const candidates = document.querySelectorAll('button, [class*="copy" i], [class*="Copy"], [aria-label*="copy" i]');
|
|
44
44
|
for (const el of candidates) {
|
|
45
|
+
// Never remove a container that wraps a code block. Some highlighters put a copy-related class
|
|
46
|
+
// on the <pre>'s wrapper (e.g. Elementor's `<div class="copy-to-clipboard"><pre>…`); removing
|
|
47
|
+
// it would delete the entire code example, not just the button. Guard on <pre> specifically: a
|
|
48
|
+
// real copy button never contains a <pre>, but may contain a decorative <code> icon.
|
|
49
|
+
if (el.querySelector?.('pre'))
|
|
50
|
+
continue;
|
|
45
51
|
const cls = (el.getAttribute('class') || '').toString();
|
|
46
52
|
const aria = el.getAttribute('aria-label') || '';
|
|
47
53
|
const text = (el.textContent || '').trim();
|
|
@@ -50,6 +56,23 @@ function removeCopyButtons(document) {
|
|
|
50
56
|
}
|
|
51
57
|
}
|
|
52
58
|
}
|
|
59
|
+
// Some highlighters (e.g. Elementor's code-highlight widget) wrap the authored source in a
|
|
60
|
+
// deprecated <xmp>/<plaintext> element and pretty-print the markup, so <code> is not <pre>'s first
|
|
61
|
+
// child. Turndown's fenced-code rule requires <code> as the first child; without this the block
|
|
62
|
+
// collapses onto a single inline span. Flatten to a clean <pre><code>…</code></pre>.
|
|
63
|
+
function flattenCodeStructure(pre, code) {
|
|
64
|
+
if (code === pre)
|
|
65
|
+
return;
|
|
66
|
+
const rawHolder = code.querySelector?.('xmp, plaintext');
|
|
67
|
+
const raw = rawHolder?.textContent ?? '';
|
|
68
|
+
if (raw)
|
|
69
|
+
code.textContent = raw;
|
|
70
|
+
if (pre.firstChild !== code || pre.childNodes.length > 1) {
|
|
71
|
+
while (pre.firstChild)
|
|
72
|
+
pre.removeChild(pre.firstChild);
|
|
73
|
+
pre.appendChild(code);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
53
76
|
/**
|
|
54
77
|
* Reconstructs newline-delimited text for every highlighted code block in the document and tags it
|
|
55
78
|
* with a `language-x` class when one is detectable. Idempotent and a no-op on plain `<pre>` blocks.
|
|
@@ -65,5 +88,6 @@ export function normalizeCodeBlocks(document) {
|
|
|
65
88
|
const existing = (code.getAttribute('class') || '').replace(/language-[\w-]+/gi, '').trim();
|
|
66
89
|
code.setAttribute('class', `${existing} language-${language}`.trim());
|
|
67
90
|
}
|
|
91
|
+
flattenCodeStructure(pre, code);
|
|
68
92
|
}
|
|
69
93
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { ResearchArtifact } from '../schema.js';
|
|
2
|
+
import type { SummaryLevel } from '../../config/schema.js';
|
|
2
3
|
/**
|
|
3
4
|
* Derives section child artifacts from a parent page artifact. Returns [] when the page is short
|
|
4
5
|
* or has too few headings to chunk.
|
|
5
6
|
*/
|
|
6
|
-
export declare function buildSectionArtifacts(parent: ResearchArtifact, currentTime: Date): ResearchArtifact[];
|
|
7
|
+
export declare function buildSectionArtifacts(parent: ResearchArtifact, currentTime: Date, summaryLevel: SummaryLevel): ResearchArtifact[];
|
|
7
8
|
/**
|
|
8
9
|
* Regenerates a page's section artifacts: writes fresh children and archives any prior child whose
|
|
9
10
|
* heading no longer exists, so a revalidated parent never leaves orphaned sections active. Returns
|
|
10
11
|
* the number of active section children written.
|
|
11
12
|
*/
|
|
12
|
-
export declare function persistSectionArtifacts(dataDir: string, parent: ResearchArtifact, currentTime: Date): number;
|
|
13
|
+
export declare function persistSectionArtifacts(dataDir: string, parent: ResearchArtifact, currentTime: Date, summaryLevel: SummaryLevel): number;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { buildCompressed } from '../compress.js';
|
|
4
4
|
import { estimateTokens } from '../token-estimate.js';
|
|
5
5
|
import { scanCacheDir, writeArtifact } from '../storage.js';
|
|
6
6
|
import { splitMarkdownSections } from './sections.js';
|
|
@@ -17,7 +17,7 @@ function childKey(parentKey, anchor) {
|
|
|
17
17
|
* Derives section child artifacts from a parent page artifact. Returns [] when the page is short
|
|
18
18
|
* or has too few headings to chunk.
|
|
19
19
|
*/
|
|
20
|
-
export function buildSectionArtifacts(parent, currentTime) {
|
|
20
|
+
export function buildSectionArtifacts(parent, currentTime, summaryLevel) {
|
|
21
21
|
if (estimateTokens(parent.detailed) <= SECTION_TOKEN_THRESHOLD)
|
|
22
22
|
return [];
|
|
23
23
|
const sections = splitMarkdownSections(parent.detailed);
|
|
@@ -25,7 +25,7 @@ export function buildSectionArtifacts(parent, currentTime) {
|
|
|
25
25
|
return sections.map((section) => {
|
|
26
26
|
const key = childKey(pm.cache_key, section.anchor);
|
|
27
27
|
const detailed = section.content;
|
|
28
|
-
const compressed =
|
|
28
|
+
const compressed = buildCompressed(detailed, summaryLevel);
|
|
29
29
|
return {
|
|
30
30
|
metadata: {
|
|
31
31
|
...pm,
|
|
@@ -59,8 +59,8 @@ function activeChildrenOf(dataDir, parentKey) {
|
|
|
59
59
|
* heading no longer exists, so a revalidated parent never leaves orphaned sections active. Returns
|
|
60
60
|
* the number of active section children written.
|
|
61
61
|
*/
|
|
62
|
-
export function persistSectionArtifacts(dataDir, parent, currentTime) {
|
|
63
|
-
const children = buildSectionArtifacts(parent, currentTime);
|
|
62
|
+
export function persistSectionArtifacts(dataDir, parent, currentTime, summaryLevel) {
|
|
63
|
+
const children = buildSectionArtifacts(parent, currentTime, summaryLevel);
|
|
64
64
|
const freshKeys = new Set(children.map((c) => c.metadata.cache_key));
|
|
65
65
|
for (const orphan of activeChildrenOf(dataDir, parent.metadata.cache_key)) {
|
|
66
66
|
if (!freshKeys.has(orphan.metadata.cache_key)) {
|
|
@@ -6,6 +6,9 @@ const ERROR_PAGE_PATTERNS = [
|
|
|
6
6
|
/\bnot\s*found\b/i,
|
|
7
7
|
/there\s*isn'?t\s*a\s*github\s*pages\s*site\s*here/i,
|
|
8
8
|
/site\s*not\s*found/i,
|
|
9
|
+
// Client-rendered (SPA) failure shells that return HTTP 200 but render only an error notice.
|
|
10
|
+
/something\s+went\s+wrong/i,
|
|
11
|
+
/an?\s+error\s+occurred/i,
|
|
9
12
|
];
|
|
10
13
|
/** True when short text reads like an error/soft-404 page rather than real content. */
|
|
11
14
|
export function looksLikeErrorPage(text) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ResearchArtifact } from './schema.js';
|
|
2
|
+
export declare const STOPWORDS: Set<string>;
|
|
3
|
+
export declare function tokenize(text: string): string[];
|
|
4
|
+
/**
|
|
5
|
+
* Derives up to `max` keyword tags from free-form Markdown/text by term frequency.
|
|
6
|
+
* Code blocks, inline code, and URLs are stripped first; stopwords, pure numbers, and very short
|
|
7
|
+
* tokens are dropped; terms appearing in Markdown headings get a frequency boost. Ranking is
|
|
8
|
+
* deterministic (frequency desc, then alphabetical) so the same input always yields the same tags.
|
|
9
|
+
*/
|
|
10
|
+
export declare function extractKeywords(text: string, max?: number): string[];
|
|
11
|
+
/**
|
|
12
|
+
* Back-fills keyword tags for an artifact that has none, deriving them from its detailed content and
|
|
13
|
+
* recording a quality note. No-op when tags are already present, so user- or caller-supplied tags
|
|
14
|
+
* always win. Mutates and returns the same artifact. This is the single entry point every ingestion
|
|
15
|
+
* path (import, fetch, revalidation) uses, so the auto-tag policy lives in exactly one place.
|
|
16
|
+
*/
|
|
17
|
+
export declare function applyAutoTags(artifact: ResearchArtifact): ResearchArtifact;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Dependency-free keyword extraction used to auto-tag imported/fetched research when the caller
|
|
2
|
+
// supplies no tags. This is a deliberately simple term-frequency heuristic.
|
|
3
|
+
// ponytail: TF + stopwords + heading boost. Ceiling: no phrase/entity detection, English-only
|
|
4
|
+
// stopwords. Upgrade path: swap in RAKE/YAKE or an LLM pass behind the same extractKeywords() API.
|
|
5
|
+
const AUTO_TAG_NOTE = 'auto-generated tags via keyword extraction';
|
|
6
|
+
// Common English function words plus a few markdown/doc-noise words. Kept inline (no dependency)
|
|
7
|
+
// because the list is small and stable.
|
|
8
|
+
export const STOPWORDS = new Set([
|
|
9
|
+
'the',
|
|
10
|
+
'a',
|
|
11
|
+
'an',
|
|
12
|
+
'and',
|
|
13
|
+
'or',
|
|
14
|
+
'but',
|
|
15
|
+
'if',
|
|
16
|
+
'then',
|
|
17
|
+
'else',
|
|
18
|
+
'when',
|
|
19
|
+
'while',
|
|
20
|
+
'for',
|
|
21
|
+
'of',
|
|
22
|
+
'to',
|
|
23
|
+
'in',
|
|
24
|
+
'on',
|
|
25
|
+
'at',
|
|
26
|
+
'by',
|
|
27
|
+
'with',
|
|
28
|
+
'from',
|
|
29
|
+
'into',
|
|
30
|
+
'onto',
|
|
31
|
+
'as',
|
|
32
|
+
'is',
|
|
33
|
+
'are',
|
|
34
|
+
'was',
|
|
35
|
+
'were',
|
|
36
|
+
'be',
|
|
37
|
+
'been',
|
|
38
|
+
'being',
|
|
39
|
+
'am',
|
|
40
|
+
'do',
|
|
41
|
+
'does',
|
|
42
|
+
'did',
|
|
43
|
+
'doing',
|
|
44
|
+
'have',
|
|
45
|
+
'has',
|
|
46
|
+
'had',
|
|
47
|
+
'having',
|
|
48
|
+
'this',
|
|
49
|
+
'that',
|
|
50
|
+
'these',
|
|
51
|
+
'those',
|
|
52
|
+
'it',
|
|
53
|
+
'its',
|
|
54
|
+
'they',
|
|
55
|
+
'them',
|
|
56
|
+
'their',
|
|
57
|
+
'there',
|
|
58
|
+
'here',
|
|
59
|
+
'we',
|
|
60
|
+
'you',
|
|
61
|
+
'your',
|
|
62
|
+
'our',
|
|
63
|
+
'us',
|
|
64
|
+
'i',
|
|
65
|
+
'me',
|
|
66
|
+
'my',
|
|
67
|
+
'he',
|
|
68
|
+
'she',
|
|
69
|
+
'his',
|
|
70
|
+
'her',
|
|
71
|
+
'not',
|
|
72
|
+
'no',
|
|
73
|
+
'nor',
|
|
74
|
+
'so',
|
|
75
|
+
'than',
|
|
76
|
+
'too',
|
|
77
|
+
'very',
|
|
78
|
+
'can',
|
|
79
|
+
'will',
|
|
80
|
+
'just',
|
|
81
|
+
'should',
|
|
82
|
+
'would',
|
|
83
|
+
'could',
|
|
84
|
+
'may',
|
|
85
|
+
'might',
|
|
86
|
+
'must',
|
|
87
|
+
'shall',
|
|
88
|
+
'about',
|
|
89
|
+
'above',
|
|
90
|
+
'after',
|
|
91
|
+
'again',
|
|
92
|
+
'against',
|
|
93
|
+
'all',
|
|
94
|
+
'any',
|
|
95
|
+
'because',
|
|
96
|
+
'before',
|
|
97
|
+
'below',
|
|
98
|
+
'between',
|
|
99
|
+
'both',
|
|
100
|
+
'down',
|
|
101
|
+
'during',
|
|
102
|
+
'each',
|
|
103
|
+
'few',
|
|
104
|
+
'further',
|
|
105
|
+
'how',
|
|
106
|
+
'more',
|
|
107
|
+
'most',
|
|
108
|
+
'other',
|
|
109
|
+
'over',
|
|
110
|
+
'own',
|
|
111
|
+
'same',
|
|
112
|
+
'some',
|
|
113
|
+
'such',
|
|
114
|
+
'only',
|
|
115
|
+
'out',
|
|
116
|
+
'up',
|
|
117
|
+
'off',
|
|
118
|
+
'where',
|
|
119
|
+
'which',
|
|
120
|
+
'who',
|
|
121
|
+
'whom',
|
|
122
|
+
'why',
|
|
123
|
+
'what',
|
|
124
|
+
'whose',
|
|
125
|
+
'also',
|
|
126
|
+
'use',
|
|
127
|
+
'used',
|
|
128
|
+
'using',
|
|
129
|
+
'via',
|
|
130
|
+
'per',
|
|
131
|
+
'e.g',
|
|
132
|
+
'i.e',
|
|
133
|
+
'etc',
|
|
134
|
+
'get',
|
|
135
|
+
'set',
|
|
136
|
+
'see',
|
|
137
|
+
'note',
|
|
138
|
+
'example',
|
|
139
|
+
'one',
|
|
140
|
+
'two',
|
|
141
|
+
'three',
|
|
142
|
+
'new',
|
|
143
|
+
'like',
|
|
144
|
+
'within',
|
|
145
|
+
'without',
|
|
146
|
+
]);
|
|
147
|
+
// Splits text into candidate tokens, keeping tech-flavoured shapes intact (e.g. "node.js", "c#",
|
|
148
|
+
// "asp.net") while trimming leading/trailing separators left over from sentence punctuation.
|
|
149
|
+
export function tokenize(text) {
|
|
150
|
+
const raw = text.toLowerCase().match(/[a-z0-9][a-z0-9+#.-]*/g) ?? [];
|
|
151
|
+
return raw.map((w) => w.replace(/^[.\-]+|[.\-]+$/g, '')).filter(Boolean);
|
|
152
|
+
}
|
|
153
|
+
function cleanNoise(text) {
|
|
154
|
+
return text
|
|
155
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
156
|
+
.replace(/`[^`]*`/g, ' ')
|
|
157
|
+
.replace(/https?:\/\/\S+/g, ' ');
|
|
158
|
+
}
|
|
159
|
+
function getHeadingTerms(cleaned) {
|
|
160
|
+
const headingTerms = new Set();
|
|
161
|
+
for (const line of cleaned.split('\n')) {
|
|
162
|
+
if (/^\s{0,3}#{1,6}\s/.test(line)) {
|
|
163
|
+
for (const term of tokenize(line)) {
|
|
164
|
+
headingTerms.add(term);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return headingTerms;
|
|
169
|
+
}
|
|
170
|
+
function calculateFrequencies(cleaned, headingTerms) {
|
|
171
|
+
const counts = new Map();
|
|
172
|
+
for (const token of tokenize(cleaned)) {
|
|
173
|
+
if (token.length < 3 || STOPWORDS.has(token) || /^\d+$/.test(token)) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
counts.set(token, (counts.get(token) ?? 0) + 1);
|
|
177
|
+
}
|
|
178
|
+
for (const term of headingTerms) {
|
|
179
|
+
const val = counts.get(term);
|
|
180
|
+
if (val !== undefined) {
|
|
181
|
+
counts.set(term, val + 3);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return counts;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Derives up to `max` keyword tags from free-form Markdown/text by term frequency.
|
|
188
|
+
* Code blocks, inline code, and URLs are stripped first; stopwords, pure numbers, and very short
|
|
189
|
+
* tokens are dropped; terms appearing in Markdown headings get a frequency boost. Ranking is
|
|
190
|
+
* deterministic (frequency desc, then alphabetical) so the same input always yields the same tags.
|
|
191
|
+
*/
|
|
192
|
+
export function extractKeywords(text, max = 5) {
|
|
193
|
+
if (!text || !text.trim() || max <= 0)
|
|
194
|
+
return [];
|
|
195
|
+
const cleaned = cleanNoise(text);
|
|
196
|
+
const headingTerms = getHeadingTerms(cleaned);
|
|
197
|
+
const counts = calculateFrequencies(cleaned, headingTerms);
|
|
198
|
+
if (counts.size === 0)
|
|
199
|
+
return [];
|
|
200
|
+
return [...counts.entries()]
|
|
201
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
202
|
+
.slice(0, max)
|
|
203
|
+
.map(([term]) => term);
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Back-fills keyword tags for an artifact that has none, deriving them from its detailed content and
|
|
207
|
+
* recording a quality note. No-op when tags are already present, so user- or caller-supplied tags
|
|
208
|
+
* always win. Mutates and returns the same artifact. This is the single entry point every ingestion
|
|
209
|
+
* path (import, fetch, revalidation) uses, so the auto-tag policy lives in exactly one place.
|
|
210
|
+
*/
|
|
211
|
+
export function applyAutoTags(artifact) {
|
|
212
|
+
if (artifact.metadata.tags.length > 0)
|
|
213
|
+
return artifact;
|
|
214
|
+
const tags = extractKeywords(artifact.detailed);
|
|
215
|
+
if (tags.length === 0)
|
|
216
|
+
return artifact;
|
|
217
|
+
artifact.metadata.tags = tags;
|
|
218
|
+
artifact.metadata.quality_notes = [...artifact.metadata.quality_notes, AUTO_TAG_NOTE];
|
|
219
|
+
return artifact;
|
|
220
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ResearchArtifact } from './schema.js';
|
|
2
|
+
import type { SummaryLevel } from '../config/schema.js';
|
|
2
3
|
export interface RevalidationResult {
|
|
3
4
|
status: 'revalidated' | 'refreshed' | 'stale';
|
|
4
5
|
artifact: ResearchArtifact;
|
|
@@ -20,7 +21,7 @@ export declare function createArtifactFromFetch(url: string, normalizedUrl: stri
|
|
|
20
21
|
detailedMarkdown: string;
|
|
21
22
|
confidence: 'high' | 'medium' | 'low';
|
|
22
23
|
qualityNotes: string[];
|
|
23
|
-
}, tier: 'stable' | 'standard' | 'volatile', ttl: string | null, currentTime: Date): ResearchArtifact;
|
|
24
|
+
}, tier: 'stable' | 'standard' | 'volatile', ttl: string | null, currentTime: Date, summaryLevel: SummaryLevel): ResearchArtifact;
|
|
24
25
|
/**
|
|
25
26
|
* Revalidates a stale cache artifact using conditional request headers,
|
|
26
27
|
* falling back to stale serving within the grace period if the remote host is offline.
|
|
@@ -29,4 +30,5 @@ export declare function revalidateCache(dataDir: string, existing: ResearchArtif
|
|
|
29
30
|
allowStale: boolean;
|
|
30
31
|
ttlOverride?: string | null;
|
|
31
32
|
rendered?: boolean;
|
|
33
|
+
summaryLevel: SummaryLevel;
|
|
32
34
|
}): Promise<RevalidationResult>;
|
|
@@ -4,9 +4,11 @@ import { fetchStaticHtml } from './fetcher.js';
|
|
|
4
4
|
import { extractHtmlContent } from './extract.js';
|
|
5
5
|
import { writeArtifact } from './storage.js';
|
|
6
6
|
import { evaluateFreshness, getPolicy } from './freshness.js';
|
|
7
|
-
import {
|
|
7
|
+
import { buildCompressed } from './compress.js';
|
|
8
|
+
import { applyAutoTags } from './keywords.js';
|
|
8
9
|
import { estimateTokens } from './token-estimate.js';
|
|
9
10
|
import { fetchRenderedHtml } from './browser.js';
|
|
11
|
+
import { looksLikeErrorPage } from './docs/validate.js';
|
|
10
12
|
function buildMetadata(url, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl, currentTime, compressed) {
|
|
11
13
|
const contentHash = createHash('sha256').update(extraction.detailedMarkdown).digest('hex');
|
|
12
14
|
const staleAfterTime = new Date(currentTime);
|
|
@@ -51,11 +53,40 @@ function buildMetadata(url, normalizedUrl, cacheKey, fetchResult, extraction, ti
|
|
|
51
53
|
section_heading_path: null,
|
|
52
54
|
};
|
|
53
55
|
}
|
|
56
|
+
// Builds a compact error marker instead of caching a full error page. Subsequent lookups serve this
|
|
57
|
+
// tiny marker (a handful of tokens) instead of re-fetching, and revalidation re-checks it when stale
|
|
58
|
+
// so a transient failure recovers into a real artifact.
|
|
59
|
+
function buildErrorArtifact(url, normalizedUrl, cacheKey, fetchResult, tier, ttl, currentTime, reason) {
|
|
60
|
+
const marker = `Error: ${url} could not be cached — ${reason}. ` +
|
|
61
|
+
'The page returned an error, so its content was not stored; it will be re-fetched when this entry goes stale.';
|
|
62
|
+
const extraction = {
|
|
63
|
+
title: `Error: ${reason}`,
|
|
64
|
+
detailedMarkdown: marker,
|
|
65
|
+
confidence: 'low',
|
|
66
|
+
qualityNotes: [`error: ${reason}`],
|
|
67
|
+
};
|
|
68
|
+
const metadata = buildMetadata(url, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl, currentTime, marker);
|
|
69
|
+
metadata.extraction_status = 'failed';
|
|
70
|
+
metadata.extraction_confidence = null;
|
|
71
|
+
return {
|
|
72
|
+
metadata,
|
|
73
|
+
summary: extraction.title,
|
|
74
|
+
compressed: marker,
|
|
75
|
+
detailed: marker,
|
|
76
|
+
provenance: `Fetched from ${url} on ${currentTime.toISOString()} (error page; content not cached)`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
54
79
|
/**
|
|
55
80
|
* Helper to construct a ResearchArtifact from a fresh HTML fetch.
|
|
56
81
|
*/
|
|
57
|
-
export function createArtifactFromFetch(url, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl, currentTime) {
|
|
58
|
-
|
|
82
|
+
export function createArtifactFromFetch(url, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl, currentTime, summaryLevel) {
|
|
83
|
+
// A managed platform / SPA can return HTTP 200 but render only a "not found" / "something went
|
|
84
|
+
// wrong" shell. Cache a compact marker for those instead of the full error markdown, so repeat
|
|
85
|
+
// lookups cost a few tokens and revalidation still re-checks the page when the entry goes stale.
|
|
86
|
+
if (looksLikeErrorPage(extraction.detailedMarkdown)) {
|
|
87
|
+
return buildErrorArtifact(url, normalizedUrl, cacheKey, fetchResult, tier, ttl, currentTime, 'page reported an error or was not found');
|
|
88
|
+
}
|
|
89
|
+
const compressed = buildCompressed(extraction.detailedMarkdown, summaryLevel);
|
|
59
90
|
const metadata = buildMetadata(url, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl, currentTime, compressed);
|
|
60
91
|
return {
|
|
61
92
|
metadata,
|
|
@@ -81,8 +112,11 @@ function preserveUserMetadata(source, target, rendered) {
|
|
|
81
112
|
// returns the "refreshed" result. Shared by both refresh paths (conditional-request fallthrough
|
|
82
113
|
// and site-module re-fetch), which differ only in how fetchResult/extraction are obtained.
|
|
83
114
|
function persistRefreshedArtifact(dataDir, meta, fetchResult, extraction, currentTime, options) {
|
|
84
|
-
const refreshed = createArtifactFromFetch(meta.source_url, meta.normalized_url, meta.cache_key, fetchResult, extraction, meta.tier, options.ttlOverride || meta.ttl, currentTime);
|
|
115
|
+
const refreshed = createArtifactFromFetch(meta.source_url, meta.normalized_url, meta.cache_key, fetchResult, extraction, meta.tier, options.ttlOverride || meta.ttl, currentTime, options.summaryLevel);
|
|
85
116
|
preserveUserMetadata(meta, refreshed, options.rendered);
|
|
117
|
+
// Back-fill keyword tags when the carried-over set is empty (e.g. an artifact first cached before
|
|
118
|
+
// auto-tagging, or one originally stored without tags), so refreshing keeps it searchable.
|
|
119
|
+
applyAutoTags(refreshed);
|
|
86
120
|
writeArtifact(dataDir, meta.cache_key, refreshed);
|
|
87
121
|
return { status: 'refreshed', artifact: refreshed };
|
|
88
122
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ResearchArtifact } from './schema.js';
|
|
2
|
+
export interface SearchableArtifact {
|
|
3
|
+
artifact: ResearchArtifact;
|
|
4
|
+
filePath: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Returns the searchable view of every active artifact in one research dir, using the sidecar index
|
|
8
|
+
* to skip unchanged files. Re-reads only files whose signature changed, then persists the refreshed
|
|
9
|
+
* index (best-effort; a write failure never breaks search). Corrupt/unreadable files are skipped —
|
|
10
|
+
* the real read paths in storage.ts handle archiving them.
|
|
11
|
+
*/
|
|
12
|
+
export declare function loadSearchableArtifactsForDir(researchDir: string): SearchableArtifact[];
|
|
13
|
+
/**
|
|
14
|
+
* Multi-root variant: scans the `research/` dir of each data dir in order and deduplicates by cache
|
|
15
|
+
* key so a project entry shadows the same key in the global cache. Mirrors the dedup semantics of
|
|
16
|
+
* {@link scanCacheDirs} but reads through the per-root sidecar index.
|
|
17
|
+
*/
|
|
18
|
+
export declare function loadSearchableArtifacts(dataDirs: string[]): SearchableArtifact[];
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { atomicWriteFile } from '../atomic-write.js';
|
|
4
|
+
import { parseArtifactShallow } from './artifact.js';
|
|
5
|
+
import { isResearchFile } from './storage.js';
|
|
6
|
+
// Sidecar index that caches the lightweight (metadata + summary + compressed) view of each artifact
|
|
7
|
+
// so repeat searches avoid re-reading and re-parsing every file. It is a derived cache only: a stale
|
|
8
|
+
// or missing index never changes results, because each entry is validated against the file's current
|
|
9
|
+
// signature and re-read on any mismatch.
|
|
10
|
+
const INDEX_FILE = '.search-index.json';
|
|
11
|
+
const INDEX_VERSION = 1;
|
|
12
|
+
// The repo writes artifacts via temp-file + rename, so every rewrite yields a new inode. Combining
|
|
13
|
+
// mtime + size + ino makes any change a guaranteed cache miss even when filesystem mtime resolution
|
|
14
|
+
// is coarse (HFS+/FAT) and a same-size edit lands within the same timestamp tick.
|
|
15
|
+
function fileSignature(stat) {
|
|
16
|
+
return `${stat.mtimeMs}:${stat.size}:${stat.ino}`;
|
|
17
|
+
}
|
|
18
|
+
function loadIndex(indexPath) {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(readFileSync(indexPath, 'utf-8'));
|
|
21
|
+
if (parsed && parsed.version === INDEX_VERSION && parsed.entries) {
|
|
22
|
+
return parsed;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Missing or corrupt index: start fresh and rebuild.
|
|
27
|
+
}
|
|
28
|
+
return { version: INDEX_VERSION, entries: {} };
|
|
29
|
+
}
|
|
30
|
+
function getOrUpdateIndexEntry(filePath, cached, onChanged) {
|
|
31
|
+
let sig;
|
|
32
|
+
try {
|
|
33
|
+
sig = fileSignature(statSync(filePath));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
if (cached && cached.sig === sig) {
|
|
39
|
+
return cached;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const entry = { sig, artifact: parseArtifactShallow(readFileSync(filePath, 'utf-8')) };
|
|
43
|
+
onChanged();
|
|
44
|
+
return entry;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Returns the searchable view of every active artifact in one research dir, using the sidecar index
|
|
52
|
+
* to skip unchanged files. Re-reads only files whose signature changed, then persists the refreshed
|
|
53
|
+
* index (best-effort; a write failure never breaks search). Corrupt/unreadable files are skipped —
|
|
54
|
+
* the real read paths in storage.ts handle archiving them.
|
|
55
|
+
*/
|
|
56
|
+
export function loadSearchableArtifactsForDir(researchDir) {
|
|
57
|
+
if (!existsSync(researchDir))
|
|
58
|
+
return [];
|
|
59
|
+
const indexPath = join(researchDir, INDEX_FILE);
|
|
60
|
+
const previous = loadIndex(indexPath);
|
|
61
|
+
const entries = {};
|
|
62
|
+
const results = [];
|
|
63
|
+
let changed = false;
|
|
64
|
+
for (const file of readdirSync(researchDir)) {
|
|
65
|
+
if (!isResearchFile(file))
|
|
66
|
+
continue;
|
|
67
|
+
const filePath = join(researchDir, file);
|
|
68
|
+
const entry = getOrUpdateIndexEntry(filePath, previous.entries[file], () => {
|
|
69
|
+
changed = true;
|
|
70
|
+
});
|
|
71
|
+
if (entry) {
|
|
72
|
+
entries[file] = entry;
|
|
73
|
+
results.push({ artifact: entry.artifact, filePath });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Files removed since the last run leave dangling entries; drop them so the index can't grow
|
|
77
|
+
// without bound.
|
|
78
|
+
if (!changed) {
|
|
79
|
+
changed = Object.keys(previous.entries).some((key) => !(key in entries));
|
|
80
|
+
}
|
|
81
|
+
if (changed) {
|
|
82
|
+
try {
|
|
83
|
+
atomicWriteFile(indexPath, JSON.stringify({ version: INDEX_VERSION, entries }));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Best-effort cache write; search results are already computed without it.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return results;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Multi-root variant: scans the `research/` dir of each data dir in order and deduplicates by cache
|
|
93
|
+
* key so a project entry shadows the same key in the global cache. Mirrors the dedup semantics of
|
|
94
|
+
* {@link scanCacheDirs} but reads through the per-root sidecar index.
|
|
95
|
+
*/
|
|
96
|
+
export function loadSearchableArtifacts(dataDirs) {
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
const all = [];
|
|
99
|
+
for (const dataDir of dataDirs) {
|
|
100
|
+
for (const located of loadSearchableArtifactsForDir(join(dataDir, 'research'))) {
|
|
101
|
+
const key = located.artifact.metadata.cache_key;
|
|
102
|
+
if (seen.has(key))
|
|
103
|
+
continue;
|
|
104
|
+
seen.add(key);
|
|
105
|
+
all.push(located);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return all;
|
|
109
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ResearchArtifact } from './schema.js';
|
|
2
|
+
export declare function isResearchFile(file: string): boolean;
|
|
2
3
|
/**
|
|
3
4
|
* Scans a cache directory and maps each valid artifact file through a callback.
|
|
4
5
|
* Return null from the callback to skip an entry; all non-null results are collected.
|
|
@@ -2,7 +2,7 @@ import { join } from 'node:path';
|
|
|
2
2
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, renameSync, } from 'node:fs';
|
|
3
3
|
import { atomicWriteFile } from '../atomic-write.js';
|
|
4
4
|
import { parseArtifact, serializeArtifact } from './artifact.js';
|
|
5
|
-
function isResearchFile(file) {
|
|
5
|
+
export function isResearchFile(file) {
|
|
6
6
|
return (file.endsWith('.md') &&
|
|
7
7
|
!file.includes('.tmp') &&
|
|
8
8
|
!file.includes('.corrupt') &&
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SummaryLevel } from '../config/schema.js';
|
|
2
|
+
export type { SummaryLevel };
|
|
3
|
+
export interface SummarizeOptions {
|
|
4
|
+
/** Aggressiveness of prose reduction. Default 'conservative'. */
|
|
5
|
+
level?: SummaryLevel;
|
|
6
|
+
/** Below this token count the whole document is returned unchanged. Default 200. */
|
|
7
|
+
minTokens?: number;
|
|
8
|
+
/** Prose paragraphs with fewer sentences than this are kept whole. Default 2. */
|
|
9
|
+
minSentences?: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function splitSentences(paragraph: string): string[];
|
|
12
|
+
/**
|
|
13
|
+
* Extractively shortens the prose paragraphs of a Markdown document while preserving headings,
|
|
14
|
+
* fenced code blocks, tables, and lists verbatim. Returns the input unchanged when it is below
|
|
15
|
+
* `minTokens` or contains no prose to condense. Deterministic for identical input + options.
|
|
16
|
+
*/
|
|
17
|
+
export declare function summarizeMarkdown(markdown: string, options?: SummarizeOptions): string;
|