@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/dist/cli.mjs +139 -27
- package/dist/prompts/system-coordinator.md +1 -1
- package/dist/prompts/system-lead-evaluator.md +1 -1
- package/dist/thread-worker.mjs +29 -11
- package/docs/ARCHITECTURE.md +51 -14
- package/docs/CHANGELOG.md +66 -0
- package/docs/CONFIGURATION.md +3 -2
- package/package.json +18 -10
- package/scripts/cleanup.cjs +26 -5
- package/scripts/setup.cjs +25 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +36 -1
- package/src/core/service-registry.ts +19 -3
- package/src/infrastructure/browser/config.ts +36 -5
- package/src/infrastructure/browser/task-execution-service.ts +12 -2
- package/src/infrastructure/browser/thread-worker-browser.ts +34 -18
- package/src/infrastructure/browser/thread-worker.mjs +29 -11
- package/src/infrastructure/gpu-resource-service.ts +15 -1
- package/src/knowledge/embedder-init.ts +17 -1
- package/src/knowledge/store.ts +11 -5
- package/src/orchestration/research-orchestration-service.ts +22 -1
- package/src/prompts/system-coordinator.md +1 -1
- package/src/prompts/system-lead-evaluator.md +1 -1
- package/src/sdk.ts +10 -1
- package/src/security/nvd-client.ts +55 -4
- package/src/security/osv-client.ts +13 -1
- package/src/tools/research-knowledge-search.ts +18 -1
- package/src/utils/log-utils.ts +16 -1
- package/src/web-research/scraper-utils.ts +55 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lincoln504/pi-research",
|
|
3
|
-
"version": "1.3.
|
|
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
|
-
"
|
|
62
|
+
"web-search",
|
|
60
63
|
"web-research",
|
|
61
64
|
"deep-research",
|
|
62
|
-
"
|
|
63
|
-
"
|
|
65
|
+
"ai-research",
|
|
66
|
+
"research",
|
|
67
|
+
"web-scraping",
|
|
64
68
|
"scraping",
|
|
65
|
-
"
|
|
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.
|
|
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": "^
|
|
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": "^
|
|
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",
|
package/scripts/cleanup.cjs
CHANGED
|
@@ -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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
|
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
|
-
|
|
42
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
/**
|
|
140
|
-
|
|
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 →
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
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
|
|
151
|
+
// viewport.isMobile field the build REJECTS ("property
|
|
144
152
|
// viewport.isMobile not described in this scheme"), failing every
|
|
145
|
-
// launch below.
|
|
146
|
-
//
|
|
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
|
-
// →
|
|
149
|
-
//
|
|
150
|
-
// `
|
|
151
|
-
//
|
|
152
|
-
//
|
|
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
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
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(
|
|
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
|
-
|
|
1648
|
-
|
|
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
|
-
|
|
1651
|
-
cachedSsrfFetcher = {
|
|
1652
|
-
fetch: undiciFetch,
|
|
1653
|
-
dispatcher: new Agent({ connect: { lookup: ssrfSafeLookup } })
|
|
1654
|
-
};
|
|
1674
|
+
await closable.close();
|
|
1655
1675
|
} catch (e) {
|
|
1656
|
-
logger.
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/knowledge/store.ts
CHANGED
|
@@ -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
|
|
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:"
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|