@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel Popsuevich
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/pi-web-spider",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
3
|
+
"version": "0.11.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "Pi extension: web_fetch, web_crawl, web_search — structured scraping for AI agents",
|
|
5
6
|
"keywords": [
|
|
6
7
|
"pi-package"
|
|
7
8
|
],
|
|
@@ -13,10 +14,12 @@
|
|
|
13
14
|
]
|
|
14
15
|
},
|
|
15
16
|
"dependencies": {
|
|
16
|
-
"@danypops/web-spider": "
|
|
17
|
+
"@danypops/web-spider": "^0.11.0",
|
|
18
|
+
"@danypops/web-spider-daemon": "^0.11.0"
|
|
17
19
|
},
|
|
18
20
|
"peerDependencies": {
|
|
19
|
-
"@earendil-works/pi-coding-agent": "*"
|
|
21
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
22
|
+
"@earendil-works/pi-tui": "*"
|
|
20
23
|
},
|
|
21
24
|
"scripts": {
|
|
22
25
|
"test": "vitest --run",
|
|
@@ -26,6 +29,7 @@
|
|
|
26
29
|
"devDependencies": {
|
|
27
30
|
"@biomejs/biome": "^2.4.15",
|
|
28
31
|
"@earendil-works/pi-coding-agent": "^0.80.10",
|
|
32
|
+
"@earendil-works/pi-tui": "^0.80.10",
|
|
29
33
|
"@types/node": "^22.19.19",
|
|
30
34
|
"jiti": "^2.0.0",
|
|
31
35
|
"typescript": "^5.9.3",
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const MODEL_CONTENT_MAX_CHARACTERS = 50_000
|
|
2
|
+
export const DETAILS_VERSION = 1 as const
|
|
3
|
+
export const DETAILS_MAX_SERIALIZED_CHARACTERS = 24_000
|
|
4
|
+
export const DETAILS_MAX_ITEMS = 20
|
|
5
|
+
export const DETAILS_MAX_FIELD_CHARACTERS = 500
|
|
6
|
+
export const COLLAPSED_ITEM_PREVIEW = 3
|
|
7
|
+
export const EXPANDED_PRIMARY_MAX_LINES = 240
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authenticated Web Spider daemon client — ported from
|
|
3
|
+
* @danypops/web-spider-daemon's client.ts/state.ts (the Bun-independent
|
|
4
|
+
* subset: node:fs/os/path/crypto and global fetch only, no bun:sqlite).
|
|
5
|
+
*
|
|
6
|
+
* This is an intentional, acknowledged duplication rather than an npm
|
|
7
|
+
* dependency on @danypops/web-spider-daemon's TypeScript source: that
|
|
8
|
+
* package ships raw TS run only via a real `bun` invocation (its db.ts/
|
|
9
|
+
* cli.ts import bun:sqlite), while this extension is loaded by Pi through
|
|
10
|
+
* three different paths verified by this package's own test suite (native
|
|
11
|
+
* ESM, jiti tryNative:false, jiti tryNative:true + Bun binary) — none of
|
|
12
|
+
* which reliably transpile a *dependency's* raw TypeScript. Duplicating
|
|
13
|
+
* this small, dependency-free client (~150 lines) avoids adding a new,
|
|
14
|
+
* unverified cross-package loader interaction on top of that already
|
|
15
|
+
* fragile history (see git log: "Map operation called on non-Map object").
|
|
16
|
+
* connectOrStartWebSpiderClient() only needs @danypops/web-spider-daemon
|
|
17
|
+
* installed as *files on disk* (to locate and spawn its cli.ts) — it never
|
|
18
|
+
* imports that package's code.
|
|
19
|
+
*/
|
|
20
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
21
|
+
import { createRequire } from "node:module"
|
|
22
|
+
import { homedir } from "node:os"
|
|
23
|
+
import { dirname, join } from "node:path"
|
|
24
|
+
import { randomBytes } from "node:crypto"
|
|
25
|
+
import { spawn } from "node:child_process"
|
|
26
|
+
|
|
27
|
+
const LOOPBACK_HOST = "127.0.0.1"
|
|
28
|
+
const WEB_SPIDER_STATE_DIRECTORY = "web-spider"
|
|
29
|
+
const TOKEN_FILENAME = "auth-token"
|
|
30
|
+
const HANDLE_FILENAME = "daemon.json"
|
|
31
|
+
const DAEMON_START_TIMEOUT_MS = 5_000
|
|
32
|
+
const DAEMON_START_POLL_INTERVAL_MS = 100
|
|
33
|
+
|
|
34
|
+
export interface WebSpiderPaths {
|
|
35
|
+
database: string
|
|
36
|
+
token: string
|
|
37
|
+
handle: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface DaemonHandle {
|
|
41
|
+
host: typeof LOOPBACK_HOST
|
|
42
|
+
port: number
|
|
43
|
+
pid: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface PathEnvironment {
|
|
47
|
+
env?: Record<string, string | undefined>
|
|
48
|
+
home?: string
|
|
49
|
+
uid?: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resolveWebSpiderPaths(options: PathEnvironment = {}): WebSpiderPaths {
|
|
53
|
+
const env = options.env ?? process.env
|
|
54
|
+
const home = options.home ?? homedir()
|
|
55
|
+
const uid = options.uid ?? process.getuid?.() ?? 0
|
|
56
|
+
const dataHome = env.XDG_DATA_HOME ?? join(home, ".local", "share")
|
|
57
|
+
const stateHome = env.XDG_STATE_HOME ?? join(home, ".local", "state")
|
|
58
|
+
const runtimeHome = env.XDG_RUNTIME_DIR ?? join("/run", "user", String(uid))
|
|
59
|
+
return {
|
|
60
|
+
database: join(dataHome, WEB_SPIDER_STATE_DIRECTORY, "web-spider.db"),
|
|
61
|
+
token: join(stateHome, WEB_SPIDER_STATE_DIRECTORY, TOKEN_FILENAME),
|
|
62
|
+
handle: join(runtimeHome, WEB_SPIDER_STATE_DIRECTORY, HANDLE_FILENAME),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function ensureAuthToken(paths: WebSpiderPaths): string {
|
|
67
|
+
mkdirSync(dirname(paths.token), { recursive: true, mode: 0o700 })
|
|
68
|
+
if (existsSync(paths.token)) {
|
|
69
|
+
chmodSync(paths.token, 0o600)
|
|
70
|
+
const token = readFileSync(paths.token, "utf8").trim()
|
|
71
|
+
if (!/^[a-f0-9]{64}$/.test(token)) throw new Error("invalid Web Spider authentication token")
|
|
72
|
+
return token
|
|
73
|
+
}
|
|
74
|
+
const token = randomBytes(32).toString("hex")
|
|
75
|
+
writeFileSync(paths.token, `${token}\n`, { mode: 0o600 })
|
|
76
|
+
return token
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function readDaemonHandle(paths: WebSpiderPaths): DaemonHandle | null {
|
|
80
|
+
try {
|
|
81
|
+
const value = JSON.parse(readFileSync(paths.handle, "utf8")) as Partial<DaemonHandle>
|
|
82
|
+
if (value.host !== LOOPBACK_HOST || !Number.isInteger(value.port) || (value.port as number) < 1 || (value.port as number) > 65_535 || !Number.isInteger(value.pid)) return null
|
|
83
|
+
return value as DaemonHandle
|
|
84
|
+
} catch {
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type FetchTransport = (request: Request) => Promise<Response>
|
|
90
|
+
|
|
91
|
+
export class WebSpiderClient {
|
|
92
|
+
constructor(
|
|
93
|
+
private readonly baseUrl: string,
|
|
94
|
+
private readonly token: string,
|
|
95
|
+
private readonly transport: FetchTransport = fetch,
|
|
96
|
+
) {}
|
|
97
|
+
|
|
98
|
+
async call<T = unknown>(operation: string, input: Record<string, unknown>): Promise<T> {
|
|
99
|
+
const response = await this.transport(new Request(`${this.baseUrl}/api/v1/ops`, {
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: { authorization: `Bearer ${this.token}`, "content-type": "application/json" },
|
|
102
|
+
body: JSON.stringify({ op: operation, input }),
|
|
103
|
+
}))
|
|
104
|
+
const body = await response.json() as { result?: T; error?: string }
|
|
105
|
+
if (!response.ok) throw new Error(body.error ?? `Web Spider operation failed with HTTP ${response.status}`)
|
|
106
|
+
return body.result as T
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async health(): Promise<{ ok: true; version: string }> {
|
|
110
|
+
const response = await this.transport(new Request(`${this.baseUrl}/health`, {
|
|
111
|
+
headers: { authorization: `Bearer ${this.token}` },
|
|
112
|
+
}))
|
|
113
|
+
const body = await response.json() as { ok?: boolean; version?: string; error?: string }
|
|
114
|
+
if (!response.ok || body.ok !== true || typeof body.version !== "string") throw new Error(body.error ?? "Web Spider health check failed")
|
|
115
|
+
return { ok: true, version: body.version }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function connectWebSpiderClient(paths: WebSpiderPaths = resolveWebSpiderPaths()): WebSpiderClient {
|
|
120
|
+
const handle = readDaemonHandle(paths)
|
|
121
|
+
if (!handle) throw new Error("Web Spider daemon is not running; install or start web-spider.service")
|
|
122
|
+
const token = ensureAuthToken(paths)
|
|
123
|
+
return new WebSpiderClient(`http://${handle.host}:${handle.port}`, token)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Resolves the installed @danypops/web-spider-daemon package's cli.ts on disk — no code import, path only. */
|
|
127
|
+
function resolveDaemonCliPath(): string {
|
|
128
|
+
const require = createRequire(import.meta.url)
|
|
129
|
+
const packageJsonPath = require.resolve("@danypops/web-spider-daemon/package.json")
|
|
130
|
+
return join(dirname(packageJsonPath), "src", "cli.ts")
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function waitForHandle(paths: WebSpiderPaths, timeoutMs: number): Promise<boolean> {
|
|
134
|
+
const deadline = Date.now() + timeoutMs
|
|
135
|
+
while (Date.now() < deadline) {
|
|
136
|
+
if (readDaemonHandle(paths)) return true
|
|
137
|
+
await new Promise((resolve) => setTimeout(resolve, DAEMON_START_POLL_INTERVAL_MS))
|
|
138
|
+
}
|
|
139
|
+
return false
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Connects to the Web Spider daemon, transparently starting it first if it
|
|
144
|
+
* is not already running — the tool must "just work" without a manual
|
|
145
|
+
* `web-spider service install` step for a fresh install, matching today's
|
|
146
|
+
* zero-config DiskCache behavior. Falls back to a clear actionable error if
|
|
147
|
+
* auto-start fails (e.g. bun is not on PATH, or the package files are
|
|
148
|
+
* missing), pointing at manual installation instead of failing silently.
|
|
149
|
+
*/
|
|
150
|
+
export interface ConnectOrStartOptions {
|
|
151
|
+
/**
|
|
152
|
+
* Environment passed to the spawned daemon process. Defaults to the
|
|
153
|
+
* current process.env, so a transparently auto-started daemon sees the
|
|
154
|
+
* same XDG/API-key environment the extension itself sees — production
|
|
155
|
+
* behavior. Tests override this (full env plus isolated XDG_* overrides)
|
|
156
|
+
* so the spawned child and the parent's own resolveWebSpiderPaths() agree
|
|
157
|
+
* on where the handle file lives.
|
|
158
|
+
*/
|
|
159
|
+
env?: Record<string, string | undefined>
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function connectOrStartWebSpiderClient(
|
|
163
|
+
paths: WebSpiderPaths = resolveWebSpiderPaths(),
|
|
164
|
+
options: ConnectOrStartOptions = {},
|
|
165
|
+
): Promise<WebSpiderClient> {
|
|
166
|
+
if (readDaemonHandle(paths)) {
|
|
167
|
+
try {
|
|
168
|
+
return connectWebSpiderClient(paths)
|
|
169
|
+
} catch {
|
|
170
|
+
// Stale/unreadable handle — fall through and (re)start.
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let cliPath: string
|
|
175
|
+
try {
|
|
176
|
+
cliPath = resolveDaemonCliPath()
|
|
177
|
+
} catch (error) {
|
|
178
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
179
|
+
throw new Error(`Web Spider daemon package not found (${message}); run \`packed install npm:@danypops/web-spider-daemon\` then \`web-spider service install\`.`)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const child = spawn(cliPath, ["serve"], { detached: true, stdio: "ignore", env: options.env ?? process.env })
|
|
183
|
+
child.unref()
|
|
184
|
+
|
|
185
|
+
const started = await waitForHandle(paths, DAEMON_START_TIMEOUT_MS)
|
|
186
|
+
if (!started) {
|
|
187
|
+
throw new Error("Web Spider daemon failed to start automatically; run `web-spider service install` or `web-spider serve` manually.")
|
|
188
|
+
}
|
|
189
|
+
return connectWebSpiderClient(paths)
|
|
190
|
+
}
|