pi-unsloth-webtools 0.9.0 → 0.10.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/index.ts CHANGED
@@ -10,7 +10,13 @@ import {
10
10
  type FetchPageOutcome,
11
11
  } from "./web-fetch.ts";
12
12
  import { renderPageWithLightpanda as defaultRenderLocalPageText } from "./lightpanda.ts";
13
- import { loadDefaultFetchSettings, loadDefaultFetchTimeoutMs, loadLightpandaSettings } from "./settings.ts";
13
+ import {
14
+ loadDefaultEngines,
15
+ loadDefaultEngineWeights,
16
+ loadDefaultFetchSettings,
17
+ loadDefaultMaxPerHost,
18
+ loadLightpandaSettings,
19
+ } from "./settings.ts";
14
20
 
15
21
  function toolCallLine(theme: Theme, name: string, detail: string) {
16
22
  const line = theme.fg("toolTitle", theme.bold(name)) + (detail ? ` ${theme.fg("accent", detail)}` : "");
@@ -179,12 +185,20 @@ export function createWebTools(deps: WebToolsDeps = {}) {
179
185
  onUpdate?.({ content: [{ type: "text", text: "Searching the web..." }], details: {} });
180
186
  const timeoutParam = positiveNumber(params.timeoutMs);
181
187
  const searchCwd = (_ctx as ExtensionContext | undefined)?.cwd;
182
- const searchTimeoutMs = timeoutParam ?? (await loadDefaultFetchTimeoutMs(searchCwd)) ?? SEARCH_TIMEOUT_MS;
188
+ const searchSettings = await loadDefaultFetchSettings(searchCwd);
189
+ const searchEngines = await loadDefaultEngines(searchCwd);
190
+ const searchEngineWeights = await loadDefaultEngineWeights(searchCwd);
191
+ const searchMaxPerHost = await loadDefaultMaxPerHost(searchCwd);
192
+ const searchTimeoutMs = timeoutParam ?? searchSettings.timeoutMs ?? SEARCH_TIMEOUT_MS;
183
193
  const text = await webSearch(params.query, {
184
194
  signal: signal ?? undefined,
185
195
  timeoutMs: searchTimeoutMs,
186
196
  maxResults: positiveNumber(params.maxResults),
187
197
  cwd: searchCwd,
198
+ transport: searchSettings.transport,
199
+ engines: searchEngines,
200
+ engineWeights: searchEngineWeights,
201
+ maxPerHost: searchMaxPerHost,
188
202
  });
189
203
  return { content: [{ type: "text", text }], details: {} };
190
204
  },
package/lightpanda.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import { spawn as spawnProcess } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
2
5
  import { visibleChars } from "./html-to-md.ts";
3
6
  import {
4
7
  checkUrlAccess,
@@ -201,11 +204,31 @@ function withProvenance(prefixed: string, url: string): string {
201
204
  return [...lines.slice(0, index), ...header, ...lines.slice(index)].join("\n");
202
205
  }
203
206
 
207
+ function installedLauncher(): string | null {
208
+ let dataHome = process.env.XDG_DATA_HOME?.trim();
209
+ if (!dataHome) {
210
+ try {
211
+ const home = homedir();
212
+ if (!home) return null;
213
+ dataHome = join(home, ".local", "share");
214
+ } catch {
215
+ return null;
216
+ }
217
+ }
218
+ const dir = join(dataHome, "pi-unsloth-webtools", "lightpanda");
219
+ for (const name of ["lightpanda-run", "lightpanda"]) {
220
+ const candidate = join(dir, name);
221
+ if (existsSync(candidate)) return candidate;
222
+ }
223
+ return null;
224
+ }
225
+
204
226
  export function lightpandaBinary(options: LightpandaRenderOptions = {}): string {
205
227
  return (
206
228
  options.binaryPath ??
207
229
  options.settings?.binaryPath ??
208
230
  process.env.PI_LIGHTPANDA_BIN?.trim() ??
231
+ installedLauncher() ??
209
232
  DEFAULT_BINARY
210
233
  );
211
234
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-unsloth-webtools",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "description": "Pi extension: web_search and web_fetch tools that began as a port of the Unsloth Studio codebase and now diverge from it (multi-engine search, opt-in SSRF guard, HTML-to-Markdown extraction)",
6
6
  "main": "index.ts",
@@ -59,10 +59,8 @@
59
59
  "test:unit": "vitest run --exclude **/smoke.test.ts",
60
60
  "test:smoke": "vitest run test/smoke.test.ts",
61
61
  "test:lightpanda": "vitest run test/lightpanda-live.test.ts",
62
- "compare:fetch": "node scripts/fetch-compare.ts",
63
- "compare:browsers": "node scripts/compare-browsers.ts",
64
62
  "camoufox:warmup": "node scripts/camoufox-warmup.ts",
65
- "stealth:matrix": "node scripts/stealth-matrix.ts",
63
+ "engine:eval": "node scripts/engine-eval.ts",
66
64
  "typecheck": "tsc --noEmit",
67
65
  "check:package": "node -e \"const fs=require('fs');const pkg=require('./package.json');const listed=new Set(pkg.files);const bad=[];for(const f of pkg.files){if(!fs.existsSync(f))bad.push('missing file: '+f)}for(const f of fs.readdirSync('.').filter(f=>f.endsWith('.ts'))){if(!listed.has(f))bad.push('unlisted source: '+f)}if(bad.length){console.error(bad.join('\\n'));process.exit(1)}\"",
68
66
  "prepublishOnly": "npm run typecheck && npm run lint && npm run test:unit && npm run check:package && npm run check:publint",
@@ -10,6 +10,7 @@ VERSION=""
10
10
  FORCE=0
11
11
  ALLOW_SHIM=1
12
12
  PRINT_PATH=0
13
+ NO_CONFIGURE=0
13
14
 
14
15
  usage() {
15
16
  cat <<'EOF'
@@ -23,12 +24,15 @@ Usage: bash scripts/install-lightpanda.sh [options]
23
24
  --force Reinstall even when a working binary is already present
24
25
  --no-shim Fail instead of bundling a newer libc when the system glibc is too old
25
26
  --print-path Print only the launcher path (for scripts and CI)
27
+ --no-configure Skip writing webRender.lightpandaPath into the global settings
26
28
  -h, --help Show this message
27
29
 
28
30
  On Linux, if the release needs a newer glibc than the system provides, the script
29
31
  downloads Debian's libc6 for the current stable suite, extracts it into the install
30
- directory, and writes a launcher shim that runs the binary with that libc. Point
31
- `webRender.lightpandaPath` (settings.json) or `PI_LIGHTPANDA_BIN` at the printed path.
32
+ directory, and writes a launcher shim that runs the binary with that libc.
33
+ The script also writes `webRender.lightpandaPath` into the global settings so the
34
+ extension picks the binary up with no further setup; `--no-configure` skips that,
35
+ and `--print-path` prints only the launcher path for scripts and CI.
32
36
  EOF
33
37
  }
34
38
 
@@ -39,6 +43,7 @@ for arg in "$@"; do
39
43
  --force) FORCE=1 ;;
40
44
  --no-shim) ALLOW_SHIM=0 ;;
41
45
  --print-path) PRINT_PATH=1 ;;
