@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/LICENSE +21 -0
- package/package.json +8 -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 +61 -31
- package/test/fixtures/mock-pi-cli.mjs +5 -9
- package/test/helpers/fixture-server.ts +48 -0
- package/test/live-fetch.test.ts +9 -2
- package/test/load.test.ts +12 -2
- 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
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { getMarkdownTheme, type AgentToolResult, type Theme } from "@earendil-works/pi-coding-agent"
|
|
2
|
+
import { Markdown, Text, truncateToWidth, type Component, type MarkdownTheme } from "@earendil-works/pi-tui"
|
|
3
|
+
import {
|
|
4
|
+
COLLAPSED_ITEM_PREVIEW,
|
|
5
|
+
DETAILS_MAX_FIELD_CHARACTERS,
|
|
6
|
+
DETAILS_MAX_ITEMS,
|
|
7
|
+
DETAILS_MAX_SERIALIZED_CHARACTERS,
|
|
8
|
+
DETAILS_VERSION,
|
|
9
|
+
EXPANDED_PRIMARY_MAX_LINES,
|
|
10
|
+
MODEL_CONTENT_MAX_CHARACTERS,
|
|
11
|
+
} from "./constants.js"
|
|
12
|
+
|
|
13
|
+
export type WebOperation = "search" | "fetch" | "crawl" | "cache-list" | "cache-search" | "tree-full" | "tree-query" | "tree-path"
|
|
14
|
+
export type WebFormat = "search" | "markdown" | "lean" | "links" | "highlights" | "tree"
|
|
15
|
+
export type WebStatus = "ok" | "empty" | "blocked"
|
|
16
|
+
|
|
17
|
+
export interface WebItemDetails {
|
|
18
|
+
title: string
|
|
19
|
+
url: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface WebPresentationDetails {
|
|
23
|
+
version: typeof DETAILS_VERSION
|
|
24
|
+
kind: "web"
|
|
25
|
+
operation: WebOperation
|
|
26
|
+
format: WebFormat
|
|
27
|
+
status: WebStatus
|
|
28
|
+
url?: string
|
|
29
|
+
title?: string
|
|
30
|
+
query?: string
|
|
31
|
+
path?: string
|
|
32
|
+
depth?: number
|
|
33
|
+
pages?: number
|
|
34
|
+
hits?: number
|
|
35
|
+
links?: number
|
|
36
|
+
errors?: number
|
|
37
|
+
wordCount?: number
|
|
38
|
+
cache?: "hit" | "miss" | "listing" | "search"
|
|
39
|
+
enhanced?: boolean
|
|
40
|
+
blockedBy?: "robots.txt"
|
|
41
|
+
papyrusDocs?: number
|
|
42
|
+
items: WebItemDetails[]
|
|
43
|
+
truncated: boolean
|
|
44
|
+
complete: boolean
|
|
45
|
+
contentCharacters: number
|
|
46
|
+
deliveredCharacters: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CreateWebDetailsInput extends Partial<Omit<WebPresentationDetails, "version" | "kind" | "items" | "truncated" | "complete" | "contentCharacters" | "deliveredCharacters">> {
|
|
50
|
+
operation: WebOperation
|
|
51
|
+
format: WebFormat
|
|
52
|
+
items?: Array<{ title?: string; url: string }>
|
|
53
|
+
truncated?: boolean
|
|
54
|
+
complete?: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function bounded(value: unknown): string | undefined {
|
|
58
|
+
if (typeof value !== "string") return undefined
|
|
59
|
+
const normalized = value.trim()
|
|
60
|
+
if (!normalized) return undefined
|
|
61
|
+
return normalized.slice(0, DETAILS_MAX_FIELD_CHARACTERS)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const SENSITIVE_QUERY_KEY = /(?:token|key|secret|auth|signature|credential|password)/iu
|
|
65
|
+
|
|
66
|
+
export function sanitizeWebUrl(value: unknown): string | undefined {
|
|
67
|
+
const raw = bounded(value)
|
|
68
|
+
if (!raw) return undefined
|
|
69
|
+
try {
|
|
70
|
+
const url = new URL(raw)
|
|
71
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined
|
|
72
|
+
url.username = ""
|
|
73
|
+
url.password = ""
|
|
74
|
+
for (const key of [...url.searchParams.keys()]) {
|
|
75
|
+
if (SENSITIVE_QUERY_KEY.test(key)) url.searchParams.set(key, "[redacted]")
|
|
76
|
+
}
|
|
77
|
+
url.hash = ""
|
|
78
|
+
return url.toString().slice(0, DETAILS_MAX_FIELD_CHARACTERS)
|
|
79
|
+
} catch {
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function createWebDetails(input: CreateWebDetailsInput): WebPresentationDetails {
|
|
85
|
+
const items = (input.items ?? []).slice(0, DETAILS_MAX_ITEMS).flatMap((item) => {
|
|
86
|
+
const url = sanitizeWebUrl(item.url)
|
|
87
|
+
if (!url) return []
|
|
88
|
+
return [{ title: bounded(item.title) ?? url, url }]
|
|
89
|
+
})
|
|
90
|
+
return {
|
|
91
|
+
version: DETAILS_VERSION,
|
|
92
|
+
kind: "web",
|
|
93
|
+
operation: input.operation,
|
|
94
|
+
format: input.format,
|
|
95
|
+
status: input.status ?? "ok",
|
|
96
|
+
...(sanitizeWebUrl(input.url) ? { url: sanitizeWebUrl(input.url) } : {}),
|
|
97
|
+
...(bounded(input.title) ? { title: bounded(input.title) } : {}),
|
|
98
|
+
...(bounded(input.query) ? { query: bounded(input.query) } : {}),
|
|
99
|
+
...(bounded(input.path) ? { path: bounded(input.path) } : {}),
|
|
100
|
+
...(validCount(input.depth) ? { depth: input.depth } : {}),
|
|
101
|
+
...(validCount(input.pages) ? { pages: input.pages } : {}),
|
|
102
|
+
...(validCount(input.hits) ? { hits: input.hits } : {}),
|
|
103
|
+
...(validCount(input.links) ? { links: input.links } : {}),
|
|
104
|
+
...(validCount(input.errors) ? { errors: input.errors } : {}),
|
|
105
|
+
...(validCount(input.wordCount) ? { wordCount: input.wordCount } : {}),
|
|
106
|
+
...(input.cache ? { cache: input.cache } : {}),
|
|
107
|
+
...(input.enhanced ? { enhanced: true } : {}),
|
|
108
|
+
...(input.blockedBy ? { blockedBy: input.blockedBy } : {}),
|
|
109
|
+
...(validCount(input.papyrusDocs) ? { papyrusDocs: input.papyrusDocs } : {}),
|
|
110
|
+
items,
|
|
111
|
+
truncated: input.truncated ?? false,
|
|
112
|
+
complete: input.complete ?? !(input.truncated ?? false),
|
|
113
|
+
contentCharacters: 0,
|
|
114
|
+
deliveredCharacters: 0,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function truncateMarkdownPayload(payload: Record<string, unknown>, originalCharacters: number): string | undefined {
|
|
119
|
+
if (typeof payload.markdown !== "string") return undefined
|
|
120
|
+
const base = {
|
|
121
|
+
...payload,
|
|
122
|
+
markdown: "",
|
|
123
|
+
truncated: true,
|
|
124
|
+
originalCharacters,
|
|
125
|
+
hint: "Use highlights, a tree query/path, or selectors for complete evidence.",
|
|
126
|
+
}
|
|
127
|
+
let low = 0
|
|
128
|
+
let high = payload.markdown.length
|
|
129
|
+
let best = JSON.stringify(base)
|
|
130
|
+
while (low <= high) {
|
|
131
|
+
const middle = Math.floor((low + high) / 2)
|
|
132
|
+
const candidate = JSON.stringify({ ...base, markdown: payload.markdown.slice(0, middle) })
|
|
133
|
+
if (candidate.length <= MODEL_CONTENT_MAX_CHARACTERS) {
|
|
134
|
+
best = candidate
|
|
135
|
+
low = middle + 1
|
|
136
|
+
} else {
|
|
137
|
+
high = middle - 1
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return best
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function truncatePayload(serialized: string): string {
|
|
144
|
+
const base = {
|
|
145
|
+
truncated: true,
|
|
146
|
+
originalCharacters: serialized.length,
|
|
147
|
+
preview: "",
|
|
148
|
+
hint: "Use a narrower format, query, tree path, selector, or lower crawl scope for complete content.",
|
|
149
|
+
}
|
|
150
|
+
let low = 0
|
|
151
|
+
let high = Math.min(serialized.length, MODEL_CONTENT_MAX_CHARACTERS)
|
|
152
|
+
let best = JSON.stringify(base)
|
|
153
|
+
while (low <= high) {
|
|
154
|
+
const middle = Math.floor((low + high) / 2)
|
|
155
|
+
const candidate = JSON.stringify({ ...base, preview: serialized.slice(0, middle) })
|
|
156
|
+
if (candidate.length <= MODEL_CONTENT_MAX_CHARACTERS) {
|
|
157
|
+
best = candidate
|
|
158
|
+
low = middle + 1
|
|
159
|
+
} else {
|
|
160
|
+
high = middle - 1
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return best
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function createWebResult(payload: unknown, inputDetails: WebPresentationDetails) {
|
|
167
|
+
const serialized = JSON.stringify(payload)
|
|
168
|
+
const content = serialized.length <= MODEL_CONTENT_MAX_CHARACTERS
|
|
169
|
+
? serialized
|
|
170
|
+
: payload && typeof payload === "object" && !Array.isArray(payload)
|
|
171
|
+
? truncateMarkdownPayload(payload as Record<string, unknown>, serialized.length) ?? truncatePayload(serialized)
|
|
172
|
+
: truncatePayload(serialized)
|
|
173
|
+
const truncated = inputDetails.truncated || serialized.length > MODEL_CONTENT_MAX_CHARACTERS
|
|
174
|
+
const details: WebPresentationDetails = {
|
|
175
|
+
...inputDetails,
|
|
176
|
+
truncated,
|
|
177
|
+
complete: inputDetails.complete && !truncated,
|
|
178
|
+
contentCharacters: serialized.length,
|
|
179
|
+
deliveredCharacters: content.length,
|
|
180
|
+
}
|
|
181
|
+
return { content: [{ type: "text" as const, text: content }], details }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const OPERATIONS = new Set<WebOperation>(["search", "fetch", "crawl", "cache-list", "cache-search", "tree-full", "tree-query", "tree-path"])
|
|
185
|
+
const FORMATS = new Set<WebFormat>(["search", "markdown", "lean", "links", "highlights", "tree"])
|
|
186
|
+
const STATUSES = new Set<WebStatus>(["ok", "empty", "blocked"])
|
|
187
|
+
|
|
188
|
+
export function parseWebDetails(value: unknown): WebPresentationDetails | undefined {
|
|
189
|
+
try {
|
|
190
|
+
if (!value || typeof value !== "object" || JSON.stringify(value).length > DETAILS_MAX_SERIALIZED_CHARACTERS) return undefined
|
|
191
|
+
const details = value as Record<string, unknown>
|
|
192
|
+
if (details.version !== DETAILS_VERSION || details.kind !== "web") return undefined
|
|
193
|
+
if (typeof details.operation !== "string" || !OPERATIONS.has(details.operation as WebOperation)) return undefined
|
|
194
|
+
if (typeof details.format !== "string" || !FORMATS.has(details.format as WebFormat)) return undefined
|
|
195
|
+
if (typeof details.status !== "string" || !STATUSES.has(details.status as WebStatus)) return undefined
|
|
196
|
+
if (!Array.isArray(details.items) || details.items.length > DETAILS_MAX_ITEMS) return undefined
|
|
197
|
+
if (!details.items.every((item) => validItem(item))) return undefined
|
|
198
|
+
if (typeof details.truncated !== "boolean" || typeof details.complete !== "boolean") return undefined
|
|
199
|
+
if (details.truncated && details.complete) return undefined
|
|
200
|
+
if (!validCount(details.contentCharacters) || !validCount(details.deliveredCharacters) || details.deliveredCharacters > MODEL_CONTENT_MAX_CHARACTERS) return undefined
|
|
201
|
+
if (details.cache !== undefined && (typeof details.cache !== "string" || !["hit", "miss", "listing", "search"].includes(details.cache))) return undefined
|
|
202
|
+
if (details.enhanced !== undefined && typeof details.enhanced !== "boolean") return undefined
|
|
203
|
+
if (details.blockedBy !== undefined && details.blockedBy !== "robots.txt") return undefined
|
|
204
|
+
for (const field of ["url", "title", "query", "path"] as const) {
|
|
205
|
+
if (details[field] !== undefined && (typeof details[field] !== "string" || details[field].length > DETAILS_MAX_FIELD_CHARACTERS)) return undefined
|
|
206
|
+
}
|
|
207
|
+
for (const field of ["depth", "pages", "hits", "links", "errors", "wordCount", "papyrusDocs"] as const) {
|
|
208
|
+
if (details[field] !== undefined && !validCount(details[field])) return undefined
|
|
209
|
+
}
|
|
210
|
+
return value as WebPresentationDetails
|
|
211
|
+
} catch {
|
|
212
|
+
return undefined
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function validCount(value: unknown): value is number {
|
|
217
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function validItem(value: unknown): boolean {
|
|
221
|
+
if (!value || typeof value !== "object") return false
|
|
222
|
+
const item = value as Record<string, unknown>
|
|
223
|
+
return typeof item.title === "string" && item.title.length <= DETAILS_MAX_FIELD_CHARACTERS
|
|
224
|
+
&& typeof item.url === "string" && item.url.length <= DETAILS_MAX_FIELD_CHARACTERS
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function host(value: unknown): string {
|
|
228
|
+
const safe = sanitizeWebUrl(value)
|
|
229
|
+
if (!safe) return ""
|
|
230
|
+
try { return new URL(safe).hostname } catch { return safe }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function callText(args: Record<string, unknown>): string {
|
|
234
|
+
const format = typeof args.format === "string" ? args.format : "markdown"
|
|
235
|
+
if (typeof args.searchQuery === "string" && args.searchQuery.trim()) return `Search · ${args.searchQuery.trim()}`
|
|
236
|
+
if (!args.url) {
|
|
237
|
+
if (typeof args.query === "string" && args.query.trim()) return `Cache search · ${args.query.trim()}`
|
|
238
|
+
return `Cache list${typeof args.grep === "string" && args.grep.trim() ? ` · ${args.grep.trim()}` : ""}`
|
|
239
|
+
}
|
|
240
|
+
const domain = host(args.url)
|
|
241
|
+
if (format === "tree") {
|
|
242
|
+
if (typeof args.path === "string" && args.path.trim()) return `Tree path · ${domain} · ${args.path.trim()}`
|
|
243
|
+
if (typeof args.query === "string" && args.query.trim()) return `Tree query · ${domain} · ${args.query.trim()}`
|
|
244
|
+
return `Tree · ${domain}`
|
|
245
|
+
}
|
|
246
|
+
if (typeof args.depth === "number" && args.depth > 0) return `Crawl · ${domain} · depth ${Math.floor(args.depth)}`
|
|
247
|
+
return `Fetch ${format} · ${domain}`
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface WebFetchCallContext {
|
|
251
|
+
/** The previously returned call-slot component, if any — reuse per Pi's documented best practice. */
|
|
252
|
+
lastComponent: Component | undefined
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Reuses `context.lastComponent` (a `Text`) via `setText()` instead of allocating a new
|
|
257
|
+
* component on every render, per docs/extensions.md's own renderCall example — `Text`
|
|
258
|
+
* already caches its own rendered lines internally and only recomputes when the text
|
|
259
|
+
* actually changes.
|
|
260
|
+
*/
|
|
261
|
+
export function renderWebFetchCall(args: Record<string, unknown>, theme: Theme, context: WebFetchCallContext = { lastComponent: undefined }): Text {
|
|
262
|
+
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0)
|
|
263
|
+
text.setText(theme.fg("toolTitle", theme.bold(truncateToWidth(callText(args), 160))))
|
|
264
|
+
return text
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function summary(details: WebPresentationDetails): string {
|
|
268
|
+
if (details.status === "blocked") return `Blocked by ${details.blockedBy ?? "policy"} · ${host(details.url)}`
|
|
269
|
+
const suffix = [
|
|
270
|
+
details.wordCount !== undefined ? `${details.wordCount} words` : undefined,
|
|
271
|
+
details.pages !== undefined ? `${details.pages} pages` : undefined,
|
|
272
|
+
details.hits !== undefined ? `${details.hits} ${details.operation === "search" ? "results" : "hits"}` : undefined,
|
|
273
|
+
details.links !== undefined ? `${details.links} links` : undefined,
|
|
274
|
+
details.cache ? `cache ${details.cache}` : undefined,
|
|
275
|
+
details.enhanced ? "browser" : undefined,
|
|
276
|
+
details.truncated ? "truncated" : undefined,
|
|
277
|
+
details.papyrusDocs ? `${details.papyrusDocs} → mesh` : undefined,
|
|
278
|
+
].filter(Boolean).join(" · ")
|
|
279
|
+
const identity = details.title || host(details.url) || details.query
|
|
280
|
+
const action = details.operation === "search" ? "Search complete"
|
|
281
|
+
: details.operation === "crawl" ? "Crawled"
|
|
282
|
+
: details.operation === "cache-list" ? "Cached pages"
|
|
283
|
+
: details.operation === "cache-search" ? "Cache search"
|
|
284
|
+
: details.operation.startsWith("tree") ? "Tree"
|
|
285
|
+
: `Fetched ${details.format}`
|
|
286
|
+
return [action, identity, suffix].filter(Boolean).join(" · ")
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function markdownTheme(theme: Theme): MarkdownTheme {
|
|
290
|
+
let highlightCode: MarkdownTheme["highlightCode"] | undefined
|
|
291
|
+
try { highlightCode = getMarkdownTheme().highlightCode } catch { highlightCode = undefined }
|
|
292
|
+
return {
|
|
293
|
+
heading: (text) => theme.fg("mdHeading", text),
|
|
294
|
+
link: (text) => theme.fg("mdLink", text),
|
|
295
|
+
linkUrl: (text) => theme.fg("mdLinkUrl", text),
|
|
296
|
+
code: (text) => theme.fg("mdCode", text),
|
|
297
|
+
codeBlock: (text) => theme.fg("mdCodeBlock", text),
|
|
298
|
+
codeBlockBorder: (text) => theme.fg("mdCodeBlockBorder", text),
|
|
299
|
+
quote: (text) => theme.fg("mdQuote", text),
|
|
300
|
+
quoteBorder: (text) => theme.fg("mdQuoteBorder", text),
|
|
301
|
+
hr: (text) => theme.fg("mdHr", text),
|
|
302
|
+
listBullet: (text) => theme.fg("mdListBullet", text),
|
|
303
|
+
bold: (text) => theme.bold(text),
|
|
304
|
+
italic: (text) => theme.italic(text),
|
|
305
|
+
strikethrough: (text) => theme.strikethrough(text),
|
|
306
|
+
underline: (text) => theme.underline(text),
|
|
307
|
+
highlightCode: (code, language) => {
|
|
308
|
+
try { return highlightCode?.(code, language) ?? code.split("\n") } catch { return code.split("\n") }
|
|
309
|
+
},
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function primaryLines(text: string, details: WebPresentationDetails, width: number, theme: Theme): string[] {
|
|
314
|
+
let payload: unknown
|
|
315
|
+
try { payload = JSON.parse(text) } catch { return new Text(text, 0, 0).render(width) }
|
|
316
|
+
if (details.format === "markdown" && payload && typeof payload === "object" && "markdown" in payload && typeof payload.markdown === "string") {
|
|
317
|
+
const component = new Markdown(payload.markdown, 0, 0, markdownTheme(theme), { color: (value) => theme.fg("text", value) })
|
|
318
|
+
const lines = component.render(width)
|
|
319
|
+
if (lines.length <= EXPANDED_PRIMARY_MAX_LINES) return lines
|
|
320
|
+
return [...lines.slice(0, EXPANDED_PRIMARY_MAX_LINES), theme.fg("warning", `… ${lines.length - EXPANDED_PRIMARY_MAX_LINES} rendered lines omitted`)]
|
|
321
|
+
}
|
|
322
|
+
const pretty = JSON.stringify(payload, null, 2)
|
|
323
|
+
const lines = new Text(pretty, 0, 0).render(width)
|
|
324
|
+
if (lines.length <= EXPANDED_PRIMARY_MAX_LINES) return lines
|
|
325
|
+
return [...lines.slice(0, EXPANDED_PRIMARY_MAX_LINES), theme.fg("warning", `… ${lines.length - EXPANDED_PRIMARY_MAX_LINES} rendered lines omitted`)]
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function fallbackText(result: AgentToolResult<unknown>): string {
|
|
329
|
+
return result.content.filter((item) => item.type === "text").map((item) => item.text).join("\n")
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Reusable width-cached result card — mirrors the documented Pi Component performance
|
|
334
|
+
* pattern (docs/tui.md "Performance": cache render lines per width, clear on invalidate())
|
|
335
|
+
* and Papyrus's ArtifactCard shape. Constructed once per tool-call result slot and
|
|
336
|
+
* updated in place via `update()` on subsequent renders of the same call
|
|
337
|
+
* (see docs/extensions.md: "Reuse context.lastComponent when the same component
|
|
338
|
+
* instance can be updated in place").
|
|
339
|
+
*
|
|
340
|
+
* Known limitation shared with every component that pre-bakes theme colors into
|
|
341
|
+
* cached lines (see docs/tui.md "Invalidation and Theme Changes"): a bare theme
|
|
342
|
+
* switch with no new tool result calls only `invalidate()`, not `update()`, so the
|
|
343
|
+
* next render recomputes from the *previous* theme reference until this call's
|
|
344
|
+
* result changes again. This is the same accepted trade-off Papyrus's ArtifactCard/
|
|
345
|
+
* ArtifactListCard/TaskHierarchyPreview already ship with in production.
|
|
346
|
+
*/
|
|
347
|
+
export class WebResultCard implements Component {
|
|
348
|
+
private result: AgentToolResult<unknown>
|
|
349
|
+
private details: WebPresentationDetails
|
|
350
|
+
private expanded: boolean
|
|
351
|
+
private theme: Theme
|
|
352
|
+
private cachedWidth: number | undefined
|
|
353
|
+
private cachedLines: string[] | undefined
|
|
354
|
+
|
|
355
|
+
constructor(result: AgentToolResult<unknown>, details: WebPresentationDetails, expanded: boolean, theme: Theme) {
|
|
356
|
+
this.result = result
|
|
357
|
+
this.details = details
|
|
358
|
+
this.expanded = expanded
|
|
359
|
+
this.theme = theme
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
update(result: AgentToolResult<unknown>, details: WebPresentationDetails, expanded: boolean, theme: Theme): void {
|
|
363
|
+
this.result = result
|
|
364
|
+
this.details = details
|
|
365
|
+
this.expanded = expanded
|
|
366
|
+
this.theme = theme
|
|
367
|
+
this.invalidate()
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
render(width: number): string[] {
|
|
371
|
+
const safeWidth = Math.max(1, width)
|
|
372
|
+
if (this.cachedLines && this.cachedWidth === safeWidth) return this.cachedLines
|
|
373
|
+
|
|
374
|
+
const details = this.details
|
|
375
|
+
const theme = this.theme
|
|
376
|
+
const expanded = this.expanded
|
|
377
|
+
const color = details.status === "blocked" ? "warning" : details.status === "empty" ? "muted" : "success"
|
|
378
|
+
const lines = [truncateToWidth(theme.fg(color, summary(details)), safeWidth)]
|
|
379
|
+
const shown = expanded ? details.items : details.items.slice(0, COLLAPSED_ITEM_PREVIEW)
|
|
380
|
+
for (const item of shown) {
|
|
381
|
+
lines.push(truncateToWidth(theme.fg("accent", ` ${item.title}`), safeWidth))
|
|
382
|
+
if (expanded) lines.push(truncateToWidth(theme.fg("dim", ` ${item.url}`), safeWidth))
|
|
383
|
+
}
|
|
384
|
+
if (!expanded && details.items.length > shown.length) lines.push(theme.fg("muted", ` … ${details.items.length - shown.length} more`))
|
|
385
|
+
|
|
386
|
+
let finalLines = lines
|
|
387
|
+
if (expanded) {
|
|
388
|
+
const text = fallbackText(this.result)
|
|
389
|
+
if (text) finalLines = [...lines, "", ...primaryLines(text, details, safeWidth, theme)]
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
this.cachedWidth = safeWidth
|
|
393
|
+
this.cachedLines = finalLines
|
|
394
|
+
return finalLines
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
invalidate(): void {
|
|
398
|
+
this.cachedWidth = undefined
|
|
399
|
+
this.cachedLines = undefined
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export interface WebFetchResultContext {
|
|
404
|
+
isPartial: boolean
|
|
405
|
+
/** The previously returned result-slot component, if any — reuse per Pi's documented best practice. */
|
|
406
|
+
lastComponent: Component | undefined
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export function renderWebFetchResult(
|
|
410
|
+
result: AgentToolResult<unknown>,
|
|
411
|
+
options: { expanded: boolean; isPartial: boolean },
|
|
412
|
+
theme: Theme,
|
|
413
|
+
context: WebFetchResultContext,
|
|
414
|
+
): Component {
|
|
415
|
+
const details = parseWebDetails(result.details)
|
|
416
|
+
if (options.isPartial || context.isPartial) {
|
|
417
|
+
const activity = details?.operation === "search" ? "Searching the web…"
|
|
418
|
+
: details?.operation === "crawl" ? "Crawling pages…"
|
|
419
|
+
: "Fetching web content…"
|
|
420
|
+
return new Text(theme.fg("accent", activity), 0, 0)
|
|
421
|
+
}
|
|
422
|
+
if (!details) return new Text(fallbackText(result), 0, 0)
|
|
423
|
+
|
|
424
|
+
const previous = context.lastComponent instanceof WebResultCard ? context.lastComponent : undefined
|
|
425
|
+
if (previous) {
|
|
426
|
+
previous.update(result, details, options.expanded, theme)
|
|
427
|
+
return previous
|
|
428
|
+
}
|
|
429
|
+
return new WebResultCard(result, details, options.expanded, theme)
|
|
430
|
+
}
|
package/test/cache-paths.test.ts
CHANGED
|
@@ -3,85 +3,53 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Two paths in execute() that were previously uncovered:
|
|
5
5
|
*
|
|
6
|
-
* 1. Cache hit — when a URL has already been fetched
|
|
7
|
-
*
|
|
8
|
-
* the network again.
|
|
9
|
-
*
|
|
6
|
+
* 1. Cache hit — when a URL has already been fetched, web_fetch(url)
|
|
7
|
+
* returns the cached page without a real second fetch.
|
|
10
8
|
* 2. Cache search — web_fetch({ query }) with no url searches all cached
|
|
11
9
|
* pages using BM25F and returns ranked hits.
|
|
12
10
|
*
|
|
13
|
-
* Both paths use a single harness session so the cache accumulates
|
|
14
|
-
* across calls as it would in a real Pi session.
|
|
11
|
+
* Both paths use a single harness session so the daemon's cache accumulates
|
|
12
|
+
* state across calls as it would in a real Pi session. Pages are served by
|
|
13
|
+
* a real local HTTP fixture server and fetched by the real (isolated) Web
|
|
14
|
+
* Spider daemon — mocking globalThis.fetch in this process no longer works
|
|
15
|
+
* once fetching moved into the daemon's own process.
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
import { readFileSync } from "node:fs"
|
|
18
19
|
import { dirname, join } from "node:path"
|
|
19
20
|
import { fileURLToPath } from "node:url"
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
describe,
|
|
25
|
-
expect,
|
|
26
|
-
it,
|
|
27
|
-
vi,
|
|
28
|
-
} from "vitest"
|
|
29
|
-
import {
|
|
30
|
-
createExtensionHarness,
|
|
31
|
-
type ExtensionHarness,
|
|
32
|
-
} from "./harness/index.ts"
|
|
21
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
|
22
|
+
import { createExtensionHarness, type ExtensionHarness } from "./harness/index.ts"
|
|
23
|
+
import { isolatedDaemonEnv, type IsolatedDaemonEnv } from "./daemon-isolation.js"
|
|
24
|
+
import { startFixtureServer, type FixtureServer } from "./helpers/fixture-server.js"
|
|
33
25
|
|
|
34
26
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
35
27
|
const FIXTURES = join(__dirname, "../../web-spider/fixtures")
|
|
36
28
|
const ARTICLE_HTML = readFileSync(join(FIXTURES, "article-with-images.html"), "utf8")
|
|
37
29
|
|
|
38
|
-
const URL_A = "https://example.com/article-a"
|
|
39
|
-
const URL_B = "https://example.com/article-b"
|
|
40
|
-
|
|
41
|
-
// ---------------------------------------------------------------------------
|
|
42
|
-
// Helpers
|
|
43
|
-
// ---------------------------------------------------------------------------
|
|
44
|
-
|
|
45
|
-
function makeOkResponse(html: string) {
|
|
46
|
-
return {
|
|
47
|
-
ok: true,
|
|
48
|
-
status: 200,
|
|
49
|
-
statusText: "OK",
|
|
50
|
-
headers: { get: () => null },
|
|
51
|
-
text: async () => html,
|
|
52
|
-
arrayBuffer: async () => new ArrayBuffer(0),
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// ---------------------------------------------------------------------------
|
|
57
|
-
// Single shared session — cache builds up across tests
|
|
58
|
-
// ---------------------------------------------------------------------------
|
|
59
|
-
|
|
60
30
|
let h: ExtensionHarness
|
|
61
|
-
let
|
|
31
|
+
let isolated: IsolatedDaemonEnv
|
|
32
|
+
let server: FixtureServer
|
|
33
|
+
let URL_A: string
|
|
34
|
+
let URL_B: string
|
|
62
35
|
|
|
63
36
|
beforeAll(async () => {
|
|
37
|
+
isolated = isolatedDaemonEnv("pi-web-spider-cache-paths-test-")
|
|
38
|
+
server = await startFixtureServer()
|
|
39
|
+
server.set("/article-a", ARTICLE_HTML)
|
|
40
|
+
server.set("/article-b", ARTICLE_HTML)
|
|
41
|
+
URL_A = `${server.baseUrl}/article-a`
|
|
42
|
+
URL_B = `${server.baseUrl}/article-b`
|
|
43
|
+
|
|
64
44
|
const { default: factory } = await import("../src/index.js")
|
|
65
|
-
h = createExtensionHarness(factory, {
|
|
66
|
-
cwd: "/tmp",
|
|
67
|
-
env: { WEB_SPIDER_CACHE_PATH: "/tmp/ws-cache-paths-test.json" },
|
|
68
|
-
})
|
|
45
|
+
h = createExtensionHarness(factory, { cwd: "/tmp", env: isolated.env })
|
|
69
46
|
await h.boot()
|
|
70
47
|
})
|
|
71
48
|
|
|
72
49
|
afterAll(async () => {
|
|
73
50
|
await h.shutdown()
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
beforeEach(() => {
|
|
77
|
-
fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
78
|
-
const url = input.toString()
|
|
79
|
-
if (url.includes("robots.txt")) return makeOkResponse("User-agent: *\nAllow: /")
|
|
80
|
-
if (url.includes("sitemap")) return { ...makeOkResponse(""), status: 404, ok: false }
|
|
81
|
-
if (url.startsWith("https://example.com")) return makeOkResponse(ARTICLE_HTML)
|
|
82
|
-
return { ...makeOkResponse(""), status: 404, ok: false }
|
|
83
|
-
})
|
|
84
|
-
vi.stubGlobal("fetch", fetchMock)
|
|
51
|
+
await server.close()
|
|
52
|
+
isolated.cleanup()
|
|
85
53
|
})
|
|
86
54
|
|
|
87
55
|
// ---------------------------------------------------------------------------
|
|
@@ -90,7 +58,6 @@ beforeEach(() => {
|
|
|
90
58
|
|
|
91
59
|
describe("cache listing path", () => {
|
|
92
60
|
it("returns total=0 and empty pages on a cold cache", async () => {
|
|
93
|
-
// Fresh harness in beforeAll, no fetches yet.
|
|
94
61
|
const result = await h.invokeTool("web_fetch", {}) as { content: { text: string }[] }
|
|
95
62
|
const text = JSON.parse(result.content[0].text)
|
|
96
63
|
|
|
@@ -107,34 +74,23 @@ describe("cache listing path", () => {
|
|
|
107
74
|
|
|
108
75
|
describe("cache hit path", () => {
|
|
109
76
|
it("fetches URL_A on first call", async () => {
|
|
110
|
-
const result = await h.invokeTool("web_fetch", {
|
|
111
|
-
url: URL_A,
|
|
112
|
-
format: "lean",
|
|
113
|
-
}) as { content: { text: string }[] }
|
|
77
|
+
const result = await h.invokeTool("web_fetch", { url: URL_A, format: "lean" }) as { content: { text: string }[]; details: Record<string, unknown> }
|
|
114
78
|
const text = JSON.parse(result.content[0].text)
|
|
115
79
|
|
|
116
80
|
expect(text).not.toHaveProperty("error")
|
|
117
81
|
expect(text.wordCount).toBeGreaterThan(0)
|
|
82
|
+
expect(result.details.cache).toBe("miss")
|
|
118
83
|
})
|
|
119
84
|
|
|
120
85
|
it("returns the cached page on second call without hitting the network", async () => {
|
|
121
|
-
|
|
122
|
-
fetchMock.mockClear()
|
|
123
|
-
|
|
124
|
-
const result = await h.invokeTool("web_fetch", {
|
|
125
|
-
url: URL_A,
|
|
126
|
-
format: "lean",
|
|
127
|
-
}) as { content: { text: string }[] }
|
|
86
|
+
const result = await h.invokeTool("web_fetch", { url: URL_A, format: "lean" }) as { content: { text: string }[]; details: Record<string, unknown> }
|
|
128
87
|
const text = JSON.parse(result.content[0].text)
|
|
129
88
|
|
|
130
89
|
expect(text).not.toHaveProperty("error")
|
|
131
90
|
expect(text.wordCount).toBeGreaterThan(0)
|
|
132
|
-
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
([input]: [RequestInfo | URL]) => input.toString().startsWith(URL_A)
|
|
136
|
-
)
|
|
137
|
-
expect(mainUrlCalls).toHaveLength(0)
|
|
91
|
+
// The daemon's own cache reports this as a hit — the authoritative signal
|
|
92
|
+
// that no second real fetch happened (replaces the old fetch-mock-call-count check).
|
|
93
|
+
expect(result.details.cache).toBe("hit")
|
|
138
94
|
})
|
|
139
95
|
|
|
140
96
|
it("cache listing shows URL_A after it has been fetched", async () => {
|
|
@@ -153,18 +109,13 @@ describe("cache hit path", () => {
|
|
|
153
109
|
|
|
154
110
|
describe("cache search path", () => {
|
|
155
111
|
it("fetches URL_B to populate the cache", async () => {
|
|
156
|
-
const result = await h.invokeTool("web_fetch", {
|
|
157
|
-
url: URL_B,
|
|
158
|
-
format: "lean",
|
|
159
|
-
}) as { content: { text: string }[] }
|
|
112
|
+
const result = await h.invokeTool("web_fetch", { url: URL_B, format: "lean" }) as { content: { text: string }[] }
|
|
160
113
|
const text = JSON.parse(result.content[0].text)
|
|
161
114
|
expect(text).not.toHaveProperty("error")
|
|
162
115
|
})
|
|
163
116
|
|
|
164
117
|
it("returns BM25F hits when query matches cached content", async () => {
|
|
165
|
-
const result = await h.invokeTool("web_fetch", {
|
|
166
|
-
query: "image scraping web spider",
|
|
167
|
-
}) as { content: { text: string }[] }
|
|
118
|
+
const result = await h.invokeTool("web_fetch", { query: "image scraping web spider" }) as { content: { text: string }[] }
|
|
168
119
|
const text = JSON.parse(result.content[0].text)
|
|
169
120
|
|
|
170
121
|
expect(text).not.toHaveProperty("error")
|
|
@@ -176,9 +127,7 @@ describe("cache search path", () => {
|
|
|
176
127
|
})
|
|
177
128
|
|
|
178
129
|
it("each hit has url, score, and text", async () => {
|
|
179
|
-
const result = await h.invokeTool("web_fetch", {
|
|
180
|
-
query: "image scraping",
|
|
181
|
-
}) as { content: { text: string }[] }
|
|
130
|
+
const result = await h.invokeTool("web_fetch", { query: "image scraping" }) as { content: { text: string }[] }
|
|
182
131
|
const text = JSON.parse(result.content[0].text)
|
|
183
132
|
const hit = text.hits[0]
|
|
184
133
|
|
|
@@ -190,9 +139,9 @@ describe("cache search path", () => {
|
|
|
190
139
|
it("returns zero hits gracefully for a query with no matches", async () => {
|
|
191
140
|
const result = await h.invokeTool("web_fetch", {
|
|
192
141
|
// Use a single token with no vowels — avoids the hyphen-splitting bug where
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
142
|
+
// "xyzzy-no-such-content-ever-12345" tokenises to [xyzzy, no, such, content,
|
|
143
|
+
// ever, 12345] and "content" literally matches the article fixture.
|
|
144
|
+
query: "zxqfkwjpvm",
|
|
196
145
|
}) as { content: { text: string }[] }
|
|
197
146
|
const text = JSON.parse(result.content[0].text)
|
|
198
147
|
|
|
@@ -202,9 +151,7 @@ describe("cache search path", () => {
|
|
|
202
151
|
})
|
|
203
152
|
|
|
204
153
|
it("grep= filter narrows cache listing results", async () => {
|
|
205
|
-
const result = await h.invokeTool("web_fetch", {
|
|
206
|
-
grep: "article-a",
|
|
207
|
-
}) as { content: { text: string }[] }
|
|
154
|
+
const result = await h.invokeTool("web_fetch", { grep: "article-a" }) as { content: { text: string }[] }
|
|
208
155
|
const text = JSON.parse(result.content[0].text)
|
|
209
156
|
|
|
210
157
|
expect(text).not.toHaveProperty("error")
|