@lincoln504/pi-research 1.3.11 → 1.3.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.3.11",
3
+ "version": "1.3.12",
4
4
  "description": "Free unlimited web search and deep research for the Pi coding agent. Local knowledge store. Also works from Claude Code, Codex, and OpenClaw.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -53,19 +53,27 @@
53
53
  "pi-package",
54
54
  "pi-extension",
55
55
  "pi-coding-agent",
56
+ "claude-code",
56
57
  "openclaw",
58
+ "codex",
59
+ "agent-skills",
57
60
  "agent-skill",
58
61
  "skills",
59
- "research",
62
+ "web-search",
60
63
  "web-research",
61
64
  "deep-research",
62
- "agent",
63
- "web-search",
65
+ "ai-research",
66
+ "research",
67
+ "web-scraping",
64
68
  "scraping",
65
- "knowledge-store",
66
- "duckduckgo",
69
+ "browser-automation",
67
70
  "stealth-browser",
68
- "camoufox"
71
+ "camoufox",
72
+ "duckduckgo",
73
+ "knowledge-store",
74
+ "llm-tools",
75
+ "web-access",
76
+ "agent"
69
77
  ],
70
78
  "author": "Lincoln Deen",
71
79
  "contributors": [
@@ -120,7 +128,7 @@
120
128
  "@earendil-works/pi-tui": ">=0.84.0 <1",
121
129
  "@huggingface/transformers": "4.2.0",
122
130
  "@kreuzberg/html-to-markdown-node": "^3.5.5",
123
- "@lancedb/lancedb": "0.29.0",
131
+ "@lancedb/lancedb": "0.37.1",
124
132
  "apache-arrow": "21.1.0",
125
133
  "bgutils-js": "^3.2.0",
126
134
  "camoufox-js": "^0.10.2",
@@ -133,7 +141,7 @@
133
141
  "poolifier": "^5.3.2",
134
142
  "typebox": "1.3.7",
135
143
  "undici": "^8.9.0",
136
- "youtubei.js": "^17.2.0"
144
+ "youtubei.js": "^18.0.0"
137
145
  },
138
146
  "optionalDependencies": {
139
147
  "webgpu": "^0.4.0"
@@ -154,7 +162,7 @@
154
162
  },
