@danypops/pi-web-spider 0.10.4 → 0.11.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/package.json +14 -4
- package/src/constants.ts +7 -0
- package/src/daemon-client.ts +190 -0
- package/src/index.ts +346 -351
- package/src/presentation.ts +430 -0
- package/test/cache-paths.test.ts +38 -91
- package/test/daemon-client.test.ts +158 -0
- package/test/daemon-isolation.ts +56 -0
- package/test/e2e-jiti.test.ts +46 -56
- package/test/execute-contract.test.ts +62 -32
- package/test/fixtures/mock-pi-cli.mjs +5 -9
- package/test/harness/extension-harness.ts +554 -0
- package/test/harness/index.ts +10 -0
- package/test/harness/jiti-native-modules.ts +13 -0
- package/test/helpers/fixture-server.ts +48 -0
- package/test/live-fetch.test.ts +10 -3
- package/test/load.test.ts +13 -3
- package/test/paths.test.ts +61 -96
- package/test/presentation.test.ts +190 -0
- package/src/format.ts +0 -142
- package/test/format.test.ts +0 -373
- package/test/playwright-fallback.test.ts +0 -248
package/src/index.ts
CHANGED
|
@@ -1,393 +1,390 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @danypops/pi-web-spider — Pi extension exposing web_fetch.
|
|
3
3
|
*
|
|
4
|
+
* Thin authenticated client of the Web Spider daemon (@danypops/web-spider-daemon):
|
|
5
|
+
* this file owns the tool contract (parameters, output shapes, presentation)
|
|
6
|
+
* and daemon connection lifecycle; it no longer performs any fetching,
|
|
7
|
+
* crawling, caching, throttling, robots.txt checking, or Playwright
|
|
8
|
+
* rendering itself — the daemon does all of that. See design doc
|
|
9
|
+
* web-spider-daemon-architecture-and-papyrus-integration-contr-5s14.
|
|
10
|
+
*
|
|
11
|
+
* The daemon's operations return tool-agnostic data (see e.g.
|
|
12
|
+
* fetch-service.ts/crawl-service.ts's own doc comments); this file
|
|
13
|
+
* reconstructs the exact historical web_fetch JSON content — hint/status
|
|
14
|
+
* text, cache-list compaction, the "cache" field split into the renderer
|
|
15
|
+
* details channel — so the tool's observable behavior is unchanged.
|
|
16
|
+
*
|
|
4
17
|
* Install: pi install git:github.com/DanyPops/web-spider
|
|
5
18
|
*/
|
|
6
|
-
import {
|
|
19
|
+
import { appendFileSync, mkdirSync } from "node:fs"
|
|
7
20
|
import { homedir } from "node:os"
|
|
8
21
|
import { dirname, join } from "node:path"
|
|
9
22
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
10
23
|
import { Type } from "typebox"
|
|
11
24
|
import type { Static } from "typebox"
|
|
12
|
-
import
|
|
13
|
-
import {
|
|
25
|
+
import { connectOrStartWebSpiderClient, type WebSpiderClient } from "./daemon-client.js"
|
|
26
|
+
import {
|
|
27
|
+
createWebDetails,
|
|
28
|
+
createWebResult,
|
|
29
|
+
renderWebFetchCall,
|
|
30
|
+
renderWebFetchResult,
|
|
31
|
+
type WebPresentationDetails,
|
|
32
|
+
} from "./presentation.js"
|
|
14
33
|
|
|
15
34
|
// ---------------------------------------------------------------------------
|
|
16
35
|
// Tool
|
|
17
36
|
// ---------------------------------------------------------------------------
|
|
18
37
|
|
|
19
38
|
export default async function (pi: ExtensionAPI) {
|
|
20
|
-
// Dynamic import bypasses jiti/Bun CJS interop, which can silently lose
|
|
21
|
-
// class constructors when require()-ing ESM packages with "type":"module".
|
|
22
|
-
// Native import() always uses the "import" condition and returns proper ESM.
|
|
23
|
-
const lib = await import("@danypops/web-spider")
|
|
24
|
-
const { spider, crawl, searchPages, SpiderCache, PageGraph, PlaywrightHttpClient,
|
|
25
|
-
queryTree, navigateTree, defaultSearchEngine, DomainThrottle, RobotsCache } = lib
|
|
26
|
-
|
|
27
|
-
// Browser processes are expensive — one shared instance per session.
|
|
28
|
-
let playwrightClient: InstanceType<typeof PlaywrightHttpClient> | null = null
|
|
29
|
-
const getPlaywrightClient = () => {
|
|
30
|
-
if (!playwrightClient) {
|
|
31
|
-
// /nonexistent in tests forces an immediate launch failure rather than a hang.
|
|
32
|
-
const executablePath = process.env["WEB_SPIDER_PLAYWRIGHT_EXECUTABLE"]
|
|
33
|
-
playwrightClient = new PlaywrightHttpClient(executablePath ? { executablePath } : undefined)
|
|
34
|
-
}
|
|
35
|
-
return playwrightClient
|
|
36
|
-
}
|
|
37
|
-
|
|
38
39
|
// Diagnostics go only to a file — never to stdout/stderr, which belong to Pi's TUI.
|
|
39
|
-
const diagPath = process.env
|
|
40
|
+
const diagPath = process.env.WEB_SPIDER_DIAG_PATH ?? join(homedir(), ".cache", "web-spider", "diag.log")
|
|
40
41
|
const diag = (entry: Record<string, unknown>) => {
|
|
41
42
|
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry })
|
|
42
|
-
try {
|
|
43
|
+
try {
|
|
44
|
+
mkdirSync(dirname(diagPath), { recursive: true })
|
|
45
|
+
appendFileSync(diagPath, `${line}\n`)
|
|
46
|
+
} catch { /* best-effort */ }
|
|
43
47
|
}
|
|
44
48
|
const log = (level: "info" | "warn" | "error", msg: string, extra?: unknown) => {
|
|
45
49
|
diag({ level, msg, ...extra !== undefined ? { extra } : {} })
|
|
46
50
|
}
|
|
47
51
|
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
52
|
+
// One shared connection attempt per session — connectOrStartWebSpiderClient()
|
|
53
|
+
// auto-starts the daemon transparently on first use if it isn't already
|
|
54
|
+
// running, so the tool "just works" without a manual `service install` step.
|
|
55
|
+
let clientPromise: Promise<WebSpiderClient> | null = null
|
|
56
|
+
const getClient = (): Promise<WebSpiderClient> => {
|
|
57
|
+
if (!clientPromise) {
|
|
58
|
+
clientPromise = connectOrStartWebSpiderClient().catch((error: unknown) => {
|
|
59
|
+
clientPromise = null // allow a retry on the next call rather than caching a permanent failure
|
|
60
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
61
|
+
log("error", "daemon connection failed", { error: message })
|
|
62
|
+
throw error
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
return clientPromise
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type Params = Static<typeof paramsSchema>
|
|
69
|
+
|
|
70
|
+
async function call<T = unknown>(operation: string, input: Record<string, unknown>): Promise<T> {
|
|
71
|
+
const client = await getClient()
|
|
62
72
|
try {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return new SpiderCache({ maxSize: 200, ttlMs: 30 * 60 * 1000 })
|
|
73
|
+
return await client.call<T>(operation, input)
|
|
74
|
+
} catch (error) {
|
|
75
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
76
|
+
log("error", "daemon operation failed", { operation, error: message })
|
|
77
|
+
throw error
|
|
69
78
|
}
|
|
70
|
-
})()
|
|
71
|
-
const graph = new PageGraph()
|
|
72
|
-
|
|
73
|
-
{
|
|
74
|
-
const storeRaw = (cache as unknown as Record<string, unknown>)["store"] ??
|
|
75
|
-
(cache as unknown as Record<string, unknown>)["map"]
|
|
76
|
-
diag({
|
|
77
|
-
tag: "boot-probe",
|
|
78
|
-
cacheClass: cache.constructor.name,
|
|
79
|
-
storeTag: Object.prototype.toString.call(storeRaw),
|
|
80
|
-
storeIsMap: storeRaw instanceof Map,
|
|
81
|
-
})
|
|
82
79
|
}
|
|
83
|
-
const corpus: SpideredPage[] = []
|
|
84
80
|
|
|
85
81
|
// ---------------------------------------------------------------------------
|
|
86
|
-
//
|
|
82
|
+
// Local materialized view helpers
|
|
87
83
|
// ---------------------------------------------------------------------------
|
|
88
84
|
|
|
89
|
-
|
|
85
|
+
function pageItems(pages: Array<{ url: string; title?: string }>) {
|
|
86
|
+
return pages.map((page) => ({ url: page.url, title: page.title ?? "" }))
|
|
87
|
+
}
|
|
90
88
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const sharedRobots = new RobotsCache()
|
|
89
|
+
function output(payload: unknown, details: WebPresentationDetails) {
|
|
90
|
+
return createWebResult(payload, details)
|
|
91
|
+
}
|
|
95
92
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Papyrus ingestion — Web Spider is a context source, Papyrus is the context
|
|
95
|
+
// mesh. Explicit opt-in only (params.ingest === true): never triggered by an
|
|
96
|
+
// ordinary fetch/search, matching the daemon's own "papyrus.ingest" contract
|
|
97
|
+
// (bounded batch, "web"/"web-search-result" subtypes, immutable service output).
|
|
98
|
+
// Scoped to a single-page fetch and a search, not crawl or cache views — a
|
|
99
|
+
// crawl can produce more pages than the ingest batch bound and picking which
|
|
100
|
+
// ones matter is a separate design question left for a follow-up.
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
type PapyrusIngestOutcome = {
|
|
103
|
+
ingested: Array<{ url: string; docId: string }>
|
|
104
|
+
skipped: Array<{ url: string; reason: string }>
|
|
106
105
|
}
|
|
107
106
|
|
|
108
|
-
function
|
|
109
|
-
|
|
110
|
-
return
|
|
111
|
-
|
|
112
|
-
try {
|
|
113
|
-
const hit = cache.get(url)
|
|
114
|
-
if (hit) return hit
|
|
115
|
-
|
|
116
|
-
log("info", "fetching", { url, enhanced: params.enhanced ?? false })
|
|
117
|
-
probe = "spider"
|
|
118
|
-
let page = await spider(url, spiderOpts)
|
|
119
|
-
log("info", "plain fetch done", { url, wordCount: page.wordCount, jsRendered: page.jsRendered })
|
|
120
|
-
|
|
121
|
-
if (page.jsRendered && !params.enhanced) {
|
|
122
|
-
log("info", "jsRendered detected, retrying with Playwright", { url })
|
|
123
|
-
probe = "spider(playwright)"
|
|
124
|
-
try {
|
|
125
|
-
page = await spider(url, { ...spiderOpts, httpClient: getPlaywrightClient() })
|
|
126
|
-
log("info", "Playwright fetch done", { url, wordCount: page.wordCount })
|
|
127
|
-
} catch (err) {
|
|
128
|
-
const msg = err instanceof Error ? err.message : String(err)
|
|
129
|
-
log("error", "Playwright fallback failed", { url, error: msg })
|
|
130
|
-
throw new Error(`Playwright fallback failed: ${msg}`)
|
|
131
|
-
}
|
|
132
|
-
}
|
|
107
|
+
async function maybeIngestPage(params: Params, url: string): Promise<PapyrusIngestOutcome | undefined> {
|
|
108
|
+
if (params.ingest !== true) return undefined
|
|
109
|
+
return await call<PapyrusIngestOutcome>("papyrus.ingest", { kind: "pages", urls: [url], relatesTo: params.relatesTo })
|
|
110
|
+
}
|
|
133
111
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
diag({
|
|
142
|
-
tag: "call-site-throw",
|
|
143
|
-
probe,
|
|
144
|
-
error: err instanceof Error ? err.message : String(err),
|
|
145
|
-
stack: err instanceof Error ? err.stack?.split("\n").slice(0, 6).join(" | ") : undefined,
|
|
146
|
-
})
|
|
147
|
-
throw err
|
|
148
|
-
}
|
|
149
|
-
}
|
|
112
|
+
async function maybeIngestSearch(
|
|
113
|
+
params: Params,
|
|
114
|
+
query: string,
|
|
115
|
+
results: Array<{ url: string; title: string; snippet: string; publishedAt?: string }>,
|
|
116
|
+
): Promise<PapyrusIngestOutcome | undefined> {
|
|
117
|
+
if (params.ingest !== true) return undefined
|
|
118
|
+
return await call<PapyrusIngestOutcome>("papyrus.ingest", { kind: "search", query, results, relatesTo: params.relatesTo })
|
|
150
119
|
}
|
|
151
120
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
121
|
+
function withPapyrus<T extends Record<string, unknown>>(content: T, papyrus: PapyrusIngestOutcome | undefined): T {
|
|
122
|
+
return papyrus ? { ...content, papyrus } : content
|
|
123
|
+
}
|
|
155
124
|
|
|
156
|
-
|
|
157
|
-
|
|
125
|
+
/** Splits the daemon's "cache" hit/miss field out of a fetch result — historically renderer-only, never model content. */
|
|
126
|
+
function splitCache<T extends { cache?: "hit" | "miss" }>(result: T): { content: Omit<T, "cache">; cache: "hit" | "miss" | undefined } {
|
|
127
|
+
const { cache, ...content } = result
|
|
128
|
+
return { content, cache }
|
|
158
129
|
}
|
|
159
130
|
|
|
160
131
|
// ---------------------------------------------------------------------------
|
|
161
132
|
// Path handlers — each owns one execution branch. SRP: one reason to change.
|
|
162
133
|
// ---------------------------------------------------------------------------
|
|
163
134
|
|
|
135
|
+
async function handleSearch(params: Params) {
|
|
136
|
+
const query = params.searchQuery ?? ""
|
|
137
|
+
const result = await call<{ query: string; results: Array<{ url: string; title: string; snippet: string; publishedAt?: string }> }>("search", {
|
|
138
|
+
query,
|
|
139
|
+
numResults: params.limit ?? 10,
|
|
140
|
+
})
|
|
141
|
+
log("info", "web search done", { query, hits: result.results.length })
|
|
142
|
+
const papyrus = await maybeIngestSearch(params, query, result.results)
|
|
143
|
+
return output(withPapyrus({
|
|
144
|
+
query: result.query,
|
|
145
|
+
results: result.results,
|
|
146
|
+
hint: "Use the url field from a result to fetch its full content with web_fetch(url=...).",
|
|
147
|
+
}, papyrus), createWebDetails({
|
|
148
|
+
operation: "search",
|
|
149
|
+
format: "search",
|
|
150
|
+
status: result.results.length === 0 ? "empty" : "ok",
|
|
151
|
+
query,
|
|
152
|
+
hits: result.results.length,
|
|
153
|
+
items: result.results.map((r) => ({ url: r.url, title: r.title })),
|
|
154
|
+
papyrusDocs: papyrus?.ingested.length,
|
|
155
|
+
}))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function handleCacheListing(params: Params) {
|
|
159
|
+
const result = await call<{ total: number; filtered: number; offset: number; limit: number; pages: Array<Record<string, unknown>> }>("cache.list", {
|
|
160
|
+
grep: params.grep,
|
|
161
|
+
offset: params.offset,
|
|
162
|
+
limit: params.limit,
|
|
163
|
+
})
|
|
164
|
+
const remaining = result.filtered - result.offset - result.pages.length
|
|
165
|
+
const meta = omitEmpty({
|
|
166
|
+
total: result.total,
|
|
167
|
+
filtered: result.filtered !== result.total ? result.filtered : undefined,
|
|
168
|
+
offset: result.offset || undefined,
|
|
169
|
+
limit: result.limit,
|
|
170
|
+
remaining: remaining > 0 ? remaining : undefined,
|
|
171
|
+
})
|
|
172
|
+
const items = pageItems(result.pages as Array<{ url: string; title?: string }>)
|
|
173
|
+
return output({ ...meta, pages: result.pages }, createWebDetails({
|
|
174
|
+
operation: "cache-list",
|
|
175
|
+
format: "lean",
|
|
176
|
+
status: result.pages.length === 0 ? "empty" : "ok",
|
|
177
|
+
pages: result.filtered,
|
|
178
|
+
cache: "listing",
|
|
179
|
+
items,
|
|
180
|
+
truncated: remaining > 0,
|
|
181
|
+
complete: remaining <= 0,
|
|
182
|
+
}))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function handleCacheSearch(params: Params) {
|
|
186
|
+
const result = await call<{ query: string; pagesSearched: number; hits: Array<{ url: string; title: string; score: number; heading: string; text: string }> }>("cache.search", {
|
|
187
|
+
query: params.query ?? "",
|
|
188
|
+
limit: params.limit ?? 10,
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
if (result.pagesSearched === 0) {
|
|
192
|
+
return output({
|
|
193
|
+
status: "empty",
|
|
194
|
+
hint: "Local cache is empty. Fetch some pages first with depth=0 or depth>0.",
|
|
195
|
+
}, createWebDetails({
|
|
196
|
+
operation: "cache-search",
|
|
197
|
+
format: "highlights",
|
|
198
|
+
status: "empty",
|
|
199
|
+
query: params.query,
|
|
200
|
+
pages: 0,
|
|
201
|
+
hits: 0,
|
|
202
|
+
cache: "search",
|
|
203
|
+
}))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Historical content shape never included title on a hit — it stays daemon-side
|
|
207
|
+
// (useful operational metadata for other consumers) but is stripped here.
|
|
208
|
+
const hits = result.hits.map(({ url, heading, score, text }) => ({ url, heading, score, text }))
|
|
209
|
+
return output({
|
|
210
|
+
...omitEmpty({ query: result.query, pagesSearched: result.pagesSearched }),
|
|
211
|
+
hits,
|
|
212
|
+
...(hits.length === 0 ? { hint: "No matches. Try broader terms, or list cached pages with web_fetch(format=lean) and no url." } : {}),
|
|
213
|
+
}, createWebDetails({
|
|
214
|
+
operation: "cache-search",
|
|
215
|
+
format: "highlights",
|
|
216
|
+
status: hits.length === 0 ? "empty" : "ok",
|
|
217
|
+
query: params.query,
|
|
218
|
+
pages: result.pagesSearched,
|
|
219
|
+
hits: hits.length,
|
|
220
|
+
cache: "search",
|
|
221
|
+
items: result.hits.map((h) => ({ url: h.url, title: h.title })),
|
|
222
|
+
}))
|
|
223
|
+
}
|
|
224
|
+
|
|
164
225
|
async function handleCrawl(params: Params) {
|
|
165
|
-
const spiderOpts = buildSpiderOpts(params)
|
|
166
226
|
const fmt = params.format ?? "markdown"
|
|
167
227
|
const depth = params.depth ?? 0
|
|
168
228
|
const url = params.url ?? ""
|
|
169
229
|
|
|
170
|
-
const result = await crawl
|
|
171
|
-
|
|
230
|
+
const result = await call<Record<string, unknown>>("crawl", {
|
|
231
|
+
url,
|
|
232
|
+
format: fmt,
|
|
233
|
+
depth,
|
|
172
234
|
maxPages: params.maxPages ?? 10,
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
235
|
+
sameDomain: params.sameDomain,
|
|
236
|
+
rootSelector: params.rootSelector,
|
|
237
|
+
excludeSelectors: params.excludeSelectors,
|
|
238
|
+
tokenBudget: params.tokenBudget,
|
|
239
|
+
enhanced: params.enhanced,
|
|
240
|
+
timeoutMs: params.timeoutMs,
|
|
241
|
+
query: params.query,
|
|
178
242
|
})
|
|
179
|
-
|
|
180
|
-
const pages = [...result.pages.values()]
|
|
181
|
-
const errorsObj = result.errors.size
|
|
182
|
-
? { errors: result.errors.size, errorUrls: [...result.errors.keys()] }
|
|
183
|
-
: {}
|
|
243
|
+
const errors = typeof result.errors === "number" ? result.errors : 0
|
|
184
244
|
|
|
185
245
|
if (fmt === "highlights") {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
url: h.url,
|
|
199
|
-
...highlightHit(h, pages.find((p) => p.url === h.url)?.chunks ?? []),
|
|
200
|
-
})),
|
|
201
|
-
}) }],
|
|
202
|
-
details: { format: "highlights", pagesSearched: pages.length, hits: hits.length },
|
|
203
|
-
}
|
|
246
|
+
const hits = (result.hits as unknown[] | undefined) ?? []
|
|
247
|
+
return output(result, createWebDetails({
|
|
248
|
+
operation: "crawl",
|
|
249
|
+
format: "highlights",
|
|
250
|
+
url,
|
|
251
|
+
query: params.query,
|
|
252
|
+
depth,
|
|
253
|
+
pages: typeof result.pagesSearched === "number" ? result.pagesSearched : 0,
|
|
254
|
+
hits: hits.length,
|
|
255
|
+
errors,
|
|
256
|
+
items: pageItems(hits as Array<{ url: string; title?: string }>),
|
|
257
|
+
}))
|
|
204
258
|
}
|
|
205
259
|
|
|
206
|
-
const
|
|
207
|
-
|
|
260
|
+
const pages = (result.pages as Array<Record<string, unknown>> | undefined) ?? []
|
|
261
|
+
const content = fmt === "lean"
|
|
262
|
+
? result
|
|
208
263
|
: {
|
|
209
|
-
|
|
210
|
-
|
|
264
|
+
...result,
|
|
265
|
+
// Historical guidance names the web_fetch tool specifically — added here,
|
|
266
|
+
// not by the daemon, which also serves the tool-agnostic CLI.
|
|
211
267
|
note: "All pages cached — use web_fetch(depth=0, format=highlights, query=...) to search them.",
|
|
212
|
-
pages: pages.map((p) => omitEmpty({ url: p.url, title: p.title, description: p.description, wordCount: p.wordCount, tags: p.tags })),
|
|
213
268
|
}
|
|
214
269
|
|
|
215
|
-
return {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
async function fetchTree(url: string, timeoutMs?: number): Promise<DOMNode> {
|
|
225
|
-
const hit = treeCache.get(url)
|
|
226
|
-
if (hit) return hit
|
|
227
|
-
const page = await spider(url, { view: "tree", timeoutMs })
|
|
228
|
-
treeCache.set(url, page.tree)
|
|
229
|
-
return page.tree
|
|
270
|
+
return output(content, createWebDetails({
|
|
271
|
+
operation: "crawl",
|
|
272
|
+
format: fmt === "lean" ? "lean" : "markdown",
|
|
273
|
+
url,
|
|
274
|
+
depth,
|
|
275
|
+
pages: typeof result.pagesFound === "number" ? result.pagesFound : pages.length,
|
|
276
|
+
errors,
|
|
277
|
+
items: pageItems(pages as Array<{ url: string; title?: string }>),
|
|
278
|
+
}))
|
|
230
279
|
}
|
|
231
280
|
|
|
232
|
-
function
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
(
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const filtered = pages.length
|
|
248
|
-
const offset = params.offset ?? 0
|
|
249
|
-
const limit = Math.min(params.limit ?? 20, 100) // hard cap at 100
|
|
250
|
-
const slice = pages.slice(offset, offset + limit)
|
|
251
|
-
const remaining = filtered - offset - slice.length
|
|
252
|
-
|
|
253
|
-
const meta = omitEmpty({
|
|
254
|
-
total,
|
|
255
|
-
filtered: filtered !== total ? filtered : undefined,
|
|
256
|
-
offset: offset || undefined,
|
|
257
|
-
limit,
|
|
258
|
-
remaining: remaining > 0 ? remaining : undefined,
|
|
259
|
-
})
|
|
260
|
-
|
|
261
|
-
return {
|
|
262
|
-
content: [{ type: "text" as const, text: JSON.stringify({ ...meta, pages: slice.map(leanOutput) }) }],
|
|
263
|
-
details: { format: "lean", total, filtered, offset, limit },
|
|
264
|
-
}
|
|
265
|
-
}
|
|
281
|
+
async function handleTreeFormat(params: Params) {
|
|
282
|
+
const url = params.url ?? ""
|
|
283
|
+
try {
|
|
284
|
+
if (params.path) {
|
|
285
|
+
const node = await call<{ found?: false; path?: string; tag?: string } & Record<string, unknown>>("fetch", {
|
|
286
|
+
url, format: "tree", path: params.path, rootSelector: params.rootSelector, excludeSelectors: params.excludeSelectors, enhanced: params.enhanced,
|
|
287
|
+
})
|
|
288
|
+
if (node.found === false) {
|
|
289
|
+
return output({ found: false, path: params.path, hint: "Inspect the full tree or query it to find a valid path." }, createWebDetails({
|
|
290
|
+
operation: "tree-path", format: "tree", status: "empty", url, path: params.path,
|
|
291
|
+
}))
|
|
292
|
+
}
|
|
293
|
+
return output(node, createWebDetails({ operation: "tree-path", format: "tree", url, path: String(node.path ?? params.path) }))
|
|
294
|
+
}
|
|
266
295
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
296
|
+
if (params.query?.trim()) {
|
|
297
|
+
const result = await call<{ url: string; query: string; hits: Array<{ path: string; tag: string; score: number; snippet: string }> }>("fetch", {
|
|
298
|
+
url, format: "tree", query: params.query, topN: params.topN, rootSelector: params.rootSelector, excludeSelectors: params.excludeSelectors, enhanced: params.enhanced,
|
|
299
|
+
})
|
|
300
|
+
return output(omitEmpty({ url: result.url, query: result.query, hits: result.hits.map((h) => omitEmpty({ ...h })) }), createWebDetails({
|
|
301
|
+
operation: "tree-query",
|
|
302
|
+
format: "tree",
|
|
303
|
+
status: result.hits.length === 0 ? "empty" : "ok",
|
|
304
|
+
url,
|
|
305
|
+
query: params.query,
|
|
306
|
+
hits: result.hits.length,
|
|
307
|
+
items: result.hits.map((hit) => ({ url, title: `${hit.tag} · ${hit.path}` })),
|
|
308
|
+
}))
|
|
273
309
|
}
|
|
274
|
-
}
|
|
275
310
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
hits: hits.map((h) => ({
|
|
282
|
-
url: h.url,
|
|
283
|
-
...highlightHit(h, pages.find((p) => p.url === h.url)?.chunks ?? []),
|
|
284
|
-
})),
|
|
285
|
-
...(hits.length === 0 ? { hint: "No matches. Try broader terms, or list cached pages with web_fetch(format=lean) and no url." } : {}),
|
|
286
|
-
}) }],
|
|
287
|
-
details: { format: "highlights", pagesSearched: pages.length, hits: hits.length },
|
|
311
|
+
const tree = await call<Record<string, unknown>>("fetch", { url, format: "tree", rootSelector: params.rootSelector, excludeSelectors: params.excludeSelectors, enhanced: params.enhanced })
|
|
312
|
+
return output(tree, createWebDetails({ operation: "tree-full", format: "tree", url }))
|
|
313
|
+
} catch (err) {
|
|
314
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
315
|
+
throw new Error(`tree fetch failed: ${message}`)
|
|
288
316
|
}
|
|
289
317
|
}
|
|
290
318
|
|
|
291
|
-
async function handleSinglePage(params: Params
|
|
319
|
+
async function handleSinglePage(params: Params) {
|
|
292
320
|
const fmt = params.format ?? "markdown"
|
|
293
321
|
const url = params.url ?? ""
|
|
294
322
|
|
|
295
|
-
|
|
296
|
-
if (fmt === "tree") {
|
|
297
|
-
try {
|
|
298
|
-
const tree = await fetchTree(url, params.timeoutMs)
|
|
299
|
-
|
|
300
|
-
// path= → navigate to a specific node
|
|
301
|
-
if (params.path) {
|
|
302
|
-
const node = navigateTree(tree, params.path)
|
|
303
|
-
if (!node) {
|
|
304
|
-
return {
|
|
305
|
-
content: [{ type: "text" as const, text: JSON.stringify({ error: `Path not found: ${params.path}` }) }],
|
|
306
|
-
details: {},
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
return {
|
|
310
|
-
content: [{ type: "text" as const, text: JSON.stringify(node) }],
|
|
311
|
-
details: { format: "tree", mode: "navigate", tag: node.tag, path: node.path },
|
|
312
|
-
}
|
|
313
|
-
}
|
|
323
|
+
if (fmt === "tree") return handleTreeFormat(params)
|
|
314
324
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
score: Math.round(h.score * 100) / 100,
|
|
326
|
-
snippet: h.snippet,
|
|
327
|
-
text: h.node.text,
|
|
328
|
-
childCount: h.node.children?.length,
|
|
329
|
-
})),
|
|
330
|
-
})) }],
|
|
331
|
-
details: { format: "tree", mode: "query", hits: hits.length },
|
|
332
|
-
}
|
|
333
|
-
}
|
|
325
|
+
const raw = await call<Record<string, unknown> & { cache?: "hit" | "miss"; blocked?: boolean }>("fetch", {
|
|
326
|
+
url,
|
|
327
|
+
format: fmt,
|
|
328
|
+
rootSelector: params.rootSelector,
|
|
329
|
+
excludeSelectors: params.excludeSelectors,
|
|
330
|
+
tokenBudget: params.tokenBudget,
|
|
331
|
+
enhanced: params.enhanced,
|
|
332
|
+
timeoutMs: params.timeoutMs,
|
|
333
|
+
query: params.query,
|
|
334
|
+
})
|
|
334
335
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
return {
|
|
343
|
-
content: [{ type: "text" as const, text: JSON.stringify({ error: message }) }],
|
|
344
|
-
details: {},
|
|
345
|
-
}
|
|
346
|
-
}
|
|
336
|
+
if (raw.blocked === true) {
|
|
337
|
+
return output({
|
|
338
|
+
blocked: true,
|
|
339
|
+
url,
|
|
340
|
+
reason: "robots.txt",
|
|
341
|
+
hint: "The site's robots.txt disallows crawling this URL. Try a different path or domain.",
|
|
342
|
+
}, createWebDetails({ operation: "fetch", format: fmt, status: "blocked", url, blockedBy: "robots.txt" }))
|
|
347
343
|
}
|
|
348
344
|
|
|
349
|
-
const
|
|
345
|
+
const { content, cache } = splitCache(raw)
|
|
346
|
+
const papyrus = await maybeIngestPage(params, url)
|
|
350
347
|
|
|
351
348
|
if (fmt === "lean") {
|
|
352
|
-
return {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
349
|
+
return output(withPapyrus(content, papyrus), createWebDetails({
|
|
350
|
+
operation: "fetch", format: "lean", url,
|
|
351
|
+
title: String(content.title ?? ""), wordCount: Number(content.wordCount ?? 0), cache, enhanced: params.enhanced,
|
|
352
|
+
papyrusDocs: papyrus?.ingested.length,
|
|
353
|
+
}))
|
|
356
354
|
}
|
|
357
355
|
|
|
358
356
|
if (fmt === "links") {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
357
|
+
const links = (content.bodyLinks as unknown[] | undefined) ?? []
|
|
358
|
+
return output(withPapyrus(content, papyrus), createWebDetails({
|
|
359
|
+
operation: "fetch", format: "links", url,
|
|
360
|
+
title: String(content.title ?? ""), links: links.length, cache, enhanced: params.enhanced,
|
|
361
|
+
items: links.map((link) => ({ url: (link as { href: string }).href, title: (link as { text: string }).text })),
|
|
362
|
+
papyrusDocs: papyrus?.ingested.length,
|
|
363
|
+
}))
|
|
363
364
|
}
|
|
364
365
|
|
|
365
366
|
if (fmt === "highlights") {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
}
|
|
375
|
-
const hits = searchPages([page], params.query, { topN: 5, snippetRadius: 150 })
|
|
376
|
-
return {
|
|
377
|
-
content: [{ type: "text" as const, text: JSON.stringify({
|
|
378
|
-
...omitEmpty({ url: page.url, title: page.title, query: params.query }),
|
|
379
|
-
hits: hits.map((h) => highlightHit(h, page.chunks)),
|
|
380
|
-
...(hits.length === 0 ? { hint: "No matches. Try broader terms or use format=markdown." } : {}),
|
|
381
|
-
}) }],
|
|
382
|
-
details: { format: "highlights", hits: hits.length },
|
|
383
|
-
}
|
|
367
|
+
const hits = (content.hits as unknown[] | undefined) ?? []
|
|
368
|
+
const withHint = hits.length === 0 ? { ...content, hint: "No matches. Try broader terms or use format=markdown." } : content
|
|
369
|
+
return output(withPapyrus(withHint, papyrus), createWebDetails({
|
|
370
|
+
operation: "fetch", format: "highlights",
|
|
371
|
+
status: hits.length === 0 ? "empty" : "ok",
|
|
372
|
+
url, title: String(content.title ?? ""), query: params.query, hits: hits.length, cache, enhanced: params.enhanced,
|
|
373
|
+
papyrusDocs: papyrus?.ingested.length,
|
|
374
|
+
}))
|
|
384
375
|
}
|
|
385
376
|
|
|
386
377
|
// markdown (default)
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
378
|
+
const truncated = content.truncated === true
|
|
379
|
+
const withHint = truncated
|
|
380
|
+
? { ...content, hint: "Content was bounded. Use highlights, tree query/path, rootSelector, or a more specific request for complete evidence." }
|
|
381
|
+
: content
|
|
382
|
+
return output(withPapyrus(withHint, papyrus), createWebDetails({
|
|
383
|
+
operation: "fetch", format: "markdown", url,
|
|
384
|
+
title: String(content.title ?? ""), wordCount: Number(content.wordCount ?? 0), cache, enhanced: params.enhanced,
|
|
385
|
+
truncated, complete: !truncated,
|
|
386
|
+
papyrusDocs: papyrus?.ingested.length,
|
|
387
|
+
}))
|
|
391
388
|
}
|
|
392
389
|
|
|
393
390
|
// ---------------------------------------------------------------------------
|
|
@@ -488,7 +485,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
488
485
|
tokenBudget: Type.Optional(
|
|
489
486
|
Type.Number({
|
|
490
487
|
description:
|
|
491
|
-
"Approximate max tokens to return (~4 chars/token)
|
|
488
|
+
"Approximate max tokens to return (~4 chars/token), capped at 10,000. Truncation carries explicit completeness markers.",
|
|
492
489
|
})
|
|
493
490
|
),
|
|
494
491
|
searchQuery: Type.Optional(
|
|
@@ -506,6 +503,21 @@ export default async function (pi: ExtensionAPI) {
|
|
|
506
503
|
"Increase for slow sites; decrease to fail fast in latency-sensitive loops.",
|
|
507
504
|
})
|
|
508
505
|
),
|
|
506
|
+
|
|
507
|
+
ingest: Type.Optional(
|
|
508
|
+
Type.Boolean({
|
|
509
|
+
description:
|
|
510
|
+
"When true, push the result into Papyrus (the context mesh) as a Doc artifact after a " +
|
|
511
|
+
"successful single-page fetch (url, depth=0) or a searchQuery search. Explicit opt-in only " +
|
|
512
|
+
"\u2014 never triggered by an ordinary fetch. Ignored for depth>0 crawls and local cache views " +
|
|
513
|
+
"(no url/searchQuery). Response includes a papyrus field with the created Doc id(s).",
|
|
514
|
+
})
|
|
515
|
+
),
|
|
516
|
+
relatesTo: Type.Optional(
|
|
517
|
+
Type.String({
|
|
518
|
+
description: "Existing Papyrus artifact id to link the ingested Doc(s) to via 'references'. Only used with ingest=true.",
|
|
519
|
+
})
|
|
520
|
+
),
|
|
509
521
|
})
|
|
510
522
|
|
|
511
523
|
pi.registerTool({
|
|
@@ -565,71 +577,54 @@ export default async function (pi: ExtensionAPI) {
|
|
|
565
577
|
" Requests are automatically rate-limited per domain (500ms min delay).",
|
|
566
578
|
" On 429/503, backs off exponentially and respects Retry-After headers.",
|
|
567
579
|
" robots.txt is checked and respected before each fetch (depth=0 and depth>0).",
|
|
580
|
+
"",
|
|
581
|
+
"CONTEXT MESH",
|
|
582
|
+
" ingest=true — push this fetch's page or this search's results into Papyrus as Doc",
|
|
583
|
+
" artifact(s). Explicit only, never automatic. Works with a single-page",
|
|
584
|
+
" fetch (depth=0) or searchQuery; ignored for crawls and cache views.",
|
|
585
|
+
" relatesTo=ID — link the ingested Doc(s) to an existing Papyrus artifact via 'references'.",
|
|
586
|
+
" Response gains a papyrus field: {ingested: [{url, docId}], skipped: [{url, reason}]}.",
|
|
568
587
|
].join("\n"),
|
|
569
588
|
promptSnippet:
|
|
570
589
|
"Fetch URL: format=markdown/lean/links/highlights, depth, rootSelector, tokenBudget",
|
|
571
590
|
parameters: paramsSchema,
|
|
572
|
-
|
|
591
|
+
renderCall(args, theme, context) { return renderWebFetchCall(args, theme, context) },
|
|
592
|
+
renderResult(result, options, theme, context) { return renderWebFetchResult(result, options, theme, context) },
|
|
573
593
|
|
|
574
594
|
// -------------------------------------------------------------------------
|
|
575
595
|
// Router — routes to the correct path handler. One reason to change: routing
|
|
576
|
-
// logic. Business logic lives in the handlers
|
|
596
|
+
// logic. Business logic lives in the daemon; this file's handlers only
|
|
597
|
+
// shape daemon operation results into the tool's historical contract.
|
|
577
598
|
// -------------------------------------------------------------------------
|
|
578
599
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
579
600
|
try {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
// Web search path — searchQuery instead of url.
|
|
583
|
-
if (params.searchQuery?.trim()) {
|
|
584
|
-
try {
|
|
585
|
-
const engine = defaultSearchEngine()
|
|
586
|
-
const results = await engine.search({ query: params.searchQuery, numResults: params.limit ?? 10 })
|
|
587
|
-
log("info", "web search done", { query: params.searchQuery, hits: results.length })
|
|
588
|
-
return {
|
|
589
|
-
content: [{ type: "text" as const, text: JSON.stringify({
|
|
590
|
-
query: params.searchQuery,
|
|
591
|
-
results,
|
|
592
|
-
hint: "Use the url field from a result to fetch its full content with web_fetch(url=...).",
|
|
593
|
-
}) }],
|
|
594
|
-
details: { searchQuery: params.searchQuery, hits: results.length },
|
|
595
|
-
}
|
|
596
|
-
} catch (err) {
|
|
597
|
-
const message = err instanceof Error ? err.message : String(err)
|
|
598
|
-
return {
|
|
599
|
-
content: [{ type: "text" as const, text: JSON.stringify({ error: `Web search failed: ${message}` }) }],
|
|
600
|
-
details: {},
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
}
|
|
601
|
+
if (params.searchQuery?.trim()) return await handleSearch(params)
|
|
604
602
|
|
|
605
|
-
// Local materialized view path — no url: query the cache directly.
|
|
606
603
|
if (!params.url) {
|
|
607
|
-
if (params.query?.trim()) return handleCacheSearch(params)
|
|
608
|
-
return handleCacheListing(params)
|
|
604
|
+
if (params.query?.trim()) return await handleCacheSearch(params)
|
|
605
|
+
return await handleCacheListing(params)
|
|
609
606
|
}
|
|
610
607
|
|
|
611
608
|
if ((params.depth ?? 0) > 0) return await handleCrawl(params)
|
|
612
609
|
|
|
613
|
-
return await handleSinglePage(params
|
|
610
|
+
return await handleSinglePage(params)
|
|
614
611
|
} catch (err) {
|
|
615
|
-
if (err instanceof Error && err.message.startsWith("Blocked by robots.txt:")) {
|
|
616
|
-
return {
|
|
617
|
-
content: [{ type: "text" as const, text: JSON.stringify({
|
|
618
|
-
blocked: true,
|
|
619
|
-
url: params.url,
|
|
620
|
-
reason: "robots.txt",
|
|
621
|
-
hint: "The site's robots.txt disallows crawling this URL. Try a different path or domain.",
|
|
622
|
-
}) }],
|
|
623
|
-
details: { blocked: true },
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
612
|
const message = err instanceof Error ? err.message : String(err)
|
|
627
|
-
|
|
628
|
-
content: [{ type: "text" as const, text: JSON.stringify({ error: message }) }],
|
|
629
|
-
details: {},
|
|
630
|
-
isError: true,
|
|
631
|
-
}
|
|
613
|
+
throw new Error(`web_fetch failed: ${message}`)
|
|
632
614
|
}
|
|
633
615
|
},
|
|
634
616
|
})
|
|
635
617
|
}
|
|
618
|
+
|
|
619
|
+
// ---------------------------------------------------------------------------
|
|
620
|
+
// Small local helper — omitEmpty was part of format.ts, which moved to the
|
|
621
|
+
// daemon package; kept here as the one remaining consumer (detail/content
|
|
622
|
+
// reshaping in this file only, not page formatting).
|
|
623
|
+
// ---------------------------------------------------------------------------
|
|
624
|
+
function omitEmpty(obj: Record<string, unknown>): Record<string, unknown> {
|
|
625
|
+
return Object.fromEntries(
|
|
626
|
+
Object.entries(obj).filter(
|
|
627
|
+
([, v]) => v !== undefined && v !== "" && v !== false && !(Array.isArray(v) && v.length === 0),
|
|
628
|
+
),
|
|
629
|
+
)
|
|
630
|
+
}
|