@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 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.10.8",
4
- "description": "Pi extension: web_fetch, web_crawl, web_search \u2014 structured scraping for AI agents",
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",
@@ -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
+ }