@sylphx/lookout 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/bin/lookout +11 -0
- package/docs/BRAND_PUBLISH.md +36 -0
- package/docs/COMPETITIVE.md +27 -0
- package/docs/EVIDENCE_CONTRACT.md +21 -0
- package/docs/IPPB.md +16 -0
- package/docs/POSITIONING.md +42 -0
- package/docs/PRODUCT_INDEPENDENCE.md +13 -0
- package/docs/PUBLISH.md +23 -0
- package/docs/TOOL_SURFACE.md +19 -0
- package/package.json +58 -0
- package/server.json +22 -0
- package/src/cache.ts +129 -0
- package/src/cli.ts +106 -0
- package/src/crawl.ts +204 -0
- package/src/doctor.ts +73 -0
- package/src/engine.ts +427 -0
- package/src/extract.ts +274 -0
- package/src/fetch.ts +145 -0
- package/src/mcp.ts +118 -0
- package/src/research.ts +98 -0
- package/src/robots.ts +97 -0
- package/src/sdk.ts +59 -0
- package/src/search.ts +356 -0
- package/src/sitemap.ts +13 -0
- package/src/ssrf.ts +69 -0
package/src/crawl.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Depth-limited same-origin crawl — light advanced tool (not a full wigolo crawler).
|
|
3
|
+
*/
|
|
4
|
+
import { webFetch } from './fetch.ts';
|
|
5
|
+
import { assertSafeUrl } from './ssrf.ts';
|
|
6
|
+
import { decodeEntities } from './search.ts';
|
|
7
|
+
import { checkRobotsAllowed } from './robots.ts';
|
|
8
|
+
import { parseSitemapXml } from './sitemap.ts';
|
|
9
|
+
|
|
10
|
+
export type CrawlPage = {
|
|
11
|
+
url: string;
|
|
12
|
+
status?: number;
|
|
13
|
+
ok: boolean;
|
|
14
|
+
title?: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
textExcerpt?: string;
|
|
17
|
+
links: string[];
|
|
18
|
+
error?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type CrawlResult = {
|
|
22
|
+
ok: boolean;
|
|
23
|
+
seed: string;
|
|
24
|
+
maxDepth: number;
|
|
25
|
+
maxPages: number;
|
|
26
|
+
pages: CrawlPage[];
|
|
27
|
+
warnings: string[];
|
|
28
|
+
route: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function extractTitle(html: string): string | undefined {
|
|
32
|
+
const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
33
|
+
return m ? decodeEntities(m[1]?.replace(/<[^>]+>/g, '').trim() ?? '') : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function extractDescription(html: string): string | undefined {
|
|
37
|
+
const og = html.match(
|
|
38
|
+
/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["'][^>]*>/i,
|
|
39
|
+
) || html.match(
|
|
40
|
+
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:description["'][^>]*>/i,
|
|
41
|
+
);
|
|
42
|
+
if (og?.[1]) return decodeEntities(og[1].trim());
|
|
43
|
+
const md = html.match(
|
|
44
|
+
/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["'][^>]*>/i,
|
|
45
|
+
) || html.match(
|
|
46
|
+
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["'][^>]*>/i,
|
|
47
|
+
);
|
|
48
|
+
return md?.[1] ? decodeEntities(md[1].trim()) : undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function extractTextExcerpt(html: string, max = 400): string | undefined {
|
|
52
|
+
const body = html.replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ');
|
|
53
|
+
const text = decodeEntities(body.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim());
|
|
54
|
+
if (!text) return undefined;
|
|
55
|
+
return text.slice(0, max);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function extractLinks(html: string, base: string): string[] {
|
|
59
|
+
const out: string[] = [];
|
|
60
|
+
const re = /href=["']([^"'#]+)["']/gi;
|
|
61
|
+
let m: RegExpExecArray | null;
|
|
62
|
+
while ((m = re.exec(html)) !== null) {
|
|
63
|
+
try {
|
|
64
|
+
const abs = new URL(m[1] ?? '', base).toString();
|
|
65
|
+
out.push(abs.split('#')[0] ?? abs);
|
|
66
|
+
} catch {
|
|
67
|
+
// ignore
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return [...new Set(out)];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function webCrawl(
|
|
74
|
+
seed: string,
|
|
75
|
+
options: { maxDepth?: number; maxPages?: number; respectRobots?: boolean; useSitemap?: boolean } = {},
|
|
76
|
+
): Promise<CrawlResult> {
|
|
77
|
+
const maxDepth = Math.min(options.maxDepth ?? 1, 3);
|
|
78
|
+
const maxPages = Math.min(options.maxPages ?? 10, 25);
|
|
79
|
+
const warnings: string[] = [];
|
|
80
|
+
const seedCheck = assertSafeUrl(seed);
|
|
81
|
+
if (!seedCheck.ok) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
seed,
|
|
85
|
+
maxDepth,
|
|
86
|
+
maxPages,
|
|
87
|
+
pages: [],
|
|
88
|
+
warnings: [seedCheck.message],
|
|
89
|
+
route: 'crawl_light',
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const origin = seedCheck.url.origin;
|
|
93
|
+
const respectRobots = options.respectRobots !== false;
|
|
94
|
+
if (respectRobots) {
|
|
95
|
+
const robots = await checkRobotsAllowed(seedCheck.url.toString(), { respectRobots: true });
|
|
96
|
+
if (!robots.allowed) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
seed,
|
|
100
|
+
maxDepth: Math.min(options.maxDepth ?? 1, 3),
|
|
101
|
+
maxPages: Math.min(options.maxPages ?? 10, 25),
|
|
102
|
+
pages: [],
|
|
103
|
+
warnings: [robots.warning ?? 'robots.txt disallows seed'],
|
|
104
|
+
route: 'crawl_light',
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (robots.warning) {
|
|
108
|
+
// missing robots.txt is fail-open with warning recorded later if we had a warnings push before loop
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const queue: { url: string; depth: number }[] = [{ url: seedCheck.url.toString(), depth: 0 }];
|
|
112
|
+
const seen = new Set<string>();
|
|
113
|
+
const pages: CrawlPage[] = [];
|
|
114
|
+
|
|
115
|
+
if (options.useSitemap) {
|
|
116
|
+
const sitemapUrl = `${origin}/sitemap.xml`;
|
|
117
|
+
try {
|
|
118
|
+
const sm = await webFetch(sitemapUrl, { timeoutMs: 8_000, maxBytes: 500_000 });
|
|
119
|
+
if (sm.ok && sm.body) {
|
|
120
|
+
const locs = parseSitemapXml(sm.body, Math.max(0, maxPages - 1));
|
|
121
|
+
let added = 0;
|
|
122
|
+
for (const loc of locs) {
|
|
123
|
+
try {
|
|
124
|
+
const u = new URL(loc);
|
|
125
|
+
if (u.origin === origin) {
|
|
126
|
+
queue.push({ url: u.toString(), depth: 0 });
|
|
127
|
+
added += 1;
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
// ignore
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
warnings.push(`sitemap.xml seeded ${added} same-origin URLs`);
|
|
134
|
+
} else {
|
|
135
|
+
warnings.push(`sitemap.xml unavailable (${sm.message ?? sm.code ?? sm.status})`);
|
|
136
|
+
}
|
|
137
|
+
} catch (e) {
|
|
138
|
+
warnings.push(e instanceof Error ? e.message : 'sitemap_fetch_error');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
while (queue.length && pages.length < maxPages) {
|
|
143
|
+
const { url, depth } = queue.shift()!;
|
|
144
|
+
if (seen.has(url)) continue;
|
|
145
|
+
seen.add(url);
|
|
146
|
+
const safety = assertSafeUrl(url);
|
|
147
|
+
if (!safety.ok) {
|
|
148
|
+
pages.push({ url, ok: false, links: [], error: safety.message });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (safety.url.origin !== origin) {
|
|
152
|
+
warnings.push(`skipped off-origin ${url}`);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (respectRobots) {
|
|
156
|
+
const robots = await checkRobotsAllowed(url, { respectRobots: true });
|
|
157
|
+
if (!robots.allowed) {
|
|
158
|
+
pages.push({
|
|
159
|
+
url,
|
|
160
|
+
ok: false,
|
|
161
|
+
links: [],
|
|
162
|
+
error: robots.warning ?? 'robots.txt disallow',
|
|
163
|
+
});
|
|
164
|
+
warnings.push(robots.warning ?? `robots disallow ${url}`);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const fetched = await webFetch(url, { timeoutMs: 12_000, maxBytes: 800_000 });
|
|
169
|
+
const links = fetched.body ? extractLinks(fetched.body, fetched.finalUrl) : [];
|
|
170
|
+
pages.push({
|
|
171
|
+
url: fetched.finalUrl,
|
|
172
|
+
status: fetched.status,
|
|
173
|
+
ok: fetched.ok,
|
|
174
|
+
title: fetched.body ? extractTitle(fetched.body) : undefined,
|
|
175
|
+
description: fetched.body ? extractDescription(fetched.body) : undefined,
|
|
176
|
+
textExcerpt: fetched.body ? extractTextExcerpt(fetched.body) : undefined,
|
|
177
|
+
links: links.slice(0, 50),
|
|
178
|
+
error: fetched.ok ? undefined : fetched.message,
|
|
179
|
+
});
|
|
180
|
+
if (depth < maxDepth && fetched.ok) {
|
|
181
|
+
for (const link of links) {
|
|
182
|
+
if (pages.length + queue.length >= maxPages) break;
|
|
183
|
+
try {
|
|
184
|
+
const u = new URL(link);
|
|
185
|
+
if (u.origin === origin && !seen.has(u.toString())) {
|
|
186
|
+
queue.push({ url: u.toString(), depth: depth + 1 });
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
// ignore
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
ok: pages.some((p) => p.ok),
|
|
197
|
+
seed,
|
|
198
|
+
maxDepth,
|
|
199
|
+
maxPages,
|
|
200
|
+
pages,
|
|
201
|
+
warnings,
|
|
202
|
+
route: 'crawl_light',
|
|
203
|
+
};
|
|
204
|
+
}
|
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { ADVANCED_TOOLS, CORE_TOOLS, LookoutEngine } from './engine.ts';
|
|
3
|
+
import { defaultCacheDir } from './cache.ts';
|
|
4
|
+
import { assertSafeUrl } from './ssrf.ts';
|
|
5
|
+
|
|
6
|
+
export type DoctorReport = {
|
|
7
|
+
ok: boolean;
|
|
8
|
+
product: string;
|
|
9
|
+
version: string;
|
|
10
|
+
checks: { name: string; status: 'ok' | 'warn' | 'fail'; message: string }[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function runDoctor(version = '0.1.0'): DoctorReport {
|
|
14
|
+
const checks: DoctorReport['checks'] = [];
|
|
15
|
+
checks.push({
|
|
16
|
+
name: 'core_tools',
|
|
17
|
+
status: CORE_TOOLS.length === 4 ? 'ok' : 'fail',
|
|
18
|
+
message: `core: ${CORE_TOOLS.join(', ')}; advanced: ${ADVANCED_TOOLS.join(', ')}`,
|
|
19
|
+
});
|
|
20
|
+
const cacheDir = defaultCacheDir();
|
|
21
|
+
checks.push({
|
|
22
|
+
name: 'cache_dir',
|
|
23
|
+
status: 'ok',
|
|
24
|
+
message: `cache dir ${cacheDir} (created on first use)`,
|
|
25
|
+
});
|
|
26
|
+
const maxAge = process.env.LOOKOUT_CACHE_MAX_AGE_MS?.trim();
|
|
27
|
+
checks.push({
|
|
28
|
+
name: 'cache_max_age_env',
|
|
29
|
+
status: 'ok',
|
|
30
|
+
message: maxAge ? `LOOKOUT_CACHE_MAX_AGE_MS=${maxAge}` : 'LOOKOUT_CACHE_MAX_AGE_MS unset (cache entries not age-limited)',
|
|
31
|
+
});
|
|
32
|
+
const ua = process.env.LOOKOUT_USER_AGENT?.trim();
|
|
33
|
+
checks.push({
|
|
34
|
+
name: 'user_agent_env',
|
|
35
|
+
status: 'ok',
|
|
36
|
+
message: ua ? `LOOKOUT_USER_AGENT set (${ua.slice(0, 48)})` : 'LOOKOUT_USER_AGENT unset (default Lookout UA)',
|
|
37
|
+
});
|
|
38
|
+
const fetchTimeout = process.env.LOOKOUT_FETCH_TIMEOUT_MS?.trim();
|
|
39
|
+
const fetchMax = process.env.LOOKOUT_FETCH_MAX_BYTES?.trim();
|
|
40
|
+
checks.push({
|
|
41
|
+
name: 'fetch_limits_env',
|
|
42
|
+
status: 'ok',
|
|
43
|
+
message: `timeout=${fetchTimeout || 'default'} maxBytes=${fetchMax || 'default'}`,
|
|
44
|
+
});
|
|
45
|
+
const ssrf = assertSafeUrl('http://127.0.0.1/');
|
|
46
|
+
checks.push({
|
|
47
|
+
name: 'ssrf_blocks_loopback',
|
|
48
|
+
status: ssrf.ok ? 'fail' : 'ok',
|
|
49
|
+
message: ssrf.ok ? 'loopback incorrectly allowed' : 'loopback denied',
|
|
50
|
+
});
|
|
51
|
+
const eng = new LookoutEngine({ cacheDir: cacheDir });
|
|
52
|
+
checks.push({
|
|
53
|
+
name: 'engine_construct',
|
|
54
|
+
status: eng ? 'ok' : 'fail',
|
|
55
|
+
message: 'LookoutEngine ready',
|
|
56
|
+
});
|
|
57
|
+
// runtime
|
|
58
|
+
checks.push({
|
|
59
|
+
name: 'runtime',
|
|
60
|
+
status: typeof fetch === 'function' ? 'ok' : 'fail',
|
|
61
|
+
message: typeof fetch === 'function' ? 'global fetch available' : 'fetch missing',
|
|
62
|
+
});
|
|
63
|
+
const ok = checks.every((c) => c.status !== 'fail');
|
|
64
|
+
return { ok, product: 'Lookout', version, checks };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function formatDoctorReport(r: DoctorReport): string {
|
|
68
|
+
const lines = [`Lookout doctor — ${r.ok ? 'OK' : 'FAIL'} (v${r.version})`];
|
|
69
|
+
for (const c of r.checks) {
|
|
70
|
+
lines.push(` [${c.status.toUpperCase()}] ${c.name}: ${c.message}`);
|
|
71
|
+
}
|
|
72
|
+
return lines.join('\n');
|
|
73
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { LookoutCache, cacheKey, defaultCacheDir } from './cache.ts';
|
|
2
|
+
import { extractFromHtml } from './extract.ts';
|
|
3
|
+
import { webFetch } from './fetch.ts';
|
|
4
|
+
import { checkRobotsAllowed } from './robots.ts';
|
|
5
|
+
import { webSearch, filterHitsByHosts } from './search.ts';
|
|
6
|
+
import { webCrawl } from './crawl.ts';
|
|
7
|
+
import { webResearch } from './research.ts';
|
|
8
|
+
|
|
9
|
+
export type ToolEnvelope = {
|
|
10
|
+
status: 'ok' | 'error';
|
|
11
|
+
tool: string;
|
|
12
|
+
answer?: unknown;
|
|
13
|
+
evidence?: unknown;
|
|
14
|
+
warnings: string[];
|
|
15
|
+
route?: string;
|
|
16
|
+
code?: string;
|
|
17
|
+
message?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type EngineOptions = {
|
|
21
|
+
cacheDir?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export class LookoutEngine {
|
|
25
|
+
readonly cache: LookoutCache;
|
|
26
|
+
|
|
27
|
+
constructor(options: EngineOptions = {}) {
|
|
28
|
+
this.cache = new LookoutCache(options.cacheDir ?? defaultCacheDir());
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
private cacheMaxAgeMs(): number | undefined {
|
|
33
|
+
const raw = process.env.LOOKOUT_CACHE_MAX_AGE_MS?.trim();
|
|
34
|
+
if (!raw) return undefined;
|
|
35
|
+
const n = Number(raw);
|
|
36
|
+
return Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async handle(tool: string, input: Record<string, unknown> = {}): Promise<ToolEnvelope> {
|
|
40
|
+
switch (tool) {
|
|
41
|
+
case 'web_search':
|
|
42
|
+
return this.search(input);
|
|
43
|
+
case 'web_fetch':
|
|
44
|
+
return this.fetch(input);
|
|
45
|
+
case 'web_extract':
|
|
46
|
+
return this.extract(input);
|
|
47
|
+
case 'web_cache':
|
|
48
|
+
return this.cacheTool(input);
|
|
49
|
+
case 'web_research':
|
|
50
|
+
return this.research(input);
|
|
51
|
+
case 'web_crawl':
|
|
52
|
+
return this.crawl(input);
|
|
53
|
+
default:
|
|
54
|
+
return {
|
|
55
|
+
status: 'error',
|
|
56
|
+
tool,
|
|
57
|
+
warnings: [],
|
|
58
|
+
code: 'UNKNOWN_TOOL',
|
|
59
|
+
message: `Unknown tool: ${tool}`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private async search(input: Record<string, unknown>): Promise<ToolEnvelope> {
|
|
65
|
+
const queryRaw = input.query ?? input.q;
|
|
66
|
+
const queries = Array.isArray(queryRaw)
|
|
67
|
+
? queryRaw.map(String)
|
|
68
|
+
: queryRaw
|
|
69
|
+
? [String(queryRaw)]
|
|
70
|
+
: [];
|
|
71
|
+
if (!queries.length) {
|
|
72
|
+
return {
|
|
73
|
+
status: 'error',
|
|
74
|
+
tool: 'web_search',
|
|
75
|
+
warnings: [],
|
|
76
|
+
code: 'INVALID_INPUT',
|
|
77
|
+
message: 'web_search requires query (string or string[])',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const useCache = input.useCache !== false;
|
|
81
|
+
const allHits = [];
|
|
82
|
+
const warnings: string[] = [];
|
|
83
|
+
const engines = [];
|
|
84
|
+
for (const q of queries) {
|
|
85
|
+
const key = cacheKey(['search', q]);
|
|
86
|
+
if (useCache) {
|
|
87
|
+
const cached = this.cache.get(key, { maxAgeMs: this.cacheMaxAgeMs() });
|
|
88
|
+
if (cached) {
|
|
89
|
+
const parsed = JSON.parse(cached.body) as Awaited<ReturnType<typeof webSearch>>;
|
|
90
|
+
allHits.push(...parsed.hits.map((h) => ({ ...h, fromCache: true })));
|
|
91
|
+
warnings.push(...parsed.warnings.map((w) => `[cache] ${w}`));
|
|
92
|
+
engines.push(...parsed.engines);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const result = await webSearch(q);
|
|
97
|
+
this.cache.put({
|
|
98
|
+
key,
|
|
99
|
+
kind: 'search',
|
|
100
|
+
query: q,
|
|
101
|
+
body: JSON.stringify(result),
|
|
102
|
+
meta: { engines: result.engines },
|
|
103
|
+
});
|
|
104
|
+
allHits.push(...result.hits);
|
|
105
|
+
warnings.push(...result.warnings);
|
|
106
|
+
engines.push(...result.engines);
|
|
107
|
+
}
|
|
108
|
+
const includeHosts = Array.isArray(input.hostsInclude)
|
|
109
|
+
? input.hostsInclude.map(String)
|
|
110
|
+
: input.hostInclude
|
|
111
|
+
? [String(input.hostInclude)]
|
|
112
|
+
: [];
|
|
113
|
+
const excludeHosts = Array.isArray(input.hostsExclude)
|
|
114
|
+
? input.hostsExclude.map(String)
|
|
115
|
+
: input.hostExclude
|
|
116
|
+
? [String(input.hostExclude)]
|
|
117
|
+
: [];
|
|
118
|
+
let hits = allHits;
|
|
119
|
+
const filterWarnings: string[] = [];
|
|
120
|
+
if (includeHosts.length || excludeHosts.length) {
|
|
121
|
+
const before = hits.length;
|
|
122
|
+
hits = filterHitsByHosts(hits, { include: includeHosts, exclude: excludeHosts });
|
|
123
|
+
filterWarnings.push(
|
|
124
|
+
`host_filter kept ${hits.length}/${before} (include=${includeHosts.join(',') || '*'}; exclude=${excludeHosts.join(',') || 'none'})`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
status: hits.length ? 'ok' : 'error',
|
|
129
|
+
tool: 'web_search',
|
|
130
|
+
answer: {
|
|
131
|
+
queries,
|
|
132
|
+
hits,
|
|
133
|
+
engines,
|
|
134
|
+
hostsInclude: includeHosts.length ? includeHosts : undefined,
|
|
135
|
+
hostsExclude: excludeHosts.length ? excludeHosts : undefined,
|
|
136
|
+
},
|
|
137
|
+
warnings: [...warnings, ...filterWarnings],
|
|
138
|
+
route: 'local_adapters',
|
|
139
|
+
code: hits.length ? undefined : 'NO_HITS',
|
|
140
|
+
message: hits.length ? undefined : 'No search hits from local adapters',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private async fetch(input: Record<string, unknown>): Promise<ToolEnvelope> {
|
|
145
|
+
const url = String(input.url ?? '');
|
|
146
|
+
if (!url) {
|
|
147
|
+
return {
|
|
148
|
+
status: 'error',
|
|
149
|
+
tool: 'web_fetch',
|
|
150
|
+
warnings: [],
|
|
151
|
+
code: 'INVALID_INPUT',
|
|
152
|
+
message: 'web_fetch requires url',
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const useCache = input.useCache !== false;
|
|
156
|
+
const respectRobots = input.respectRobots === true; // default off for fetch (crawl defaults on)
|
|
157
|
+
const key = cacheKey(['fetch', url, respectRobots ? 'robots' : 'norobots']);
|
|
158
|
+
if (useCache) {
|
|
159
|
+
const cached = this.cache.get(key, { maxAgeMs: this.cacheMaxAgeMs() });
|
|
160
|
+
if (cached) {
|
|
161
|
+
return {
|
|
162
|
+
status: 'ok',
|
|
163
|
+
tool: 'web_fetch',
|
|
164
|
+
answer: { ...JSON.parse(cached.body), fromCache: true },
|
|
165
|
+
warnings: ['served_from_cache'],
|
|
166
|
+
route: 'cache',
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (respectRobots) {
|
|
171
|
+
const robots = await checkRobotsAllowed(url, { respectRobots: true });
|
|
172
|
+
if (!robots.allowed) {
|
|
173
|
+
return {
|
|
174
|
+
status: 'error',
|
|
175
|
+
tool: 'web_fetch',
|
|
176
|
+
warnings: [robots.warning ?? 'robots.txt disallow'],
|
|
177
|
+
code: 'ROBOTS_DISALLOW',
|
|
178
|
+
message: robots.warning ?? 'Blocked by robots.txt',
|
|
179
|
+
route: 'robots.txt',
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const result = await webFetch(url, {
|
|
184
|
+
maxBytes: typeof input.maxBytes === 'number' ? input.maxBytes : undefined,
|
|
185
|
+
timeoutMs: typeof input.timeoutMs === 'number' ? input.timeoutMs : undefined,
|
|
186
|
+
});
|
|
187
|
+
if (result.ok && result.body) {
|
|
188
|
+
this.cache.put({
|
|
189
|
+
key,
|
|
190
|
+
kind: 'fetch',
|
|
191
|
+
url: result.finalUrl,
|
|
192
|
+
contentType: result.contentType,
|
|
193
|
+
body: JSON.stringify(result),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
// cite span: first 240 chars of body
|
|
197
|
+
const excerpt = result.body?.slice(0, 240) ?? '';
|
|
198
|
+
const spans = excerpt
|
|
199
|
+
? [{ text: excerpt, start: 0, end: excerpt.length, kind: 'body_prefix' }]
|
|
200
|
+
: [];
|
|
201
|
+
return {
|
|
202
|
+
status: result.ok ? 'ok' : 'error',
|
|
203
|
+
tool: 'web_fetch',
|
|
204
|
+
answer: { ...result, spans },
|
|
205
|
+
warnings: result.warnings,
|
|
206
|
+
route: result.route,
|
|
207
|
+
code: result.code,
|
|
208
|
+
message: result.message,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private async extract(input: Record<string, unknown>): Promise<ToolEnvelope> {
|
|
213
|
+
let html = typeof input.html === 'string' ? input.html : undefined;
|
|
214
|
+
let url = typeof input.url === 'string' ? input.url : undefined;
|
|
215
|
+
if (!html && url) {
|
|
216
|
+
const fetched = await this.fetch({ url, useCache: input.useCache });
|
|
217
|
+
if (fetched.status !== 'ok') return { ...fetched, tool: 'web_extract' };
|
|
218
|
+
const body = (fetched.answer as { body?: string })?.body;
|
|
219
|
+
html = body;
|
|
220
|
+
url = (fetched.answer as { finalUrl?: string })?.finalUrl ?? url;
|
|
221
|
+
if (!input.contentType) {
|
|
222
|
+
const ct = (fetched.answer as { contentType?: string })?.contentType;
|
|
223
|
+
if (ct) input = { ...input, contentType: ct };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (!html) {
|
|
227
|
+
return {
|
|
228
|
+
status: 'error',
|
|
229
|
+
tool: 'web_extract',
|
|
230
|
+
warnings: [],
|
|
231
|
+
code: 'INVALID_INPUT',
|
|
232
|
+
message: 'web_extract requires html or url',
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
// JSON bodies: light local extract without HTML DOM
|
|
236
|
+
const looksJson =
|
|
237
|
+
(typeof input.contentType === 'string' && input.contentType.includes('application/json')) ||
|
|
238
|
+
/^\s*[\[{]/.test(html);
|
|
239
|
+
if (looksJson && !/<html[\s>]/i.test(html) && !/<body[\s>]/i.test(html)) {
|
|
240
|
+
let parsed: unknown = null;
|
|
241
|
+
try {
|
|
242
|
+
parsed = JSON.parse(html);
|
|
243
|
+
} catch {
|
|
244
|
+
// keep as text
|
|
245
|
+
}
|
|
246
|
+
const text = typeof parsed === 'string' ? parsed : JSON.stringify(parsed ?? html).slice(0, 4000);
|
|
247
|
+
return {
|
|
248
|
+
status: 'ok',
|
|
249
|
+
tool: 'web_extract',
|
|
250
|
+
answer: {
|
|
251
|
+
ok: true,
|
|
252
|
+
url,
|
|
253
|
+
textExcerpt: text,
|
|
254
|
+
headings: [],
|
|
255
|
+
links: [],
|
|
256
|
+
jsonLd: parsed && typeof parsed === 'object' ? [parsed] : [],
|
|
257
|
+
tables: [],
|
|
258
|
+
spans: text
|
|
259
|
+
? [{ text: text.slice(0, 240), start: 0, end: Math.min(240, text.length), kind: 'json_excerpt' }]
|
|
260
|
+
: [],
|
|
261
|
+
route: 'json_body',
|
|
262
|
+
warnings: parsed ? [] : ['json_parse_failed_used_raw_text'],
|
|
263
|
+
},
|
|
264
|
+
warnings: parsed ? [] : ['json_parse_failed_used_raw_text'],
|
|
265
|
+
route: 'json_body',
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const ct = typeof input.contentType === 'string' ? input.contentType : '';
|
|
269
|
+
if ((ct.includes('text/plain') || ct.includes('text/markdown')) && !/<html[\s>]/i.test(html) && !/<body[\s>]/i.test(html)) {
|
|
270
|
+
const text = html.slice(0, 4000);
|
|
271
|
+
return {
|
|
272
|
+
status: 'ok',
|
|
273
|
+
tool: 'web_extract',
|
|
274
|
+
answer: {
|
|
275
|
+
ok: true,
|
|
276
|
+
url,
|
|
277
|
+
textExcerpt: text,
|
|
278
|
+
headings: [],
|
|
279
|
+
links: [],
|
|
280
|
+
jsonLd: [],
|
|
281
|
+
tables: [],
|
|
282
|
+
spans: text
|
|
283
|
+
? [{ text: text.slice(0, 240), start: 0, end: Math.min(240, text.length), kind: 'text_excerpt' }]
|
|
284
|
+
: [],
|
|
285
|
+
route: ct.includes('markdown') ? 'text_markdown' : 'text_plain',
|
|
286
|
+
warnings: [],
|
|
287
|
+
},
|
|
288
|
+
warnings: [],
|
|
289
|
+
route: ct.includes('markdown') ? 'text_markdown' : 'text_plain',
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
const extracted = extractFromHtml(html, url);
|
|
293
|
+
return {
|
|
294
|
+
status: 'ok',
|
|
295
|
+
tool: 'web_extract',
|
|
296
|
+
answer: extracted,
|
|
297
|
+
warnings: extracted.warnings,
|
|
298
|
+
route: extracted.route,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
private async research(input: Record<string, unknown>): Promise<ToolEnvelope> {
|
|
305
|
+
const query = typeof input.query === 'string' ? input.query : Array.isArray(input.query) ? input.query.join(' ') : '';
|
|
306
|
+
if (!query.trim()) {
|
|
307
|
+
return {
|
|
308
|
+
status: 'error',
|
|
309
|
+
tool: 'web_research',
|
|
310
|
+
code: 'INVALID_INPUT',
|
|
311
|
+
message: 'web_research requires query',
|
|
312
|
+
warnings: [],
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
const maxPages = typeof input.maxPages === 'number' ? input.maxPages : undefined;
|
|
316
|
+
const hostsInclude = Array.isArray(input.hostsInclude)
|
|
317
|
+
? input.hostsInclude.map(String)
|
|
318
|
+
: input.hostInclude
|
|
319
|
+
? [String(input.hostInclude)]
|
|
320
|
+
: [];
|
|
321
|
+
const hostsExclude = Array.isArray(input.hostsExclude)
|
|
322
|
+
? input.hostsExclude.map(String)
|
|
323
|
+
: input.hostExclude
|
|
324
|
+
? [String(input.hostExclude)]
|
|
325
|
+
: [];
|
|
326
|
+
const result = await webResearch(query, {
|
|
327
|
+
maxPages,
|
|
328
|
+
hostsInclude: hostsInclude.length ? hostsInclude : undefined,
|
|
329
|
+
hostsExclude: hostsExclude.length ? hostsExclude : undefined,
|
|
330
|
+
});
|
|
331
|
+
return {
|
|
332
|
+
status: 'ok',
|
|
333
|
+
tool: 'web_research',
|
|
334
|
+
answer: {
|
|
335
|
+
...result,
|
|
336
|
+
hostsInclude: hostsInclude.length ? hostsInclude : undefined,
|
|
337
|
+
hostsExclude: hostsExclude.length ? hostsExclude : undefined,
|
|
338
|
+
},
|
|
339
|
+
evidence: result.pages.flatMap((p) =>
|
|
340
|
+
(p.spans ?? []).map((s) => ({ url: p.url, kind: s.kind, text: s.text })),
|
|
341
|
+
),
|
|
342
|
+
warnings: result.warnings,
|
|
343
|
+
route: result.route,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private async crawl(input: Record<string, unknown>): Promise<ToolEnvelope> {
|
|
348
|
+
const url = String(input.url ?? input.seed ?? '');
|
|
349
|
+
if (!url) {
|
|
350
|
+
return {
|
|
351
|
+
status: 'error',
|
|
352
|
+
tool: 'web_crawl',
|
|
353
|
+
warnings: [],
|
|
354
|
+
code: 'INVALID_INPUT',
|
|
355
|
+
message: 'web_crawl requires url',
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
const result = await webCrawl(url, {
|
|
359
|
+
maxDepth: typeof input.maxDepth === 'number' ? input.maxDepth : undefined,
|
|
360
|
+
maxPages: typeof input.maxPages === 'number' ? input.maxPages : undefined,
|
|
361
|
+
});
|
|
362
|
+
return {
|
|
363
|
+
status: result.ok ? 'ok' : 'error',
|
|
364
|
+
tool: 'web_crawl',
|
|
365
|
+
answer: result,
|
|
366
|
+
warnings: result.warnings,
|
|
367
|
+
route: result.route,
|
|
368
|
+
code: result.ok ? undefined : 'CRAWL_FAILED',
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private cacheTool(input: Record<string, unknown>): ToolEnvelope {
|
|
373
|
+
const op = String(input.op ?? input.operation ?? 'query');
|
|
374
|
+
if (op === 'stats') {
|
|
375
|
+
return {
|
|
376
|
+
status: 'ok',
|
|
377
|
+
tool: 'web_cache',
|
|
378
|
+
answer: this.cache.stats(),
|
|
379
|
+
warnings: [],
|
|
380
|
+
route: 'cache',
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
if (op === 'clear') {
|
|
384
|
+
return {
|
|
385
|
+
status: 'ok',
|
|
386
|
+
tool: 'web_cache',
|
|
387
|
+
answer: this.cache.clear(),
|
|
388
|
+
warnings: [],
|
|
389
|
+
route: 'cache',
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
if (op === 'prune') {
|
|
393
|
+
const maxAgeMs =
|
|
394
|
+
typeof input.maxAgeMs === 'number'
|
|
395
|
+
? input.maxAgeMs
|
|
396
|
+
: this.cacheMaxAgeMs() ?? 86_400_000;
|
|
397
|
+
return {
|
|
398
|
+
status: 'ok',
|
|
399
|
+
tool: 'web_cache',
|
|
400
|
+
answer: { ...this.cache.pruneExpired(maxAgeMs), maxAgeMs },
|
|
401
|
+
warnings: [],
|
|
402
|
+
route: 'cache',
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
const q = String(input.query ?? '');
|
|
406
|
+
const limit = typeof input.limit === 'number' ? input.limit : 20;
|
|
407
|
+
const results = this.cache.query(q, limit).map((r) => ({
|
|
408
|
+
key: r.key,
|
|
409
|
+
kind: r.kind,
|
|
410
|
+
createdAt: r.createdAt,
|
|
411
|
+
url: r.url,
|
|
412
|
+
query: r.query,
|
|
413
|
+
contentType: r.contentType,
|
|
414
|
+
preview: r.body.slice(0, 200),
|
|
415
|
+
}));
|
|
416
|
+
return {
|
|
417
|
+
status: 'ok',
|
|
418
|
+
tool: 'web_cache',
|
|
419
|
+
answer: { results, dir: this.cache.dir },
|
|
420
|
+
warnings: [],
|
|
421
|
+
route: 'cache',
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export const CORE_TOOLS = ['web_search', 'web_fetch', 'web_extract', 'web_cache'] as const;
|
|
427
|
+
export const ADVANCED_TOOLS = ['web_crawl', 'web_research'] as const;
|