46
+ --no-configure) NO_CONFIGURE=1 ;;
42
47
  -h|--help) usage; exit 0 ;;
43
48
  *) echo "unknown option: $arg" >&2; usage >&2; exit 2 ;;
44
49
  esac
@@ -100,11 +105,96 @@ mkdir -p "$DIR"
100
105
  binary="$DIR/lightpanda"
101
106
  shim="$DIR/lightpanda-run"
102
107
 
108
+ settings_file_for_agent() {
109
+ local agent_dir="${PI_CODING_AGENT_DIR:-${PI_AGENT_DIR:-$HOME/.pi/agent}}"
110
+ case "$agent_dir" in
111
+ "~") agent_dir="$HOME" ;;
112
+ "~/"*) agent_dir="$HOME/${agent_dir#\~/}" ;;
113
+ esac
114
+ printf '%s\n' "$agent_dir/settings.json"
115
+ }
116
+
117
+ configure_renderer() {
118
+ local binary_path="$1"
119
+ local settings_file
120
+ local status
121
+ settings_file="$(settings_file_for_agent)"
122
+ if [ "$NO_CONFIGURE" = 1 ]; then
123
+ echo "settings: skipped (--no-configure); set PI_LIGHTPANDA_BIN or webRender.lightpandaPath to $binary_path"
124
+ return
125
+ fi
126
+ if ! command -v node >/dev/null 2>&1; then
127
+ echo "settings: node not found; set PI_LIGHTPANDA_BIN=$binary_path or webRender.lightpandaPath yourself" >&2
128
+ return
129
+ fi
130
+ status="$(SETTINGS_FILE="$settings_file" LIGHTPANDA_PATH="$binary_path" node --input-type=module <<'NODE'
131
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
132
+ import { dirname } from "node:path";
133
+
134
+ const settingsFile = process.env.SETTINGS_FILE;
135
+ const binaryPath = process.env.LIGHTPANDA_PATH;
136
+ function fail(message) {
137
+ console.error(message);
138
+ process.exit(3);
139
+ }
140
+
141
+ let settings = {};
142
+ let mode;
143
+ if (existsSync(settingsFile)) {
144
+ try {
145
+ settings = JSON.parse(readFileSync(settingsFile, "utf8"));
146
+ } catch {
147
+ fail(`settings: keeping ${settingsFile}: not valid JSON`);
148
+ }
149
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
150
+ fail(`settings: keeping ${settingsFile}: not a JSON object`);
151
+ }
152
+ mode = statSync(settingsFile).mode & 0o777;
153
+ }
154
+
155
+ const sections = [settings.unslothWebTools, settings.webRender].filter(
156
+ (section) => section && typeof section === "object" && !Array.isArray(section),
157
+ );
158
+ const alreadyConfigured = sections.some(
159
+ (section) =>
160
+ (typeof section.lightpandaPath === "string" && section.lightpandaPath.length > 0) ||
161
+ (Array.isArray(section.lightpandaCommand) && section.lightpandaCommand.length > 0),
162
+ );
163
+ if (alreadyConfigured) {
164
+ console.log("kept");
165
+ process.exit(0);
166
+ }
167
+
168
+ if (settings.webRender !== undefined && (!settings.webRender || typeof settings.webRender !== "object" || Array.isArray(settings.webRender))) {
169
+ fail(`settings: keeping ${settingsFile}: webRender is not a JSON object`);
170
+ }
171
+
172
+ settings.webRender = { ...(settings.webRender ?? {}), lightpandaPath: binaryPath };
173
+ const temporary = `${settingsFile}.tmp.${process.pid}`;
174
+ try {
175
+ mkdirSync(dirname(settingsFile), { recursive: true });
176
+ writeFileSync(temporary, JSON.stringify(settings, null, 2) + "\n", mode === undefined ? {} : { mode });
177
+ renameSync(temporary, settingsFile);
178
+ } catch (error) {
179
+ rmSync(temporary, { force: true });
180
+ fail(`settings: keeping ${settingsFile}: ${error instanceof Error ? error.message : String(error)}`);
181
+ }
182
+ console.log("configured");
183
+ NODE
184
+ )" || status=""
185
+ case "$status" in
186
+ configured) echo "settings: ${settings_file} now sets webRender.lightpandaPath" ;;
187
+ kept) echo "settings: ${settings_file} already configures a lightpanda path or command; left unchanged" ;;
188
+ *) echo "settings: could not update ${settings_file} automatically" >&2 ;;
189
+ esac
190
+ }
191
+
103
192
  report_path() {
104
193
  if [ "$PRINT_PATH" = 1 ]; then
105
194
  printf '%s\n' "$1"
106
195
  else
107
196
  echo "already installed and working: $1" >&2
197
+ configure_renderer "$1"
108
198
  fi
109
199
  exit 0
110
200
  }
