@danypops/pi-web-spider 0.10.8 → 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/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 { existsSync, mkdirSync, appendFileSync } from "node:fs"
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 type { SpideredPage, DOMNode } from "@danypops/web-spider"
13
- import { bodyLinks, highlightHit, leanOutput, linksOutput, markdownOutput, navLinksCount, omitEmpty } from "./format.js"
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["WEB_SPIDER_DIAG_PATH"] ?? join(homedir(), ".cache", "web-spider", "diag.log")
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 { appendFileSync(diagPath, line + "\n") } catch { /* best-effort */ }
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
- // throttle.js and robots.js become undefined via the barrel under jiti
49
- // tryNative:false (Bun binary mode) they load as side-effects of crawl.js
50
- // before index.js finishes its own re-exports. crawl() creates its own
51
- // DomainThrottle / RobotsCache internally, so we don't need to import them.
52
- //
53
- // WEB_SPIDER_CACHE_PATH — pages JSON index (default: ~/.cache/web-spider/pages.json)
54
- // WEB_SPIDER_IMAGES_PATH — large image files; DiskCache derives this from
55
- // dirname(cachePath)/images, so set only to override.
56
- // Falls back to in-memory SpiderCache if the cache path is not writable.
57
- const cache = (() => {
58
- const cachePath = process.env["WEB_SPIDER_CACHE_PATH"]
59
- ?? join(homedir(), ".cache", "web-spider", "pages.json")
60
- const imagesDir = process.env["WEB_SPIDER_IMAGES_PATH"]
61
- ?? join(homedir(), ".cache", "web-spider", "images")
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
- const dir = dirname(cachePath)
64
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
65
- if (!existsSync(imagesDir)) mkdirSync(imagesDir, { recursive: true })
66
- return new lib.DiskCache(cachePath, { maxSize: 500, ttlMs: 30 * 60 * 1000 })
67
- } catch {
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
- // Per-request helpers
82
+ // Local materialized view helpers
87
83
  // ---------------------------------------------------------------------------
88
84
 
89
- type Params = Static<typeof paramsSchema>
85
+ function pageItems(pages: Array<{ url: string; title?: string }>) {
86
+ return pages.map((page) => ({ url: page.url, title: page.title ?? "" }))
87
+ }
90
88
 
91
- // Shared throttle + robots checker for single-page spider() calls.
92
- // (crawl() creates its own internally; these cover depth=0 fetches.)
93
- const sharedThrottle = new DomainThrottle({ minDelayMs: 500 })
94
- const sharedRobots = new RobotsCache()
89
+ function output(payload: unknown, details: WebPresentationDetails) {
90
+ return createWebResult(payload, details)
91
+ }
95
92
 
96
- function buildSpiderOpts(params: Params) {
97
- return {
98
- rootSelector: params.rootSelector,
99
- excludeSelectors: params.excludeSelectors,
100
- tokenBudget: params.tokenBudget,
101
- timeoutMs: params.timeoutMs,
102
- httpClient: params.enhanced ? getPlaywrightClient() : undefined,
103
- throttle: sharedThrottle,
104
- robotsCache: sharedRobots,
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 buildFetchPage(params: Params) {
109
- const spiderOpts = buildSpiderOpts(params)
110
- return async (url: string): Promise<SpideredPage> => {
111
- let probe = "cache.get"
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
- probe = "cache.set"
135
- cache.set(url, page)
136
- corpus.push(page)
137
- probe = "graph.addPage"
138
- graph.addPage(page)
139
- return page
140
- } catch (err) {
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
- // Local materialized view helpers
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
- function cachedPages(): SpideredPage[] {
157
- return cache.values()
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(url, {
171
- maxDepth: depth,
230
+ const result = await call<Record<string, unknown>>("crawl", {
231
+ url,
232
+ format: fmt,
233
+ depth,
172
234
  maxPages: params.maxPages ?? 10,
173
- sameDomainOnly: params.sameDomain ?? true,
174
- cache,
175
- graph,
176
- onPage: (page) => corpus.push(page),
177
- ...spiderOpts,
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
- if (!params.query?.trim()) {
187
- return {
188
- content: [{ type: "text" as const, text: JSON.stringify({ error: "highlights format requires a query." }) }],
189
- details: {},
190
- }
191
- }
192
- const hits = searchPages(pages, params.query, { topN: 8, snippetRadius: 150 })
193
- return {
194
- content: [{ type: "text" as const, text: JSON.stringify({
195
- query: params.query,
196
- pagesSearched: pages.length,
197
- hits: hits.map((h) => ({
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 summary = fmt === "lean"
207
- ? { pagesFound: result.pages.size, ...errorsObj, pages: pages.map(leanOutput) }
260
+ const pages = (result.pages as Array<Record<string, unknown>> | undefined) ?? []
261
+ const content = fmt === "lean"
262
+ ? result
208
263
  : {
209
- pagesFound: result.pages.size,
210
- ...errorsObj,
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
- content: [{ type: "text" as const, text: JSON.stringify(summary) }],
217
- details: { depth, pagesFound: result.pages.size },
218
- }
219
- }
220
-
221
- // Trees are too large and volatile for DiskCache — session-scoped only.
222
- const treeCache = new Map<string, DOMNode>()
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 handleCacheListing(params: Params) {
233
- let pages = cachedPages()
234
- const total = pages.length
235
-
236
- if (params.grep?.trim()) {
237
- const pat = params.grep.toLowerCase()
238
- pages = pages.filter(
239
- (p) =>
240
- p.url.toLowerCase().includes(pat) ||
241
- p.title.toLowerCase().includes(pat) ||
242
- p.domain.toLowerCase().includes(pat) ||
243
- (p.description ?? "").toLowerCase().includes(pat),
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
- function handleCacheSearch(params: Params) {
268
- const pages = cachedPages()
269
- if (pages.length === 0) {
270
- return {
271
- content: [{ type: "text" as const, text: JSON.stringify({ error: "Local cache is empty. Fetch some pages first with depth=0 or depth>0." }) }],
272
- details: {},
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
- const topN = params.limit ?? 10
277
- const hits = searchPages(pages, params.query ?? "", { topN, snippetRadius: 150 })
278
- return {
279
- content: [{ type: "text" as const, text: JSON.stringify({
280
- ...omitEmpty({ query: params.query, pagesSearched: pages.length }),
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, fetchPage: ReturnType<typeof buildFetchPage>) {
319
+ async function handleSinglePage(params: Params) {
292
320
  const fmt = params.format ?? "markdown"
293
321
  const url = params.url ?? ""
294
322
 
295
- // ── Tree formats ───────────────────────────────────────────────────────────────
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
- // query= search the tree (atomic hits whole code blocks, whole table rows)
316
- if (params.query?.trim()) {
317
- const hits = queryTree(tree, params.query, { topN: params.topN ?? 5 })
318
- return {
319
- content: [{ type: "text" as const, text: JSON.stringify(omitEmpty({
320
- url: params.url,
321
- query: params.query,
322
- hits: hits.map((h) => omitEmpty({
323
- path: h.path,
324
- tag: h.node.tag,
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
- // no query, no path → return the full tree
336
- return {
337
- content: [{ type: "text" as const, text: JSON.stringify(tree) }],
338
- details: { format: "tree", mode: "full" },
339
- }
340
- } catch (err) {
341
- const message = err instanceof Error ? err.message : String(err)
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 page = await fetchPage(url)
345
+ const { content, cache } = splitCache(raw)
346
+ const papyrus = await maybeIngestPage(params, url)
350
347
 
351
348
  if (fmt === "lean") {
352
- return {
353
- content: [{ type: "text" as const, text: JSON.stringify(leanOutput(page)) }],
354
- details: { format: "lean", wordCount: page.wordCount },
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
- return {
360
- content: [{ type: "text" as const, text: JSON.stringify(linksOutput(page)) }],
361
- details: { format: "links", bodyLinks: bodyLinks(page).length, navLinksCount: navLinksCount(page) },
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
- if (!params.query?.trim()) {
367
- return {
368
- content: [{ type: "text" as const, text: JSON.stringify({
369
- error: "highlights format requires a query.",
370
- hint: "Pass query='what you are looking for', or use format=markdown to read the full page.",
371
- }) }],
372
- details: {},
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
- return {
388
- content: [{ type: "text" as const, text: JSON.stringify(markdownOutput(page)) }],
389
- details: { format: "markdown", wordCount: page.wordCount },
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). Truncated at a line boundary.",
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 above.
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
- const fetchPage = buildFetchPage(params)
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, fetchPage)
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
- return {
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
+ }