@v1nvn/readability 0.45.0 → 0.45.1
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 +3 -3
- package/package.json +4 -4
- package/src/cli.js +99 -0
- package/src/config.js +29 -0
- package/src/dev.js +55 -0
- package/src/errors.js +16 -0
- package/src/handler.js +4 -0
- package/src/host-sampling.js +57 -0
- package/src/index.js +23 -0
- package/src/index.ts +0 -0
- package/src/logger.js +2 -0
- package/src/output/format.js +92 -0
- package/src/pipeline/cell-text.js +51 -0
- package/src/pipeline/context.js +1 -0
- package/src/pipeline/dom.js +16 -0
- package/src/pipeline/footnotes.js +148 -0
- package/src/pipeline/math.js +113 -0
- package/src/pipeline/normalize.js +507 -0
- package/src/pipeline/readability.js +10 -0
- package/src/pipeline/resolver.js +113 -0
- package/src/pipeline/sanitize.js +26 -0
- package/src/pipeline/tables.js +239 -0
- package/src/pipeline/turndown.js +153 -0
- package/src/pipeline/urls.js +14 -0
- package/src/policy/chunk.js +365 -0
- package/src/policy/diagnostics.js +53 -0
- package/src/policy/explain.js +104 -0
- package/src/policy/fallback.js +68 -0
- package/src/policy/gating.js +90 -0
- package/src/policy/grid-detector.js +207 -0
- package/src/policy/identifiers.js +62 -0
- package/src/policy/images.js +49 -0
- package/src/policy/list-detector.js +232 -0
- package/src/policy/lost-signal.js +44 -0
- package/src/policy/markdown.js +58 -0
- package/src/policy/metadata.js +216 -0
- package/src/policy/outline-chains.js +165 -0
- package/src/policy/outline.js +71 -0
- package/src/policy/pagination.js +79 -0
- package/src/policy/presets.js +100 -0
- package/src/policy/section.js +111 -0
- package/src/policy/selector-lint.js +143 -0
- package/src/policy/sibling-scan.js +92 -0
- package/src/policy/text.js +18 -0
- package/src/policy/truncate.js +45 -0
- package/src/preset-cache.js +165 -0
- package/src/resources.js +227 -0
- package/src/sampling.js +45 -0
- package/src/server.js +73 -0
- package/src/tools/chunk_text.js +40 -0
- package/src/tools/explain.js +200 -0
- package/src/tools/extract.js +263 -0
- package/src/tools/extract_grid.js +56 -0
- package/src/tools/extract_links.js +98 -0
- package/src/tools/extract_list.js +66 -0
- package/src/tools/extract_metadata.js +67 -0
- package/src/tools/extract_section.js +56 -0
- package/src/tools/extract_tables.js +59 -0
- package/src/tools/html-source.js +30 -0
- package/src/tools/html_to_markdown.js +113 -0
- package/src/tools/outline.js +47 -0
- package/src/tools/output-schema.js +504 -0
- package/src/tools/schemas.js +288 -0
- package/src/tools/suggest-preset.js +504 -0
package/src/resources.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// In-memory cache of extract results keyed by a volatility-normalized hash of
|
|
2
|
+
// the HTML plus an args fingerprint, exposed to clients as addressable MCP
|
|
3
|
+
// Resources (`readability://page/{hash}`). Re-renders that differ only in
|
|
4
|
+
// nonce/CSP/generated ids collapse to the same key; the original hash rides
|
|
5
|
+
// alongside each entry so a should-have-hit-but-didn't miss points at a
|
|
6
|
+
// normalizer bug rather than a genuinely different page.
|
|
7
|
+
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { presetGeneration } from "./policy/presets.js";
|
|
10
|
+
const MAX_ENTRIES = 256;
|
|
11
|
+
const TTL_MS = 30 * 60 * 1000;
|
|
12
|
+
const RESOURCE_SCHEME = 'readability://page/';
|
|
13
|
+
const entries = new Map();
|
|
14
|
+
export function resetCache() {
|
|
15
|
+
entries.clear();
|
|
16
|
+
}
|
|
17
|
+
function evictExpired(now) {
|
|
18
|
+
for (const [key, entry] of entries) {
|
|
19
|
+
if (entry.expiresAt <= now) {
|
|
20
|
+
entries.delete(key);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// Bounded LRU: Map preserves insertion order, so re-insert on access to surface
|
|
25
|
+
// recently-used entries to the end and drop the oldest when over capacity.
|
|
26
|
+
function touch(key, entry) {
|
|
27
|
+
entries.delete(key);
|
|
28
|
+
entries.set(key, entry);
|
|
29
|
+
}
|
|
30
|
+
function pruneToMax() {
|
|
31
|
+
while (entries.size > MAX_ENTRIES) {
|
|
32
|
+
const oldest = entries.keys().next();
|
|
33
|
+
if (oldest.done) {
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
entries.delete(oldest.value);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// --- Hashing ---------------------------------------------------------------
|
|
40
|
+
// Conservative volatility normalization — over-stripping collides distinct
|
|
41
|
+
// pages. Each rule targets a specific known-volatile source.
|
|
42
|
+
function normalizeForHash(html) {
|
|
43
|
+
let s = html;
|
|
44
|
+
// Non-JSON-LD scripts carry CSP nonces, build hashes, A/B test buckets —
|
|
45
|
+
// same page, different bytes on every render — so strip them. Preserve
|
|
46
|
+
// <script type="application/ld+json">: structured metadata is content, so
|
|
47
|
+
// a changed datePublished must bust the cache. Mirrors the extraction
|
|
48
|
+
// normalizer in pipeline/normalize.ts.
|
|
49
|
+
s = s.replace(/<script\b(?![^>]*type\s*=\s*["']application\/ld\+json["'])[^>]*>[\s\S]*?<\/script>/gi, '');
|
|
50
|
+
s = s.replace(/<script\b(?![^>]*type\s*=\s*["']application\/ld\+json["'])[^>]*\/?>/gi, '');
|
|
51
|
+
// CSP <meta http-equiv="Content-Security-Policy"> carries the per-response
|
|
52
|
+
// nonce directive — strip the whole tag.
|
|
53
|
+
s = s.replace(/<meta\b[^>]*http-equiv=["']?content-security-policy["']?[^>]*>/gi, '');
|
|
54
|
+
// Per-render nonce attributes on any tag (script, link, style, …).
|
|
55
|
+
s = s.replace(/\snonce\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
|
|
56
|
+
// Build-tool generated attribute names: Vue `data-v-1a2b3c4d`,
|
|
57
|
+
// CSS-modules `data-css-1a2b3c4d`, Svelte `data-svelte-1a2b3c4d`. The
|
|
58
|
+
// value-part is optional — Vue's compiler emits the bare `data-v-<hash>`
|
|
59
|
+
// form with no `="..."`.
|
|
60
|
+
s = s.replace(/\sdata-(?:v|css|svelte|h)-[a-z0-9]{6,}(?:\s*=\s*("[^"]*"|'[^']*'|[^\s>]+))?/gi, '');
|
|
61
|
+
// React useId / RSC / Next.js generated ids: id=":R1:", ':r1:',
|
|
62
|
+
// __next_internal_action_…, react internal identifiers.
|
|
63
|
+
s = s.replace(/\sid\s*=\s*(":[A-Za-z0-9_-]+:"|':[A-Za-z0-9_-]+:'|__next_[A-Za-z0-9_-]+)/g, '');
|
|
64
|
+
s = s.replace(/\sid\s*=\s*("react[A-Z]_[A-Za-z0-9_]+"|'react[A-Z]_[A-Za-z0-9_]+')/gi, '');
|
|
65
|
+
// Whitespace runs and indentation differ by minifier/pretty-printer.
|
|
66
|
+
s = s.replace(/\s+/g, ' ');
|
|
67
|
+
return s.trim();
|
|
68
|
+
}
|
|
69
|
+
function sha256(text) {
|
|
70
|
+
return createHash('sha256').update(text).digest('hex');
|
|
71
|
+
}
|
|
72
|
+
export function normalizedHashOf(html) {
|
|
73
|
+
return sha256(normalizeForHash(html));
|
|
74
|
+
}
|
|
75
|
+
export function originalHashOf(html) {
|
|
76
|
+
return sha256(html.trim());
|
|
77
|
+
}
|
|
78
|
+
// Output-affecting options participate in the fingerprint so the same page with
|
|
79
|
+
// format:'html' vs format:'markdown' does not collide. `baseUrl` absolutizes
|
|
80
|
+
// links in the output, so it must participate too. The preset generation rides
|
|
81
|
+
// in the fingerprint rather than the resolved scope: an entry extracted before
|
|
82
|
+
// a preset landed must never be served after it, and hashing the scope would
|
|
83
|
+
// need a second document parse while conflating applied and missed runs under
|
|
84
|
+
// one key.
|
|
85
|
+
function buildArgsFingerprint(args) {
|
|
86
|
+
const sel = args.selectors
|
|
87
|
+
? {
|
|
88
|
+
exclude: args.selectors.exclude ?? [],
|
|
89
|
+
include: args.selectors.include ?? '',
|
|
90
|
+
}
|
|
91
|
+
: null;
|
|
92
|
+
const chunk = args.chunk
|
|
93
|
+
? {
|
|
94
|
+
maxTokens: args.chunk.maxTokens,
|
|
95
|
+
overlap: args.chunk.overlap,
|
|
96
|
+
strategy: args.chunk.strategy,
|
|
97
|
+
}
|
|
98
|
+
: null;
|
|
99
|
+
const fp = {
|
|
100
|
+
chunk,
|
|
101
|
+
cleanChrome: args.cleanChrome,
|
|
102
|
+
codeBlockStyle: args.codeBlockStyle,
|
|
103
|
+
debug: args.debug,
|
|
104
|
+
extraction: args.extraction,
|
|
105
|
+
format: args.format,
|
|
106
|
+
gfm: args.gfm,
|
|
107
|
+
headingStyle: args.headingStyle,
|
|
108
|
+
imageInventory: args.imageInventory,
|
|
109
|
+
images: args.images,
|
|
110
|
+
keepClasses: args.keepClasses,
|
|
111
|
+
maxChars: args.maxChars ?? null,
|
|
112
|
+
maxNodes: args.maxNodes ?? null,
|
|
113
|
+
metadataMode: args.metadataMode,
|
|
114
|
+
minArticleLength: args.minArticleLength ?? null,
|
|
115
|
+
presetGeneration: presetGeneration(),
|
|
116
|
+
readabilityOverrides: args.readabilityOverrides ?? null,
|
|
117
|
+
sanitize: args.sanitize,
|
|
118
|
+
selectors: sel,
|
|
119
|
+
tables: args.tables ?? null,
|
|
120
|
+
baseUrl: args.baseUrl ?? null,
|
|
121
|
+
wordsPerMinute: args.wordsPerMinute,
|
|
122
|
+
};
|
|
123
|
+
return JSON.stringify(fp);
|
|
124
|
+
}
|
|
125
|
+
function computeHashes(html) {
|
|
126
|
+
return {
|
|
127
|
+
normalizedHash: normalizedHashOf(html),
|
|
128
|
+
originalHash: originalHashOf(html),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function combineKey(normalizedHash, argsFingerprint) {
|
|
132
|
+
return sha256(`${normalizedHash}:${argsFingerprint}`);
|
|
133
|
+
}
|
|
134
|
+
// --- Store API -------------------------------------------------------------
|
|
135
|
+
export function lookup(html, args) {
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
evictExpired(now);
|
|
138
|
+
const { normalizedHash, originalHash } = computeHashes(html);
|
|
139
|
+
const cacheKey = combineKey(normalizedHash, buildArgsFingerprint(args));
|
|
140
|
+
const entry = entries.get(cacheKey);
|
|
141
|
+
if (!entry || entry.expiresAt <= now) {
|
|
142
|
+
if (entry) {
|
|
143
|
+
entries.delete(cacheKey);
|
|
144
|
+
}
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
touch(cacheKey, entry);
|
|
148
|
+
return { entry, normalizedHash, originalHash };
|
|
149
|
+
}
|
|
150
|
+
export function storeResult(html, args, result) {
|
|
151
|
+
const now = Date.now();
|
|
152
|
+
evictExpired(now);
|
|
153
|
+
const { normalizedHash, originalHash } = computeHashes(html);
|
|
154
|
+
const argsFingerprint = buildArgsFingerprint(args);
|
|
155
|
+
const cacheKey = combineKey(normalizedHash, argsFingerprint);
|
|
156
|
+
const entry = {
|
|
157
|
+
argsFingerprint,
|
|
158
|
+
cacheKey,
|
|
159
|
+
contentText: result.contentText,
|
|
160
|
+
expiresAt: now + TTL_MS,
|
|
161
|
+
normalizedHash,
|
|
162
|
+
originalHash,
|
|
163
|
+
structuredContent: result.structuredContent,
|
|
164
|
+
};
|
|
165
|
+
entries.set(cacheKey, entry);
|
|
166
|
+
pruneToMax();
|
|
167
|
+
return { normalizedHash, originalHash };
|
|
168
|
+
}
|
|
169
|
+
export function getEntryByHash(hash) {
|
|
170
|
+
for (const entry of entries.values()) {
|
|
171
|
+
if (entry.normalizedHash === hash || entry.cacheKey === hash) {
|
|
172
|
+
return entry;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
export function listEntries() {
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
evictExpired(now);
|
|
180
|
+
return [...entries.values()];
|
|
181
|
+
}
|
|
182
|
+
// --- Resource registration -------------------------------------------------
|
|
183
|
+
// "page-cache" names the registered template; the readability:// URI scheme
|
|
184
|
+
// identifies the cache namespace and {hash} is the combined cache key.
|
|
185
|
+
const PAGE_CACHE_TEMPLATE = new ResourceTemplate('readability://page/{hash}', {
|
|
186
|
+
// Dynamic set: the cache comes and goes, so we enumerate current entries.
|
|
187
|
+
list: () => ({
|
|
188
|
+
resources: listEntries().map(entry => ({
|
|
189
|
+
description: `Cached extract for normalized hash ${entry.normalizedHash.slice(0, 12)}…`,
|
|
190
|
+
mimeType: 'text/markdown',
|
|
191
|
+
name: entry.normalizedHash,
|
|
192
|
+
uri: `${RESOURCE_SCHEME}${entry.cacheKey}`,
|
|
193
|
+
})),
|
|
194
|
+
}),
|
|
195
|
+
});
|
|
196
|
+
export function registerCacheResources(server) {
|
|
197
|
+
return server.registerResource('page-cache', PAGE_CACHE_TEMPLATE, {
|
|
198
|
+
title: 'Cached page extractions',
|
|
199
|
+
description: 'Addressable cache of `extract` results called with cache:true. Each entry is keyed by the volatility-normalized hash of the HTML plus the output options; the URI path segment is the combined cache key.',
|
|
200
|
+
mimeType: 'text/markdown',
|
|
201
|
+
}, (uri, variables) => {
|
|
202
|
+
const hash = String(variables.hash);
|
|
203
|
+
const entry = getEntryByHash(hash);
|
|
204
|
+
if (!entry) {
|
|
205
|
+
return {
|
|
206
|
+
contents: [
|
|
207
|
+
{
|
|
208
|
+
uri: uri.href,
|
|
209
|
+
text: '',
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
contents: [
|
|
216
|
+
{
|
|
217
|
+
uri: uri.href,
|
|
218
|
+
mimeType: 'text/markdown',
|
|
219
|
+
text: entry.contentText,
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
};
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
export function registerResources(server) {
|
|
226
|
+
return [registerCacheResources(server)];
|
|
227
|
+
}
|
package/src/sampling.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Optional, capability-gated tools backed by the HOST's model. The sampling
|
|
2
|
+
// seam itself lives in host-sampling.ts; this file owns the family's tool
|
|
3
|
+
// registrations.
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { tool } from "./handler.js";
|
|
6
|
+
import { sampleText } from "./host-sampling.js";
|
|
7
|
+
import { registerSuggestPresetTool } from "./tools/suggest-preset.js";
|
|
8
|
+
const SUMMARIZE_SYSTEM_PROMPT = 'Summarize the user-supplied text concisely while preserving its key points, entities, and any decisive conclusions. Output only the summary prose — no preamble, no headings unless the source had them.';
|
|
9
|
+
const summarizeInputShape = {
|
|
10
|
+
text: z
|
|
11
|
+
.string()
|
|
12
|
+
.describe('The markdown or text to summarize — typically the output of `extract`, `extract_section`, `html_to_markdown`, or `chunk_text`. Passed through to the host model verbatim; the server does not parse or modify it.'),
|
|
13
|
+
maxTokens: z
|
|
14
|
+
.number()
|
|
15
|
+
.int()
|
|
16
|
+
.min(1)
|
|
17
|
+
.describe('Ceiling on tokens the host model may generate, forwarded as `sampling/createMessage` maxTokens. Thinking counts toward it on thinking models, so a low value truncates the summary; the system prompt already asks for a concise one.')
|
|
18
|
+
.default(4096),
|
|
19
|
+
};
|
|
20
|
+
const summarizeInputSchema = z.object(summarizeInputShape);
|
|
21
|
+
export const SUMMARIZE_TOOL_DESCRIPTION = `Summarize text using the HOST's model via MCP \`sampling/createMessage\` — the server embeds no model and calls no provider directly. Hand it the output of \`extract\`, \`extract_section\`, \`html_to_markdown\`, or any markdown/text string; the host picks the model and may ask the user to approve the sampling request (human-in-the-loop per MCP). The tool is only listed when the connected client advertises the sampling capability.`;
|
|
22
|
+
async function summarizeWithHost(server, args) {
|
|
23
|
+
return sampleText(server, {
|
|
24
|
+
maxTokens: args.maxTokens,
|
|
25
|
+
systemPrompt: SUMMARIZE_SYSTEM_PROMPT,
|
|
26
|
+
userText: args.text,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export function registerSummarizeTool(server) {
|
|
30
|
+
return server.registerTool('summarize', {
|
|
31
|
+
title: 'Summarize text using the host model',
|
|
32
|
+
description: SUMMARIZE_TOOL_DESCRIPTION,
|
|
33
|
+
inputSchema: summarizeInputShape,
|
|
34
|
+
}, tool('summarize', async (args) => {
|
|
35
|
+
const summary = await summarizeWithHost(server, args);
|
|
36
|
+
return {
|
|
37
|
+
content: [{ type: 'text', text: summary }],
|
|
38
|
+
};
|
|
39
|
+
}, summarizeInputSchema));
|
|
40
|
+
}
|
|
41
|
+
// Mirrors registerTools/registerResources so the dev reload
|
|
42
|
+
// loop and capability gate can treat sampling as one registration family.
|
|
43
|
+
export function registerSamplingTools(server) {
|
|
44
|
+
return [registerSummarizeTool(server), registerSuggestPresetTool(server)];
|
|
45
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { loadConfig } from "./config.js";
|
|
3
|
+
import { loadPresets } from "./preset-cache.js";
|
|
4
|
+
import { registerResources } from "./resources.js";
|
|
5
|
+
import { registerSamplingTools } from "./sampling.js";
|
|
6
|
+
import { registerChunkTextTool } from "./tools/chunk_text.js";
|
|
7
|
+
import { registerExplainTool } from "./tools/explain.js";
|
|
8
|
+
import { registerExtractGridTool } from "./tools/extract_grid.js";
|
|
9
|
+
import { registerExtractLinksTool } from "./tools/extract_links.js";
|
|
10
|
+
import { registerExtractListTool } from "./tools/extract_list.js";
|
|
11
|
+
import { registerExtractMetadataTool } from "./tools/extract_metadata.js";
|
|
12
|
+
import { registerExtractSectionTool } from "./tools/extract_section.js";
|
|
13
|
+
import { registerExtractTablesTool } from "./tools/extract_tables.js";
|
|
14
|
+
import { registerExtractTool } from "./tools/extract.js";
|
|
15
|
+
import { registerHtmlToMarkdownTool } from "./tools/html_to_markdown.js";
|
|
16
|
+
import { registerOutlineTool } from "./tools/outline.js";
|
|
17
|
+
// Re-exported so the dev hot-reload loop can import server.ts as a single
|
|
18
|
+
// RuntimeModule and pick up every registration family (tools/resources) plus
|
|
19
|
+
// the boot-time preset load.
|
|
20
|
+
export { loadPresets, registerResources };
|
|
21
|
+
export function createMcpServer() {
|
|
22
|
+
const { name, version, title, description, instructions } = loadConfig();
|
|
23
|
+
return new McpServer({ name, version, title, description }, { instructions });
|
|
24
|
+
}
|
|
25
|
+
// A tool earns its place only by skipping a pipeline stage (outline,
|
|
26
|
+
// extract_metadata skip Readability) or returning a fundamentally different
|
|
27
|
+
// shape (extract_list is a second engine). Alternate views of the one pipeline
|
|
28
|
+
// — tables, images, structured data, code — are options or fields on `extract`,
|
|
29
|
+
// not separate tools; that keeps the MCP surface small and the pipeline deep.
|
|
30
|
+
export function registerTools(server) {
|
|
31
|
+
return [
|
|
32
|
+
registerChunkTextTool(server),
|
|
33
|
+
registerExplainTool(server),
|
|
34
|
+
registerExtractGridTool(server),
|
|
35
|
+
registerExtractLinksTool(server),
|
|
36
|
+
registerExtractListTool(server),
|
|
37
|
+
registerExtractTool(server),
|
|
38
|
+
registerExtractMetadataTool(server),
|
|
39
|
+
registerExtractSectionTool(server),
|
|
40
|
+
registerExtractTablesTool(server),
|
|
41
|
+
registerHtmlToMarkdownTool(server),
|
|
42
|
+
registerOutlineTool(server),
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
// Capability-gated tools are registered AFTER the initialize handshake, not
|
|
46
|
+
// eagerly with the families above. The MCP `tools` capability locks in on the
|
|
47
|
+
// first pre-connect registration (registerCapabilities throws post-connect),
|
|
48
|
+
// so the tool-list handlers are already live; adding to `_registeredTools`
|
|
49
|
+
// later simply appears in the next `tools/list` and fires `listChanged`. The
|
|
50
|
+
// low-level Server populates `getClientCapabilities()` from the client's
|
|
51
|
+
// `initialize` request, exposed via `McpServer.server`.
|
|
52
|
+
export function registerCapabilityGatedTools(server) {
|
|
53
|
+
const caps = server.server.getClientCapabilities();
|
|
54
|
+
if (!caps?.sampling) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
return registerSamplingTools(server);
|
|
58
|
+
}
|
|
59
|
+
export function createServer() {
|
|
60
|
+
const server = createMcpServer();
|
|
61
|
+
loadPresets();
|
|
62
|
+
registerTools(server);
|
|
63
|
+
registerResources(server);
|
|
64
|
+
// Capability-gated tools (sampling) need the client's advertised
|
|
65
|
+
// capabilities, which the low-level Server only populates after the
|
|
66
|
+
// initialize handshake. Hook the `initialized` notification — it fires once
|
|
67
|
+
// the client has connected and before any `tools/list`. `dev.ts` re-runs the
|
|
68
|
+
// gate directly on reload (the client is already past `initialized`).
|
|
69
|
+
server.server.oninitialized = () => {
|
|
70
|
+
registerCapabilityGatedTools(server);
|
|
71
|
+
};
|
|
72
|
+
return server;
|
|
73
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { tool } from "../handler.js";
|
|
2
|
+
import { chunkMarkdown } from "../policy/chunk.js";
|
|
3
|
+
import { chunkTextOutputShape } from "./output-schema.js";
|
|
4
|
+
import { chunkTextInputSchema, chunkTextInputShape, } from "./schemas.js";
|
|
5
|
+
// One numbered section per chunk, heading context in brackets when present, so
|
|
6
|
+
// content[0].text is always scannable without unpacking structuredContent.
|
|
7
|
+
function renderChunkIndex(chunks) {
|
|
8
|
+
if (chunks.length === 0) {
|
|
9
|
+
return '(no chunks emitted — input had no non-whitespace content)';
|
|
10
|
+
}
|
|
11
|
+
return chunks
|
|
12
|
+
.map(chunk => {
|
|
13
|
+
const head = chunk.headingContext ? ` [${chunk.headingContext}]` : '';
|
|
14
|
+
return `## Chunk ${chunk.index}${head}\n\n${chunk.text}`;
|
|
15
|
+
})
|
|
16
|
+
.join('\n\n');
|
|
17
|
+
}
|
|
18
|
+
export function chunkTextDocument(args) {
|
|
19
|
+
const { text, maxTokens, overlap, strategy } = args;
|
|
20
|
+
const chunks = chunkMarkdown(text, { maxTokens, overlap, strategy });
|
|
21
|
+
const content = renderChunkIndex(chunks);
|
|
22
|
+
return {
|
|
23
|
+
content: [{ text: content, type: 'text' }],
|
|
24
|
+
structuredContent: {
|
|
25
|
+
schemaVersion: 1,
|
|
26
|
+
content,
|
|
27
|
+
chunks,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export const CHUNK_TEXT_TOOL_DESCRIPTION = `Split already-extracted text into token-bounded chunks for embedding/RAG. Each chunk carries its index, tokenCount (chars/4), and the nearest preceding markdown heading as headingContext. Operates on any text — pair with the \`chunk\` option on \`extract\` when you want chunks inline with the extraction. The server fetches nothing: \`text\` is the only input.`;
|
|
32
|
+
export const chunkTextHandler = tool('chunk_text', chunkTextDocument, chunkTextInputSchema);
|
|
33
|
+
export function registerChunkTextTool(server) {
|
|
34
|
+
return server.registerTool('chunk_text', {
|
|
35
|
+
title: 'Chunk text for RAG/embedding',
|
|
36
|
+
description: CHUNK_TEXT_TOOL_DESCRIPTION,
|
|
37
|
+
inputSchema: chunkTextInputShape,
|
|
38
|
+
outputSchema: chunkTextOutputShape,
|
|
39
|
+
}, chunkTextHandler);
|
|
40
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { tool } from "../handler.js";
|
|
3
|
+
import { buildExplainReport } from "../policy/explain.js";
|
|
4
|
+
import { fromLocalPath, withSchemaDefaults } from "./html-source.js";
|
|
5
|
+
import { localPathField, selectorsSchema, } from "./schemas.js";
|
|
6
|
+
// Schemas live with the tool rather than in schemas.ts/output-schema.ts to keep
|
|
7
|
+
// the explain surface in one file (the report shape is explain-specific and not
|
|
8
|
+
// shared with other tools). `selectorsSchema` is reused verbatim from extract so
|
|
9
|
+
// the two tools agree on the include/exclude contract.
|
|
10
|
+
const explainInputShape = {
|
|
11
|
+
localPath: localPathField,
|
|
12
|
+
baseUrl: z
|
|
13
|
+
.url()
|
|
14
|
+
.describe('Base URL for absolutizing relative links during pagination/gating detection. NEVER fetched — origin context only.')
|
|
15
|
+
.optional(),
|
|
16
|
+
selectors: selectorsSchema,
|
|
17
|
+
topN: z
|
|
18
|
+
.number()
|
|
19
|
+
.int()
|
|
20
|
+
.min(1)
|
|
21
|
+
.max(20)
|
|
22
|
+
.describe('Maximum number of scored candidate nodes to return (highest first). Default 5.')
|
|
23
|
+
.default(5),
|
|
24
|
+
};
|
|
25
|
+
const explainInputSchema = z.object(explainInputShape);
|
|
26
|
+
const candidateSchema = z
|
|
27
|
+
.object({
|
|
28
|
+
className: z
|
|
29
|
+
.string()
|
|
30
|
+
.describe("The candidate's class attribute (raw, unsplit). Empty string when absent."),
|
|
31
|
+
id: z.string().describe("The candidate's id attribute, or empty string."),
|
|
32
|
+
score: z
|
|
33
|
+
.number()
|
|
34
|
+
.describe("Readability's actual contentScore for this node (link-density-scaled). Higher is better; the top entry is Readability's raw top candidate before its parent-walking/only-child adjustments."),
|
|
35
|
+
selector: z
|
|
36
|
+
.string()
|
|
37
|
+
.describe("A CSS-ish hint (tag#id.class1.class2) for locating the node in the host DOM. NOT a unique locator — Readability's score lives on a JS expando invisible to CSS."),
|
|
38
|
+
tag: z
|
|
39
|
+
.string()
|
|
40
|
+
.describe('Uppercase DOM tag name (e.g. "ARTICLE", "MAIN", "DIV").'),
|
|
41
|
+
textLength: z
|
|
42
|
+
.number()
|
|
43
|
+
.int()
|
|
44
|
+
.min(0)
|
|
45
|
+
.describe('Trimmed textContent length of the candidate node, for eyeballing content density.'),
|
|
46
|
+
})
|
|
47
|
+
.describe('One scored candidate node Readability considered, with its real contentScore.');
|
|
48
|
+
const explainOutputShape = {
|
|
49
|
+
schemaVersion: z
|
|
50
|
+
.literal(1)
|
|
51
|
+
.describe('Structured-content schema version. Bumps only on breaking shape changes to this object.'),
|
|
52
|
+
content: z
|
|
53
|
+
.string()
|
|
54
|
+
.describe('Readable rendering of the report (chosen root, ranked candidates, removal counts, gating/pagination, snapshot head) so content[0].text is always scannable.'),
|
|
55
|
+
chosenRoot: candidateSchema
|
|
56
|
+
.nullable()
|
|
57
|
+
.describe('The highest-scoring candidate Readability computed — its raw top pick before parent-walking/only-child post-processing. Null when Readability scored nothing (e.g. empty input).'),
|
|
58
|
+
candidates: z
|
|
59
|
+
.array(candidateSchema)
|
|
60
|
+
.describe("Scored candidate nodes (highest first), capped at topN. These are Readability's real contentScore values, not a self-computed heuristic."),
|
|
61
|
+
readerable: z
|
|
62
|
+
.boolean()
|
|
63
|
+
.describe('Readability isProbablyReaderable verdict on the normalized document.'),
|
|
64
|
+
parseSucceeded: z
|
|
65
|
+
.boolean()
|
|
66
|
+
.describe('True when reader.parse() returned article content. False signals that `extract` would fall back to its selector cascade.'),
|
|
67
|
+
fallbackUsed: z
|
|
68
|
+
.boolean()
|
|
69
|
+
.describe('Always false for explain — this tool runs only the Readability path it is diagnosing, never the fallback cascade.'),
|
|
70
|
+
gating: z
|
|
71
|
+
.object({
|
|
72
|
+
likely: z
|
|
73
|
+
.boolean()
|
|
74
|
+
.describe('True when heuristics strongly suggest the content is paywalled or truncated.'),
|
|
75
|
+
reason: z
|
|
76
|
+
.string()
|
|
77
|
+
.describe('Short label naming the detected signal (e.g. "paywall overlay").'),
|
|
78
|
+
})
|
|
79
|
+
.nullable()
|
|
80
|
+
.describe('Likely paywall / gating signal detected before normalization. Null when none.'),
|
|
81
|
+
pagination: z
|
|
82
|
+
.object({
|
|
83
|
+
type: z
|
|
84
|
+
.enum(['infinite', 'paginated'])
|
|
85
|
+
.describe('Kind of pagination signal detected.'),
|
|
86
|
+
nextUrl: z
|
|
87
|
+
.string()
|
|
88
|
+
.optional()
|
|
89
|
+
.describe('Absolute URL of the detected next page (paginated only). Never fetched.'),
|
|
90
|
+
selector: z
|
|
91
|
+
.string()
|
|
92
|
+
.optional()
|
|
93
|
+
.describe('CSS selector of the load-more / infinite-scroll sentinel (infinite only).'),
|
|
94
|
+
})
|
|
95
|
+
.nullable()
|
|
96
|
+
.describe('Detected pagination / infinite-scroll signal. Null when none.'),
|
|
97
|
+
removedNodes: z
|
|
98
|
+
.object({
|
|
99
|
+
boilerplate: z
|
|
100
|
+
.number()
|
|
101
|
+
.int()
|
|
102
|
+
.min(0)
|
|
103
|
+
.describe('Boilerplate blocks (related-posts, newsletter signup) stripped before Readability.'),
|
|
104
|
+
chrome: z
|
|
105
|
+
.number()
|
|
106
|
+
.int()
|
|
107
|
+
.min(0)
|
|
108
|
+
.describe('Browser-chrome nodes stripped (scrollbars, consent banners, overlays).'),
|
|
109
|
+
total: z
|
|
110
|
+
.number()
|
|
111
|
+
.int()
|
|
112
|
+
.min(0)
|
|
113
|
+
.describe('Net element count removed across the whole pipeline (delta vs. the parsed document).'),
|
|
114
|
+
})
|
|
115
|
+
.describe('Breakdown of nodes removed before Readability saw the document, reused from the extract diagnostics path.'),
|
|
116
|
+
snapshot: z
|
|
117
|
+
.object({
|
|
118
|
+
html: z
|
|
119
|
+
.string()
|
|
120
|
+
.describe('The sanitized-by-normalization (post chrome/boilerplate/script strip) HTML fed to Readability — "what Readability saw". Not DOMPurify-sanitized (that runs on Readability\'s output in `extract`), so it may still carry inline event handlers (`onerror`/`onclick`/…); it is diagnostic data — do not render verbatim.'),
|
|
121
|
+
truncated: z
|
|
122
|
+
.boolean()
|
|
123
|
+
.describe('True when the snapshot was cut at snapshotMaxChars (default 4000).'),
|
|
124
|
+
})
|
|
125
|
+
.describe('Pre-Readability HTML snapshot of the normalized document body.'),
|
|
126
|
+
};
|
|
127
|
+
const explainOutput = z.object(explainOutputShape);
|
|
128
|
+
function formatGating(report) {
|
|
129
|
+
const g = report.gating;
|
|
130
|
+
if (!g) {
|
|
131
|
+
return 'none';
|
|
132
|
+
}
|
|
133
|
+
return `${g.reason}${g.likely ? '' : ' (weak)'}`;
|
|
134
|
+
}
|
|
135
|
+
function formatPagination(report) {
|
|
136
|
+
const p = report.pagination;
|
|
137
|
+
if (!p) {
|
|
138
|
+
return 'none';
|
|
139
|
+
}
|
|
140
|
+
if (p.type === 'paginated') {
|
|
141
|
+
return `paginated -> ${p.nextUrl ?? '(no href)'}`;
|
|
142
|
+
}
|
|
143
|
+
return `infinite (${p.selector ?? 'sentinel'})`;
|
|
144
|
+
}
|
|
145
|
+
function renderText(report) {
|
|
146
|
+
const lines = [];
|
|
147
|
+
const root = report.chosenRoot;
|
|
148
|
+
lines.push(`readerable: ${report.readerable ? 'yes' : 'no'} parse: ${report.parseSucceeded ? 'ok' : 'fail'} fallback: ${report.fallbackUsed ? 'yes' : 'no'}`);
|
|
149
|
+
lines.push(`chosen root: ${root ? `${root.selector} (score ${root.score.toFixed(2)}, ${root.textLength} chars)` : '(no candidate scored)'}`);
|
|
150
|
+
lines.push(`top candidates (${report.candidates.length}):`);
|
|
151
|
+
if (report.candidates.length === 0) {
|
|
152
|
+
lines.push(' (none)');
|
|
153
|
+
}
|
|
154
|
+
report.candidates.forEach((c, i) => {
|
|
155
|
+
lines.push(` ${i + 1}. ${c.selector} score ${c.score.toFixed(2)} (${c.textLength} chars)`);
|
|
156
|
+
});
|
|
157
|
+
const r = report.removedNodes;
|
|
158
|
+
lines.push(`removed: total=${r.total} (chrome=${r.chrome}, boilerplate=${r.boilerplate})`);
|
|
159
|
+
lines.push(`gating: ${formatGating(report)}`);
|
|
160
|
+
lines.push(`pagination: ${formatPagination(report)}`);
|
|
161
|
+
lines.push(`snapshot (${report.snapshot.html.length} chars${report.snapshot.truncated ? ', truncated' : ''}):`);
|
|
162
|
+
lines.push(report.snapshot.html);
|
|
163
|
+
return lines.join('\n');
|
|
164
|
+
}
|
|
165
|
+
export function explainFromHtml(input) {
|
|
166
|
+
const { html, baseUrl, selectors, topN } = withSchemaDefaults(explainInputSchema, input);
|
|
167
|
+
const report = buildExplainReport({
|
|
168
|
+
html,
|
|
169
|
+
selectors,
|
|
170
|
+
topN,
|
|
171
|
+
baseUrl,
|
|
172
|
+
});
|
|
173
|
+
const content = renderText(report);
|
|
174
|
+
return {
|
|
175
|
+
content: [{ text: content, type: 'text' }],
|
|
176
|
+
structuredContent: {
|
|
177
|
+
schemaVersion: 1,
|
|
178
|
+
content,
|
|
179
|
+
chosenRoot: report.chosenRoot,
|
|
180
|
+
candidates: report.candidates,
|
|
181
|
+
readerable: report.readerable,
|
|
182
|
+
parseSucceeded: report.parseSucceeded,
|
|
183
|
+
fallbackUsed: report.fallbackUsed,
|
|
184
|
+
gating: report.gating ?? null,
|
|
185
|
+
pagination: report.pagination ?? null,
|
|
186
|
+
removedNodes: report.removedNodes,
|
|
187
|
+
snapshot: report.snapshot,
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
export const EXPLAIN_TOOL_DESCRIPTION = `Post-mortem diagnostics for extraction: shows WHY Readability picked what it picked. Returns the chosen root, the ranked candidate nodes with their REAL Readability contentScore values (read off the DOM expando Readability stamps during scoring), a categorized removed-nodes breakdown, gating/pagination signals, and a snapshot of the normalized HTML fed to Readability. Runs the same normalize + Readability pipeline as \`extract\` (no fallback cascade, no Turndown). The server fetches nothing: \`localPath\` is the only source, and \`baseUrl\` (optional) is origin context only.`;
|
|
192
|
+
export const explainHandler = tool('explain', input => explainFromHtml(fromLocalPath(input)), explainInputSchema);
|
|
193
|
+
export function registerExplainTool(server) {
|
|
194
|
+
return server.registerTool('explain', {
|
|
195
|
+
title: 'Explain why Readability picked what it picked',
|
|
196
|
+
description: EXPLAIN_TOOL_DESCRIPTION,
|
|
197
|
+
inputSchema: explainInputShape,
|
|
198
|
+
outputSchema: explainOutput,
|
|
199
|
+
}, explainHandler);
|
|
200
|
+
}
|