@@ -200,7 +290,5 @@ fi
200
290
  echo
201
291
  echo "Lightpanda $installed_version installed"
202
292
  echo " run this: $run_path"
203
- echo " configure one of:"
204
- echo " export PI_LIGHTPANDA_BIN=$run_path"
205
- echo " settings.json: { \"webRender\": { \"lightpandaPath\": \"$run_path\" } }"
206
293
  echo " verify: $run_path fetch --dump markdown --wait-until networkidle https://example.com/"
294
+ configure_renderer "$run_path"
package/settings.ts CHANGED
@@ -50,6 +50,26 @@ function pickBoolean(data: Record<string, unknown>, paths: string[][]): boolean
50
50
  return undefined;
51
51
  }
52
52
 
53
+ function pickNumberRecord(data: Record<string, unknown>, paths: string[][]): Record<string, number> | undefined {
54
+ for (const path of paths) {
55
+ let cur: unknown = data;
56
+ for (const key of path) {
57
+ if (cur && typeof cur === "object" && !Array.isArray(cur)) cur = (cur as Record<string, unknown>)[key];
58
+ else {
59
+ cur = undefined;
60
+ break;
61
+ }
62
+ }
63
+ if (!cur || typeof cur !== "object" || Array.isArray(cur)) continue;
64
+ const record: Record<string, number> = {};
65
+ for (const [key, value] of Object.entries(cur as Record<string, unknown>)) {
66
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) record[key] = value;
67
+ }
68
+ if (Object.keys(record).length) return record;
69
+ }
70
+ return undefined;
71
+ }
72
+
53
73
  function pickStringArray(data: Record<string, unknown>, paths: string[][]): string[] | undefined {
54
74
  for (const path of paths) {
55
75
  let cur: unknown = data;
@@ -133,6 +153,21 @@ const LIGHTPANDA_COMMAND_PATHS: string[][] = [
133
153
  ["unslothWebTools", "lightpandaCommand"],
134
154
  ["webRender", "lightpandaCommand"],
135
155
  ];
156
+
157
+ const ENGINE_WEIGHTS_PATHS: string[][] = [
158
+ ["unslothWebTools", "engineWeights"],
159
+ ["webSearch", "engineWeights"],
160
+ ];
161
+
162
+ const MAX_PER_HOST_PATHS: string[][] = [
163
+ ["unslothWebTools", "maxPerHost"],
164
+ ["webSearch", "maxPerHost"],
165
+ ];
166
+
167
+ const ENGINE_PATHS: string[][] = [
168
+ ["unslothWebTools", "engines"],
169
+ ["webSearch", "engines"],
170
+ ];
136
171
  function clampMaxResults(value: number): number {
137
172
  return Math.min(20, Math.max(1, value));
138
173
  }
@@ -163,6 +198,28 @@ export async function loadDefaultMaxResults(cwd?: string): Promise<number> {
163
198
  return result;
164
199
  }
165
200
 
201
+ export async function loadDefaultEngines(cwd?: string): Promise<string[] | undefined> {
202
+ let result: string[] | undefined;
203
+ for (const data of await settingsEntries(cwd)) {
204
+ const candidate = pickStringArray(data, ENGINE_PATHS);
205
+ if (candidate !== undefined) result = candidate;
206
+ }
207
+ return result;
208
+ }
209
+
210
+ export async function loadDefaultEngineWeights(cwd?: string): Promise<Record<string, number> | undefined> {
211
+ let result: Record<string, number> | undefined;
212
+ for (const data of await settingsEntries(cwd)) {
213
+ const candidate = pickNumberRecord(data, ENGINE_WEIGHTS_PATHS);
214
+ if (candidate !== undefined) result = candidate;
215
+ }
216
+ return result;
217
+ }
218
+
219
+ export async function loadDefaultMaxPerHost(cwd?: string): Promise<number | undefined> {
220
+ return loadFetchSetting(cwd, MAX_PER_HOST_PATHS, (n) => n >= 0);
221
+ }
222
+
166
223
  async function loadFetchSetting(cwd: string | undefined, paths: string[][], valid: (n: number) => boolean): Promise<number | undefined> {
167
224
  let result: number | undefined;
168
225
  for (const data of await settingsEntries(cwd)) {
package/tls-fetch.ts CHANGED
@@ -49,15 +49,19 @@ interface ImpersonationModule {
49
49
 
50
50
  export type ImpersonationLoader = () => Promise<ImpersonationModule | null>;
51
51
 
52
+ export type FetchTransport = "tls-first" | "direct-first" | "off";
53
+
52
54
  export interface TlsHopOptions {
53
55
  url: URL;
54
- pinnedIp: string;
55
- family: number;
56
+ pinnedIp?: string;
57
+ family?: number;
56
58
  timeoutMs: number;
57
59
  signal?: AbortSignal;
58
60
  maxBytes: number;
59
61
  maxPdfBytes?: number;
60
62
  profile?: string;
63
+ method?: string;
64
+ body?: string;
61
65
  extraHeaders?: Record<string, string>;
62
66
  loader?: ImpersonationLoader;
63
67
  }
@@ -85,7 +89,7 @@ async function loadImpersonationModule(): Promise<ImpersonationModule | null> {
85
89
  const transports = new Map<string, Promise<ImpersonationTransport>>();
86
90
 
87
91
  function transportKey(options: TlsHopOptions): string {
88
- return `${options.profile ?? DEFAULT_PROFILE}|${options.url.hostname}|${options.pinnedIp}|${options.family}`;
92
+ return `${options.profile ?? DEFAULT_PROFILE}|${options.url.hostname}|${options.pinnedIp ?? ""}|${options.family ?? 0}`;
89
93
  }
90
94
 
91
95
  function evictTransports(): void {
@@ -107,12 +111,13 @@ async function transportFor(
107
111
  const key = transportKey(options);
108
112
  const existing = transports.get(key);
109
113
  if (existing) return existing;
114
+ const transportOptions: Record<string, unknown> = {
115
+ browser: options.profile ?? DEFAULT_PROFILE,
116
+ os: DEFAULT_OS,
117
+ };
118
+ if (options.pinnedIp) transportOptions.resolve = { [options.url.hostname]: options.pinnedIp };
110
119
  const created = module
111
- .createTransport({
112
- resolve: { [options.url.hostname]: options.pinnedIp },
113
- browser: options.profile ?? DEFAULT_PROFILE,
114
- os: DEFAULT_OS,
115
- })
120
+ .createTransport(transportOptions)
116
121
  .catch((error: unknown) => {
117
122
  transports.delete(key);
118
123
  throw error;
@@ -179,12 +184,15 @@ export async function impersonatedRequest(options: TlsHopOptions): Promise<TlsHo
179
184
  const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
180
185
  let response: ImpersonationResponse;
181
186
  try {
182
- response = await module.fetch(options.url.toString(), {
187
+ const init: Record<string, unknown> = {
183
188
  transport,
189
+ method: options.method ?? "GET",
184
190
  redirect: "manual",
185
191
  signal,
186
192
  headers: hopHeaders(options.extraHeaders),
187
- });
193
+ };
194
+ if (options.body !== undefined) init.body = options.body;
195
+ response = await module.fetch(options.url.toString(), init);
188
196
  } catch (error) {
189
197
  throw new Error(failureMessage(error, options.signal, signal));
190
198
  }
package/web-fetch.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  stripIpv6Brackets,
24
24
  type WebsitePolicy,
25
25
  } from "./web-access.ts";
26
- import { impersonatedRequest, type TlsHopOptions, type TlsHopResponse } from "./tls-fetch.ts";
26
+ import { impersonatedRequest, type FetchTransport, type TlsHopOptions, type TlsHopResponse } from "./tls-fetch.ts";
27
27
  import { collapseWhitespace, decodeHtmlEntities, feedHtml, htmlToMarkdown, visibleChars } from "./html-to-md.ts";
28
28
  import type { AttrDict } from "./html-to-md.ts";
29
29
  import { INVALID_CHARREFS } from "./entities.ts";
@@ -210,7 +210,7 @@ export interface ResolvedHost {
210
210
  alternates?: { ip: string; family: number }[];
211
211
  }
212
212
 
213
- export type FetchTransport = "tls-first" | "direct-first" | "off";
213
+ export type { FetchTransport };
214
214
 
215
215
  type TransportKind = "direct" | "tls";
216
216
  export interface FetchSeams {
package/web-search.ts CHANGED
@@ -6,8 +6,10 @@ import {
6
6
  EmptySweepError,
7
7
  SearchCancelled,
8
8
  SearchTimeoutError,
9
+ type SearchEngineOptions,
9
10
  type SearchResult,
10
11
  } from "./engines.ts";
12
+ import type { FetchTransport } from "./tls-fetch.ts";
11
13
 
12
14
  export { EmptySweepError, SearchCancelled, SearchTimeoutError } from "./engines.ts";
13
15
 
@@ -24,15 +26,20 @@ export type SearchClient = (
24
26
  maxResults: number,
25
27
  signal?: AbortSignal,
26
28
  timeoutMs?: number,
29
+ options?: SearchEngineOptions,
27
30
  ) => Promise<SearchResult[]>;
28
31
  export async function ddgSearch(
29
32
  query: string,
30
33
  maxResults = MAX_RESULTS,
31
34
  signal?: AbortSignal,
32
35
  timeoutMs = SEARCH_TIMEOUT_MS,
36
+ options: SearchEngineOptions = {},
33
37
  ): Promise<SearchResult[]> {
34
38
  if (signal?.aborted) throw new SearchCancelled();
35
- return autoTextSearch(query, maxResults, timeoutMs, signal);
39
+ return autoTextSearch(query, maxResults, timeoutMs, signal, {
40
+ ...options,
41
+ transport: options.transport ?? "tls-first",
42
+ });
36
43
  }
37
44
 
38
45
  const POLICY_OVERFETCH = 4;
@@ -44,6 +51,10 @@ export interface WebSearchOptions {
44
51
  websitePolicy?: WebsitePolicy | null;
45
52
  client?: SearchClient;
46
53
  cwd?: string;
54
+ transport?: FetchTransport;
55
+ engines?: string[];
56
+ engineWeights?: Record<string, number>;
57
+ maxPerHost?: number;
47
58
  }
48
59
 
49
60
  export { loadDefaultMaxResults };
@@ -71,7 +82,13 @@ export async function webSearch(
71
82
  (policy?.blockedDomains?.length ?? 0) > 0,
72
83
  );
73
84
  const wanted = restricted ? maxResults * POLICY_OVERFETCH : maxResults;
74
- const results = await client(effectiveQuery, wanted, signal, timeoutMs);
85
+ const results = await client(effectiveQuery, wanted, signal, timeoutMs, {
86
+ transport: options.transport,
87
+ policy,
88
+ engines: options.engines,
89
+ engineWeights: options.engineWeights,
90
+ maxPerHost: options.maxPerHost,
91
+ });
75
92
  if (signal?.aborted) return "Search cancelled.";
76
93
  if (!results.length) return EMPTY_SEARCH_RESULTS[0];
77
94
  const allowed: SearchResult[] = [];