155
163
  "devDependencies": {
156
164
  "@eslint/js": "^10.0.1",
157
- "@types/jsdom": "^28.0.3",
165
+ "@types/jsdom": "^30.0.0",
158
166
  "@typescript-eslint/eslint-plugin": "^8.65.0",
159
167
  "@typescript-eslint/parser": "^8.65.0",
160
168
  "@vitest/coverage-v8": "^4.1.10",
@@ -197,13 +197,34 @@ const dirName = rawDirName && !rawDirName.includes('/') && !rawDirName.includes(
197
197
  * several tools' state by name); a substring match would treat that shared
198
198
  * folder as exclusively ours and wholesale-delete it. Only the exact default
199
199
  * layout is something WE constructed and can therefore prove is ours.
200
+ *
201
+ * Segment-adjacency is NOT that proof either, and used to be what this did:
202
+ * it tested whether `<dirName>/research` appeared ANYWHERE in the path, with no
203
+ * constraint on what followed. That also matched:
204
+ * - the research ROOT itself (`~/.pi/research`) — which holds config.env and
205
+ * knowledge_db/, the two things this script's header promises to preserve;
206
+ * - any unrelated directory beneath it (`/mnt/backup/.pi/research/mystate`);
207
+ * - traversal forms (`~/.pi/research/../../..`) that resolve far outside our
208
+ * tree entirely.
209
+ *
210
+ * The real proof is the full default *shape*: the runtime only ever builds
211
+ * `<root>/<dirName>/research/state`. Requiring those three trailing segments on
212
+ * the RESOLVED path keeps a relocated-but-default layout reclaimable while
213
+ * refusing the research root, sibling directories, and traversal escapes.
200
214
  */
215
+ const DEFAULT_STATE_TAIL = [dirName, 'research', 'state'];
216
+
201
217
  function isPiResearchNamespaced(p) {
202
- const segs = p.split(/[\\/]+/).filter(Boolean);
203
- for (let i = 0; i < segs.length - 1; i++) {
204
- if (segs[i] === dirName && segs[i + 1] === 'research') return true;
205
- }
206
- return false;
218
+ // Resolve first so `..` cannot smuggle the tail past this check.
219
+ const resolved = path.resolve(p);
220
+ // Windows and macOS default to case-insensitive filesystems, so a path
221
+ // differing only in case still names the directory we built.
222
+ const fold = (s) =>
223
+ process.platform === 'win32' || process.platform === 'darwin' ? s.toLowerCase() : s;
224
+ const segs = resolved.split(/[\\/]+/).filter(Boolean);
225
+ if (segs.length < DEFAULT_STATE_TAIL.length) return false;
226
+ const tail = segs.slice(-DEFAULT_STATE_TAIL.length);
227
+ return DEFAULT_STATE_TAIL.every((want, i) => fold(tail[i]) === fold(want));
207
228
  }
208
229
 
209
230
  /**
package/scripts/setup.cjs CHANGED
@@ -47,8 +47,25 @@ function resolveCamoufoxBin() {
47
47
  return null;
48
48
  }
49
49
 
50
+ /**
51
+ * The custom camoufox location, if the user set one. Mirrors
52
+ * getCustomCamoufoxDir() in src/infrastructure/browser/config.ts — the two must
53
+ * agree or install and lookup land in different directories.
54
+ *
55
+ * CAMOUFOX_INSTALL_DIR first: it is camoufox-js's own variable and the only one
56
+ * that can relocate the install (camoufox-js <0.12.0 hardcodes
57
+ * userCacheDir("camoufox") and honours nothing). PLAYWRIGHT_BROWSERS_PATH is
58
+ * kept as the documented alias and is exported BELOW as CAMOUFOX_INSTALL_DIR.
59
+ * NOTE: inert on the pinned camoufox-js 0.10.x, which reads neither — this is
60
+ * forward-compatible plumbing, not a live fix. See src/infrastructure/browser/
61
+ * config.ts and thread-worker-browser.ts.
62
+ */
63
+ function customCamoufoxDir() {
64
+ return process.env.CAMOUFOX_INSTALL_DIR || process.env.PLAYWRIGHT_BROWSERS_PATH || null;
65
+ }
66
+
50
67
  function camoufoxCachePath() {
51
- const customPath = process.env.PLAYWRIGHT_BROWSERS_PATH;
68
+ const customPath = customCamoufoxDir();
52
69
  if (customPath) return customPath;
53
70
 
54
71
  if (isWindows) {
@@ -68,6 +85,13 @@ if (process.env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD === '1') {
68
85
  console.log('pi-research: skipping browser download (PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1)');
69
86
  } else {
70
87
  const env = { ...process.env };
88
+ // Make the fetch honour the custom location. Without this the download always
89
+ // went to camoufox's default cache while camoufoxCachePath() reported the
90
+ // custom one: the "already installed" probe never matched, every install
91
+ // re-fetched, and at runtime the browser was reported missing despite having
92
+ // downloaded successfully.
93
+ const customDir = customCamoufoxDir();
94
+ if (customDir) env.CAMOUFOX_INSTALL_DIR = customDir;
71
95
 
72
96
  const installDeps = process.argv.includes('--system-deps') || process.env.PLAYWRIGHT_INSTALL_DEPS === 'true';
73
97
  if (installDeps && isLinux) {
@@ -2,7 +2,7 @@
2
2
  name: pi-research
3
3
  description: "Multi-agent web research for any coding agent. Use for anything requiring current information from the internet — news, trends, product/service/company facts, statistics, people, places, events, APIs, library behavior, or 'what is X / how does X work' questions. Also use to search a local knowledge store of previously researched findings before doing live research. Powered by the pi-research engine (search, stealth scraping, security databases, Stack Exchange). Not for reading local files, running project commands, or analyzing this repository's own source."
4
4
  license: MIT
5
- metadata: { "author": "Lincoln Deen", "version": "1.3.11", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
5
+ metadata: { "author": "Lincoln Deen", "version": "1.3.12", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
6
6
  allowed-tools: Bash(node:*)
7
7
  ---
8
8
 
package/src/cli.ts CHANGED
@@ -45,6 +45,7 @@ import { getConfig, getGlobalConfigDir, getGlobalEnvFilePath, getInterfaceEnvFil
45
45
  import { getAgentDir } from '@earendil-works/pi-coding-agent';
46
46
  import { buildModelRegistry, safeGetAvailable } from './core/llm/model-registry-factory.ts';
47
47
  import { RESEARCH_TOOL_NAMES } from './constants.ts';
48
+ import { isBrowserAvailable } from './infrastructure/browser/config.ts';
48
49
 
49
50
  // ---------------------------------------------------------------------------
50
51
  // Constants
@@ -770,6 +771,18 @@ async function cmdStatus(json?: boolean): Promise<number> {
770
771
  const det = await detectCredentials();
771
772
  const paths = resolvedConfigPaths();
772
773
  const cfg = getConfig(process.cwd(), 'cli');
774
+ // The stealth browser is installed by this package's `postinstall`
775
+ // (scripts/setup.cjs), which npm 11.19+ BLOCKS BY DEFAULT — it now warns
776
+ // "packages have install scripts not yet covered by allowScripts" and carries
777
+ // on, so the browser is simply absent on an otherwise successful install.
778
+ // Every other native artifact survives that (onnxruntime, sharp, esbuild and
779
+ // webgpu all ship prebuilt platform packages), which is exactly what makes
780
+ // this one worth reporting: nothing else fails, and without this the first
781
+ // symptom is a scrape falling back mid-run. A missing browser degrades
782
+ // capability rather than breaking the tool — the fetch path still serves most
783
+ // pages — so it is reported alongside `ready` rather than folded into it.
784
+ // isBrowserAvailable() is a resolve + existsSync; it never launches anything.
785
+ const browserInstalled = isBrowserAvailable();
773
786
  const summary = {
774
787
  package: '@lincoln504/pi-research',
775
788
  version: PKG_VERSION,
@@ -783,6 +796,14 @@ async function cmdStatus(json?: boolean): Promise<number> {
783
796
  modelFrom: det.modelFrom,
784
797
  problem: det.problem ?? null,
785
798
  },
799
+ browser: {
800
+ installed: browserInstalled,
801
+ // Same remedy the health check prints, so both surfaces name one command.
802
+ fix: browserInstalled ? null : 'npx camoufox-js fetch',
803
+ note: browserInstalled
804
+ ? null
805
+ : 'Stealth browser not installed — scraping falls back to plain fetch, which some sites block. npm 11.19+ blocks install scripts by default, which skips this package\'s postinstall.',
806
+ },
786
807
  knowledgeStoreMode: cfg.KNOWLEDGE_STORE_MODE,
787
808
  defaultDepth: cfg.DEFAULT_RESEARCH_DEPTH,
788
809
  paths,
@@ -796,6 +817,10 @@ async function cmdStatus(json?: boolean): Promise<number> {
796
817
  toStdout(`pi-research ${PKG_VERSION}\n`);
797
818
  toStdout(`ready: ${summary.ready ? 'yes' : 'no'}\n\n`);
798
819
  toStdout(configBlock(det, summary.ready ? undefined : '— fix the problem above, then re-run.') + '\n');
820
+ if (!summary.browser.installed) {
821
+ toStdout(`\nstealth browser: NOT INSTALLED — run: ${summary.browser.fix}\n`);
822
+ toStdout(` ${summary.browser.note}\n`);
823
+ }
799
824
  toStdout(`\nknowledge store mode: ${cfg.KNOWLEDGE_STORE_MODE} (default depth: ${cfg.DEFAULT_RESEARCH_DEPTH})\n`);
800
825
  return EXIT.OK;
801
826
  }
@@ -1064,7 +1089,17 @@ export async function reportError(err: unknown, what: string, json?: boolean): P
1064
1089
  // --json: emit a structured error on stdout so a machine consumer never has to
1065
1090
  // special-case plain-text stderr. Mirrors the { ok: true, ... } success shape.
1066
1091
  if (json) {
1067
- const payload: Record<string, unknown> = { ok: false, error: msg, exitCode };
1092
+ // A cancellation reports itself as a cancellation, not as whatever component
1093
+ // happened to notice the abort first. The internal message is genuinely
1094
+ // misleading here — a Ctrl-C unwinding through the planner surfaces as
1095
+ // "Coordinator failed: Request was aborted", so a consumer relaying `error`
1096
+ // verbatim (which SKILL.md instructs agents to do) tells the user a component
1097
+ // broke when they simply stopped the run. The plain-text branch below already
1098
+ // refuses to say "failed" for a cancel; JSON said it anyway. The original
1099
+ // wording is preserved under `cause` so nothing diagnostic is lost.
1100
+ const payload: Record<string, unknown> = isCancellation
1101
+ ? { ok: false, error: `pi-research ${what} cancelled.`, cause: msg, exitCode }
1102
+ : { ok: false, error: msg, exitCode };
1068
1103
  // Machine-readable "this will succeed later" flag, so a consumer can back off
1069
1104
  // and retry without string-matching the human message. Gated on cancellation:
1070
1105
  // a Ctrl-C landing in the same window a queue-wait expires must never emit
@@ -178,10 +178,26 @@ export class ServiceContainer {
178
178
  options: ServiceContainerOptions = {}
179
179
  ): Promise<void> {
180
180
  const mergedOptions = { ...this.defaultOptions, ...options, allowOverwrite: true };
181
-
181
+
182
182
  // Dispose existing service if present
183
- if (this.services.has(name)) {
184
- const registration = this.services.get(name)!;
183
+ const registration = this.services.get(name);
184
+ if (registration) {
185
+ // Join an already-in-flight clear()/replace()/disposeAll() on this
186
+ // registration before touching its instance — the same guard those paths
187
+ // use on each other. Without it, a concurrent teardown and this call both
188
+ // dispose the SAME instance, and register() below then swaps the whole
189
+ // registration object out from under the in-flight one, orphaning any
190
+ // instance it constructs.
191
+ // while(), not if(): a newer disposal can begin while we are awaiting this
192
+ // one, exactly as get() documents for the same field.
193
+ while (registration.disposalPromise) {
194
+ await registration.disposalPromise.catch(() => { /* disposal errors are logged by clear()/replace() */ });
195
+ }
196
+ // Settle an in-flight initialization too, so we never discard a service
197
+ // that is still being built — mirrors replace().
198
+ if (registration.initializationPromise) {
199
+ await registration.initializationPromise.catch(() => { /* failed init cleaned up after itself */ });
200
+ }
185
201
  if (registration.instance && registration.instance.dispose) {
186
202
  try {
187
203
  await registration.instance.dispose();
@@ -31,15 +31,40 @@ function getWindowsCamoufoxDir(): string {
31
31
  return join(homedir(), 'AppData', 'Local', 'camoufox', 'camoufox', 'Cache');
32
32
  }
33
33
 
34
+ /**
35
+ * The custom camoufox location, if the user set one.
36
+ *
37
+ * CAMOUFOX_INSTALL_DIR takes precedence because it is camoufox-js's OWN
38
+ * variable and the only one that can ever move the install: camoufox-js ≥0.12.0
39
+ * resolves INSTALL_DIR from it. PLAYWRIGHT_BROWSERS_PATH is kept as a documented
40
+ * alias, but on its own it only ever moved where we LOOK — camoufox went on
41
+ * installing to the default cache, so setting it made the browser permanently
42
+ * "not found" right after a successful download. Both are exported onward to
43
+ * camoufox-js (getBrowserEnv, scripts/setup.cjs) so that install and lookup
44
+ * cannot disagree.
45
+ *
46
+ * CAVEAT, deliberate: the pinned camoufox-js (0.10.x — see
47
+ * thread-worker-browser.ts for why it cannot move yet) hardcodes
48
+ * `userCacheDir("camoufox")` and honours NEITHER variable, so on the shipped
49
+ * pin a custom location still only relocates lookup and the underlying bug
50
+ * remains. This plumbing is correct and inert rather than wrong: it starts
51
+ * working the moment the pin can advance, and costs nothing until then. Until
52
+ * that happens, treat a custom browser directory as unsupported.
53
+ */
54
+ function getCustomCamoufoxDir(): string | undefined {
55
+ return process.env['CAMOUFOX_INSTALL_DIR'] || process.env['PLAYWRIGHT_BROWSERS_PATH'] || undefined;
56
+ }
57
+
34
58
  /**
35
59
  * Get the camoufox binary cache directory.
36
- * Uses PLAYWRIGHT_BROWSERS_PATH if set, otherwise the standard user cache location
60
+ * Uses the custom location above if set, otherwise the standard user cache location
37
61
  * that camoufox uses by default (~/.cache/camoufox on Linux).
38
62
  * We do NOT override HOME — that trick was unreliable and caused install/runtime mismatches.
39
63
  */
40
64
  export function getBrowserCacheDir(): string {
41
- if (process.env['PLAYWRIGHT_BROWSERS_PATH']) {
42
- return process.env['PLAYWRIGHT_BROWSERS_PATH'];
65
+ const custom = getCustomCamoufoxDir();
66
+ if (custom) {
67
+ return custom;
43
68
  }
44
69
  // Mirror getCamoufoxBinaryPath() so both functions agree on the cache location
45
70
  const osPlatform = platform();
@@ -109,11 +134,17 @@ export function getBrowserProfileDir(config?: Config): string {
109
134
  */
110
135
  export function getBrowserEnv(config?: Config): NodeJS.ProcessEnv {
111
136
  const env: NodeJS.ProcessEnv = { ...process.env };
112
- const customPath = process.env['PLAYWRIGHT_BROWSERS_PATH'];
137
+ const customPath = getCustomCamoufoxDir();
113
138
  if (customPath) {
114
139
  env['PLAYWRIGHT_BROWSERS_PATH'] = customPath;
140
+ // The one that camoufox-js actually reads. Without it the worker's
141
+ // camoufox launches from the DEFAULT cache while everything on this side
142
+ // resolves the custom path — the install/runtime mismatch this file's
143
+ // header warns about, just arriving through a different door.
144
+ env['CAMOUFOX_INSTALL_DIR'] = customPath;
115
145
  } else {
116
146
  delete env['PLAYWRIGHT_BROWSERS_PATH'];
147
+ delete env['CAMOUFOX_INSTALL_DIR'];
117
148
  }
118
149
  // Tell thread-workers where to write their lifecycle/error log. Precedence:
119
150
  // 1. A user-set PI_RESEARCH_LOG_FILE (already copied from process.env above) wins —
@@ -164,7 +195,7 @@ export function ensureBrowserCacheDir(): string {
164
195
  * Matches camoufox-js's own resolution logic.
165
196
  */
166
197
  export function getCamoufoxBinaryPath(): string {
167
- const customPath = process.env['PLAYWRIGHT_BROWSERS_PATH'];
198
+ const customPath = getCustomCamoufoxDir();
168
199
  if (customPath) {
169
200
  return customPath;
170
201
  }
@@ -136,9 +136,19 @@ async function isRegisteredLeaderAlive(container: ServiceContainer): Promise<boo
136
136
  return result;
137
137
  }
138
138
 
139
- /** Test seam: drop the coalesced liveness probe. */
140
- export function _resetLeaderLivenessProbeForTests(): void {
139
+ /**
140
+ * Test seam: drop BOTH module-level globals that survive between tests — the
141
+ * coalesced liveness probe (1s window) and the restart cooldown (10s window).
142
+ *
143
+ * Both are far longer than a test file's runtime, so a test that reaches
144
+ * restartSchedulerWithHerdGuard leaves state that silently short-circuits the
145
+ * next one: isRegisteredLeaderAliveUncached / forceSchedulerRestart simply are
146
+ * not re-invoked. Today nothing observes that only because the one test that
147
+ * reaches this path happens to run last — ordering, not a guarantee.
148
+ */
149
+ export function _resetTaskExecutionGlobalsForTests(): void {
141
150
  leaderLivenessProbe = null;
151
+ lastRestartTime = 0;
142
152
  }
143
153
 
144
154
  /**
@@ -134,29 +134,45 @@ export async function initBrowser(): Promise<void> {
134
134
  // (consumers install without our lockfile, so a floating range resolves
135
135
  // to a newer, broken version):
136
136
  //
137
- // • camoufox-js ^0.10.2 → fetches camoufox Firefox 135 / beta.24, the
138
- // NEWEST camoufox with binaries for every OS we support (Linux x64/
139
- // arm64, macOS x64/arm64, Windows x64). FF146/FF150 dropped the
140
- // Windows build entirely, so the 0.11.x line breaks Windows installs.
137
+ // • camoufox-js ^0.10.2 → the two ORIGINAL reasons for this pin have
138
+ // both lapsed (camoufox restored Windows assets in v152.0.4-beta.26,
139
+ // 2026-07-16; and impit's `only-allow pnpm` guard existed only in
140
+ // 0.13.1/0.14.0), but a THIRD, worse one replaced them. 0.12.0
141
+ // requires `better-sqlite3 ^13.0.1`, and better-sqlite3 13 DROPPED
142
+ // PREBUILT BINARIES: 12.x installs via `prebuild-install || node-gyp
143
+ // rebuild`, 13.x has no install script at all, so npm falls back to
144
+ // auto-running `node-gyp rebuild` for its binding.gyp and every
145
+ // install needs a full C++ toolchain. Measured: `npm ci` fails
146
+ // outright on the Windows CI runner ("could not find a version of
147
+ // Visual Studio 2017 or newer"), which is most Windows consumers.
148
+ // Upgrading again means checking better-sqlite3 first, not camoufox.
141
149
  // • playwright-core 1.60.0 (exact) → playwright-core and the camoufox
142
150
  // binary are one matched Juggler-protocol pair. 1.61 added a
143
- // viewport.isMobile field the FF135 build REJECTS ("property
151
+ // viewport.isMobile field the build REJECTS ("property
144
152
  // viewport.isMobile not described in this scheme"), failing every
145
- // launch below. 1.60.0 is the newest playwright the FF135 Juggler
146
- // accepts.
153
+ // launch below. Corroborated upstream: camoufox-js 0.12.0 declares
154
+ // `peerDependencies: playwright-core "<1.61.0"`, the same bound this
155
+ // pin has held by hand.
147
156
  // • impit 0.13.0 (exact, a direct dep though only camoufox-js uses it)
148
- // → impit 0.13.1/0.14.0 shipped a `preinstall: npx only-allow pnpm`
149
- // guard (an upstream mistake, fixed in 0.14.1+) that hard-fails
150
- // `npm install -g`. Within camoufox-js's ^0.13.0 range 0.13.0 is the
151
- // only guard-free version; a direct-dep pin is the ONLY way to force
152
- // it for consumers (npm `overrides` don't propagate to installers of
153
- // a published package).
157
+ // → camoufox-js 0.10.2 requires `impit ^0.13.0`, and within that range
158
+ // 0.13.0 is the only version free of the `preinstall: npx only-allow
159
+ // pnpm` guard (an upstream mistake in 0.13.1/0.14.0, dropped again in
160
+ // 0.14.1) that hard-fails `npm install -g`. The exact pin is also the
161
+ // ONLY way to force a version for consumers, since npm `overrides`
162
+ // don't propagate to installers of a published package.
154
163
  //
155
- // Removal: upgrade all three together when camoufox ships stable
156
- // cross-platform (incl. Windows) FF150 binaries — bumping camoufox-js to
157
- // ≥0.11.1 also drops the impit pin (0.11.1 requires the fixed impit
158
- // ^0.14.1) and needs playwright re-pinned to the new build's Juggler.
159
- // Always re-verify a real headless run on Linux/macOS/Windows after.
164
+ // What is NOT pinned: the browser BINARY. `camoufox-js fetch` takes no
165
+ // version argument — it walks the GitHub releases newest-first and takes
166
+ // the first non-prerelease one with an asset for this OS/arch. A consumer
167
+ // therefore gets whatever camoufox published most recently, whichever
168
+ // camoufox-js they have, and a future release could break launches with no
169
+ // change on our side. Both the current newest (v152.0.4-beta.28, Firefox
170
+ // 152) and the older v135.0.1-beta.24 an existing cache may hold were
171
+ // verified launching and driving under playwright-core 1.60.0.
172
+ //
173
+ // Always re-verify a REAL headless run after touching any of this: the
174
+ // unit and integration suites mock the browser, so a Juggler mismatch
175
+ // reaches production with a fully green suite.
160
176
  let CamoufoxModule: any;
161
177
  try {
162
178
  CamoufoxModule = await import('camoufox-js');
@@ -99,7 +99,10 @@ var init_log_utils = __esm({
99
99
  `${B_LEFT}(sk-[A-Za-z0-9_-]{16,}|gh[posru]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,})${B_RIGHT}`,
100
100
  "g"
101
101
  );
102
- JWT_PATTERN = new RegExp(`${B_LEFT}eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+`, "g");
102
+ JWT_PATTERN = new RegExp(
103
+ `${B_LEFT}eyJ[A-Za-z0-9_-]{1,1024}\\.[A-Za-z0-9_-]{1,8192}\\.[A-Za-z0-9_-]{1,4096}`,
104
+ "g"
105
+ );
103
106
  BASIC_AUTH_PATTERN = new RegExp(`${B_LEFT}Basic\\s+[A-Za-z0-9+/]{16,}={0,2}`, "gi");
104
107
  BEARER_AUTH_PATTERN = new RegExp(`${B_LEFT}Bearer\\s+[A-Za-z0-9._~+/-]+=*`, "gi");
105
108
  PROVIDER_TOKEN_PATTERN = new RegExp(
@@ -1509,6 +1512,7 @@ var scraper_utils_exports = {};
1509
1512
  __export(scraper_utils_exports, {
1510
1513
  createJsMarkdownConverter: () => createJsMarkdownConverter,
1511
1514
  createNativeMarkdownConverter: () => createNativeMarkdownConverter,
1515
+ disposeSsrfSafeFetcher: () => disposeSsrfSafeFetcher,
1512
1516
  extractDomain: () => extractDomain,
1513
1517
  formatErrorWithCause: () => formatErrorWithCause,
1514
1518
  getRandomUserAgent: () => getRandomUserAgent,
@@ -1644,19 +1648,33 @@ function isSsrfBlockError(error) {
1644
1648
  }
1645
1649
  return false;
1646
1650
  }
1647
- async function getSsrfSafeFetcher() {
1648
- if (cachedSsrfFetcher !== void 0) return cachedSsrfFetcher;
1651
+ function getSsrfSafeFetcher() {
1652
+ cachedSsrfFetcher ??= (async () => {
1653
+ try {
1654
+ const { Agent, fetch: undiciFetch } = await import("undici");
1655
+ return {
1656
+ fetch: undiciFetch,
1657
+ dispatcher: new Agent({ connect: { lookup: ssrfSafeLookup } })
1658
+ };
1659
+ } catch (e) {
1660
+ logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
1661
+ return null;
1662
+ }
1663
+ })();
1664
+ return cachedSsrfFetcher;
1665
+ }
1666
+ async function disposeSsrfSafeFetcher() {
1667
+ const pending = cachedSsrfFetcher;
1668
+ cachedSsrfFetcher = void 0;
1669
+ if (pending === void 0) return;
1670
+ const cached = await pending.catch(() => null);
1671
+ const closable = cached?.dispatcher;
1672
+ if (!closable?.close) return;
1649
1673
  try {
1650
- const { Agent, fetch: undiciFetch } = await import("undici");
1651
- cachedSsrfFetcher = {
1652
- fetch: undiciFetch,
1653
- dispatcher: new Agent({ connect: { lookup: ssrfSafeLookup } })
1654
- };
1674
+ await closable.close();
1655
1675
  } catch (e) {
1656
- logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
1657
- cachedSsrfFetcher = null;
1676
+ logger.debug(`[Scrapers] Error closing SSRF dispatcher: ${e instanceof Error ? e.message : String(e)}`);
1658
1677
  }
1659
- return cachedSsrfFetcher;
1660
1678
  }
1661
1679
  function formatErrorWithCause(err) {
1662
1680
  try {
@@ -27,7 +27,21 @@ export class GPUResourceService implements IGPUResourceService {
27
27
  lifecycle = ServiceLifecycle.UNINITIALIZED;
28
28
 
29
29
  private processLifecycle: IProcessLifecycle | null = null;
30
- private gpuLockStaleThresholdMs: number = 1000 * 60 * 5; // 5 minutes default
30
+ /**
31
+ * Backstop for a holder that is alive but wedged. Step 3 already reclaims from
32
+ * *dead* owners via PID+startTime liveness, so this only ever fires against a
33
+ * process we just proved is running — which makes it a lock STEAL, and the
34
+ * threshold must therefore exceed the longest legitimate hold.
35
+ *
36
+ * It previously did not: at 5 minutes it exactly equalled the default
37
+ * EMBEDDING_MODEL_INIT_TIMEOUT_MS (300000, schema max 600000), and the
38
+ * embedder holds this lock across the whole model download + pipeline load.
39
+ * A first-time HuggingFace fetch running near its own permitted budget would
40
+ * have the GPU lock taken out from under it, admitting exactly the concurrent
41
+ * WebGPU/Dawn init this lock exists to prevent. Keep this strictly above that
42
+ * schema maximum.
43
+ */
44
+ private gpuLockStaleThresholdMs: number = 1000 * 60 * 15; // 15 min > EMBEDDING_MODEL_INIT_TIMEOUT_MS max (10 min)
31
45
 
32
46
  constructor(options?: GPUResourceServiceOptions) {
33
47
  if (options) {
@@ -295,7 +295,23 @@ export async function loadModelOnCPU(
295
295
  interOpNumThreads: 1,
296
296
  },
297
297
  });
298
- return await withTimeout(pipelinePromise, initializationTimeoutMs, 'CPU fallback model load timed out');
298
+ // Same late-arrival disposal as loadPipelineWithTimeout: withTimeout races
299
+ // but cannot cancel, so on timeout the CPU load keeps running and its
300
+ // eventual ONNX session would leak. This path runs *after* a WebGPU
301
+ // failure — i.e. on a host already under memory pressure — and can be
302
+ // re-entered per retry, so each orphaned session compounds.
303
+ let timedOut = true;
304
+ try {
305
+ const p = await withTimeout(pipelinePromise, initializationTimeoutMs, 'CPU fallback model load timed out');
306
+ timedOut = false;
307
+ return p;
308
+ } finally {
309
+ if (timedOut) {
310
+ pipelinePromise
311
+ .then((late) => (late as unknown as { dispose?: () => Promise<void> }).dispose?.())
312
+ .catch(() => { /* load failed after the timeout — nothing to dispose */ });
313
+ }
314
+ }
299
315
  });
300
316
 
301
317
  return loadedPipeline;
@@ -30,11 +30,15 @@ import { normalizeWorkspacePath } from '../utils/text-utils.ts';
30
30
 
31
31
  /**
32
32
  * Detect a LanceDB concurrent-write conflict — the class of error worth retrying
33
- * with backoff. LanceDB 0.29 surfaces these as "Commit conflict for version N",
33
+ * with backoff. LanceDB surfaces these as "Commit conflict for version N",
34
34
  * "Retryable commit conflict for version N" and "Too many concurrent writes,
35
- * please retry later:" (strings confirmed in the 0.29.0 native binary). The three
36
- * legacy strings ("Version mismatch" / "Lock error" / "Commit error") no longer
37
- * appear in 0.29 but are kept as a safety net for other/older Lance versions.
35
+ * please retry later:" — all three re-confirmed present in the 0.37.1 native
36
+ * binary (`@lancedb/lancedb-<platform>`, NOT the `@lancedb/lancedb` JS package;
37
+ * grepping the latter finds nothing and looks like a regression), as they were
38
+ * in 0.29.0. The three legacy strings ("Version mismatch" / "Lock error" /
39
+ * "Commit error") appear in neither and are kept as a safety net for other/older
40
+ * Lance versions. Re-check these against the native binary on every upgrade: a
41
+ * changed message silently turns a retryable conflict into a hard failure.
38
42
  */
39
43
  export function isLanceCommitConflict(err: unknown): boolean {
40
44
  const msg = err instanceof Error ? err.message : String(err);
@@ -1065,7 +1069,9 @@ export class KnowledgeStore implements IKnowledgeStore {
1065
1069
  // no lock, so rows committed DURING the rebuild were absorbed into the
1066
1070
  // baseline as already-indexed and stayed out of the BM25 leg until an
1067
1071
  // unrelated later commit. Each createIndex commits exactly one version
1068
- // (measured on lancedb 0.29, fresh and replace alike); anything beyond
1072
+ // (measured on lancedb 0.29 and re-measured on 0.37.1 — vector index, FTS
1073
+ // index and a `replace: true` rebuild each advance version() by exactly
1074
+ // 1); anything beyond
1069
1075
  // version+2 means a concurrent commit rebased beneath our index commits, so
1070
1076
  // fall back to the conservative pre-rebuild baseline — the next cleanup
1071
1077
  // sees version > baseline and rebuilds over the missed rows.
@@ -14,7 +14,7 @@ import type { Model } from '@earendil-works/pi-ai';
14
14
  import { resolveResearchModel } from '../core/llm/research-model-resolver.ts';
15
15
  import type { QueryResultWithError } from '../web-research/types.ts';
16
16
  import type { RunResearchersOptions } from './orchestration-types.ts';
17
- import { RESEARCHER_LAUNCH_DELAY_MS, resolveExcludedTools } from '../constants.ts';
17
+ import { RESEARCHER_LAUNCH_DELAY_MS, RESEARCH_TOOL_NAMES, resolveExcludedTools } from '../constants.ts';
18
18
  import { search } from '../web-research/search.ts';
19
19
  import { parseCitations } from '../utils/text-utils.ts';
20
20
  import { logger, resetLogger } from '../logger.ts';
@@ -98,6 +98,27 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
98
98
  const configDisabledTools = researchConfig.DISABLED_TOOLS
99
99
  ? researchConfig.DISABLED_TOOLS.split(',').map((s) => s.trim()).filter(Boolean)
100
100
  : [];
101
+ // Unknown names are a SILENT no-op downstream (the merge is a blind Set union),
102
+ // so a typo leaves the tool enabled for a whole billed run while the user
103
+ // believes it is off. `--exclude-tools` fails fast on this in the CLI parser;
104
+ // its two siblings — the SDK's `excludeTools` option and
105
+ // PI_RESEARCH_DISABLED_TOOLS — reach the same union with no check at all, and
106
+ // RESEARCH_TOOL_NAMES already documents itself as the vocabulary all THREE
107
+ // accept. Warn rather than throw here: this chokepoint is mid-run and serves a
108
+ // library API and an ambient env var, where aborting a legitimate request over
109
+ // one unrecognized name is the wrong trade — the CLI keeps its fail-fast.
110
+ for (const [surface, names] of [
111
+ ['excludeTools', excludeTools ?? []],
112
+ ['PI_RESEARCH_DISABLED_TOOLS', configDisabledTools],
113
+ ] as const) {
114
+ const unknown = names.filter((t) => !RESEARCH_TOOL_NAMES.includes(t));
115
+ if (unknown.length > 0) {
116
+ logger.warn(
117
+ `[ResearchOrchestrationService] ${surface}: ignoring unknown tool name${unknown.length === 1 ? '' : 's'} ` +
118
+ `${unknown.map((t) => `"${t}"`).join(', ')} — these exclude nothing. Valid names: ${RESEARCH_TOOL_NAMES.join(', ')}.`
119
+ );
120
+ }
121
+ }
101
122
  const mergedExcludeTools = resolveExcludedTools(excludeTools, configDisabledTools);
102
123
 
103
124
  // Resolve model using centralized priority logic
@@ -36,7 +36,7 @@ Before splitting the query into researchers, pin down its scope — exactly what
36
36
 
37
37
  1. **Plan ONLY Round 1**: Split the query into distinct sub-topics, all inside the scope above, each assigned to a researcher. **Do NOT plan multiple rounds in advance** — additional rounds are only delegated reactively when Round 1 findings reveal gaps.
38
38
  2. **Assign Goals**: Each researcher gets a focused goal covering a specific angle or time period.
39
- 3. **Temporal Awareness**: Lead with the current month and year shown above. Generate time-sensitive queries using that exact month and year (e.g., append the current year and, where relevant, the current month; "latest", "current status"). Never anchor queries to an older year (e.g. 2024 or 2025) unless the user explicitly asks about the past. Research MUST be grounded in the present month and year.
39
+ 3. **Temporal Awareness**: Lead with the current month and year shown above. Generate time-sensitive queries using that exact month and year (e.g., append the current year and, where relevant, the current month; "latest", "current status"). Never anchor queries to an older year than the one shown above unless the user explicitly asks about the past. Research MUST be grounded in the present month and year.
40
40
  4. **Query Planning**: For EACH researcher, generate targeted, specific queries within the budget. Fill the budget when the topic is broad enough to warrant it, but don't pad queries for a narrow topic. Each round is planned independently — make Round 1 count.
41
41
  5. **YouTube Discovery**: For roughly **one in {{youtube_query_every_n}}** of each researcher's queries, append the word `youtube` to the query text (e.g. `"<topic> explained youtube"`). DuckDuckGo rarely surfaces YouTube on its own, and YouTube links let researchers read video transcripts. Skip this only when the topic is clearly unsuited to video (e.g. a single numeric lookup).
42
42
  6. **Seed Search Burst**: All queries across all researchers are combined into a single pre-search pass that seeds the global link pool.
@@ -74,7 +74,7 @@ Use unique, targeted queries for any new researchers.
74
74
  - **CRITICAL — Queries are mandatory**: Every researcher MUST have at least one query. Never plan a researcher without queries. Researchers receive ONLY the search results you delegate to them.
75
75
  - **Maximize queries**: For EACH researcher, generate the maximum number of targeted, specific queries within the budget. Do not plan fewer than needed — fill the budget. Queries should target primary sources and authoritative evidence.
76
76
  - **YouTube discovery**: For roughly one in {{youtube_query_every_n}} queries, append the word `youtube` to the query text (e.g. `"<topic> analysis youtube"`). DuckDuckGo rarely surfaces YouTube on its own, and YouTube links let researchers read video transcripts. Skip only when the gap is clearly unsuited to video.
77
- - **Temporal anchoring**: Anchor every new time-sensitive query to the current month and year shown at the top of this prompt. Use that exact year (and month where relevant); never default to an older year such as 2024 or 2025 unless the user explicitly asks about the past.
77
+ - **Temporal anchoring**: Anchor every new time-sensitive query to the current month and year shown at the top of this prompt. Use that exact year (and month where relevant); never default to an older year than the one shown there unless the user explicitly asks about the past.
78
78
  - **Flexible coverage**: Use up to {{max_team_size}} researchers to cover distinct angles in parallel. Scale based on research gaps — a single well-targeted researcher is often sufficient for focused gaps.
79
79
  - **Source diversity**: Encourage researchers to find multiple authoritative sources per topic area to enable comprehensive citations in the final synthesis.
80
80