@lincoln504/pi-research 0.1.12 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +64 -115
- package/dist/cli.mjs +23211 -0
- package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
- package/dist/prompts/research-knowledge-search-extractor.md +41 -0
- package/dist/prompts/research-tool-usage.md +92 -0
- package/dist/prompts/researcher.md +89 -0
- package/dist/prompts/system-coordinator.md +75 -0
- package/dist/prompts/system-lead-evaluator.md +118 -0
- package/dist/thread-worker.mjs +2611 -0
- package/dist/webgpu-probe.mjs +80 -0
- package/docs/AGENT-SKILL.md +102 -0
- package/docs/ARCHITECTURE.md +306 -80
- package/docs/CONFIGURATION.md +249 -0
- package/docs/KNOWLEDGE-STORE.md +213 -0
- package/docs/PI-EXTENSION.md +91 -0
- package/docs/SDK.md +113 -0
- package/docs/THIRD-PARTY-NOTICES.md +64 -0
- package/package.json +102 -36
- package/scripts/build.cjs +140 -0
- package/scripts/cleanup.cjs +149 -0
- package/scripts/prepare.cjs +28 -0
- package/scripts/setup.cjs +134 -0
- package/skills/pi-research/README.md +90 -0
- package/skills/pi-research/SKILL.md +102 -0
- package/skills/pi-research/references/configuration.md +77 -0
- package/skills/pi-research/scripts/run.mjs +237 -0
- package/src/cleanup/research-cleanup.ts +140 -0
- package/src/cli.ts +1044 -0
- package/src/config.ts +1036 -147
- package/src/constants.ts +115 -84
- package/src/core/health-check-service.ts +265 -0
- package/src/core/interfaces/health-check-interfaces.ts +50 -0
- package/src/core/interfaces/knowledge-interfaces.ts +190 -0
- package/src/core/interfaces/observer-interfaces.ts +67 -0
- package/src/core/interfaces/orchestration-interfaces.ts +86 -0
- package/src/core/interfaces/planning-interfaces.ts +100 -0
- package/src/core/interfaces/process-interfaces.ts +37 -0
- package/src/core/interfaces/research-plan-types.ts +52 -0
- package/src/core/interfaces/scheduler-interfaces.ts +91 -0
- package/src/core/interfaces/service-names.ts +39 -0
- package/src/core/interfaces/state-manager-interfaces.ts +29 -0
- package/src/core/interfaces/steering-interfaces.ts +31 -0
- package/src/core/llm/agentic-repair.ts +169 -0
- package/src/core/llm/inject-date.ts +52 -0
- package/src/core/llm/llm-timeout.ts +64 -0
- package/src/core/llm/llm-utils.ts +86 -0
- package/src/core/llm/model-registry-factory.ts +287 -0
- package/src/core/llm/pi-ai-completion.ts +29 -0
- package/src/core/llm/prompts.ts +50 -0
- package/src/core/llm/research-model-resolver.ts +91 -0
- package/src/core/planning-constants.ts +13 -0
- package/src/core/planning-service.ts +649 -0
- package/src/core/planning-utils.ts +299 -0
- package/src/core/scheduler-factory.ts +48 -0
- package/src/core/scheduler-service.ts +123 -0
- package/src/core/service-initialization.ts +194 -0
- package/src/core/service-interfaces.ts +48 -0
- package/src/core/service-registry.ts +659 -0
- package/src/healthcheck/index.ts +271 -265
- package/src/healthcheck/registry.ts +130 -0
- package/src/index.ts +639 -50
- package/src/infrastructure/browser/browser-cleanup.ts +309 -0
- package/src/infrastructure/browser/browser-client.ts +199 -0
- package/src/infrastructure/browser/browser-error-utils.ts +160 -0
- package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
- package/src/infrastructure/browser/browser-server.ts +174 -0
- package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
- package/src/infrastructure/browser/cleanup-utils.ts +155 -0
- package/src/infrastructure/browser/client-agent.ts +31 -0
- package/src/infrastructure/browser/config.ts +288 -0
- package/src/infrastructure/browser/ensure-browser.ts +205 -0
- package/src/infrastructure/browser/index.ts +37 -0
- package/src/infrastructure/browser/priority-task-queue.ts +226 -0
- package/src/infrastructure/browser/scheduler-factory.ts +354 -0
- package/src/infrastructure/browser/task-execution-service.ts +209 -0
- package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
- package/src/infrastructure/browser/thread-worker-types.ts +25 -0
- package/src/infrastructure/browser/thread-worker.mjs +2611 -0
- package/src/infrastructure/browser/thread-worker.ts +168 -0
- package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
- package/src/infrastructure/embedding/embedding-client.ts +205 -0
- package/src/infrastructure/embedding/embedding-factory.ts +324 -0
- package/src/infrastructure/embedding/embedding-server.ts +535 -0
- package/src/infrastructure/file-lock-service.ts +592 -0
- package/src/infrastructure/gpu-resource-service.ts +191 -0
- package/src/infrastructure/knowledge-store-service.ts +475 -0
- package/src/infrastructure/metrics-service.ts +129 -0
- package/src/infrastructure/process-lifecycle-service.ts +253 -0
- package/src/infrastructure/scheduler-factory-service.ts +58 -0
- package/src/infrastructure/service-initialization.ts +292 -0
- package/src/infrastructure/state/state-backup-manager.ts +290 -0
- package/src/infrastructure/state/state-browser-api.ts +62 -0
- package/src/infrastructure/state/state-browser-manager.ts +70 -0
- package/src/infrastructure/state/state-manager-service.ts +264 -0
- package/src/infrastructure/state/state-manager.ts +547 -0
- package/src/infrastructure/state/state-metrics.ts +92 -0
- package/src/infrastructure/state/state-migration.ts +103 -0
- package/src/infrastructure/state/state-path-configuration.ts +119 -0
- package/src/infrastructure/state/state-session-api.ts +94 -0
- package/src/infrastructure/state/state-session-manager.ts +127 -0
- package/src/infrastructure/state/state-validator.ts +93 -0
- package/src/infrastructure/types/state-types.ts +85 -0
- package/src/knowledge/chunker.ts +157 -0
- package/src/knowledge/embedder-init.ts +413 -0
- package/src/knowledge/embedder-utils.ts +296 -0
- package/src/knowledge/embedder.ts +592 -0
- package/src/knowledge/index.ts +126 -0
- package/src/knowledge/lancedb-loader.ts +35 -0
- package/src/knowledge/migration.ts +17 -0
- package/src/knowledge/model-config.ts +132 -0
- package/src/knowledge/onnx-env.ts +48 -0
- package/src/knowledge/store-operations.ts +291 -0
- package/src/knowledge/store-schema.ts +79 -0
- package/src/knowledge/store.ts +1251 -0
- package/src/knowledge/transformers-loader.ts +31 -0
- package/src/knowledge/webgpu-probe.mjs +80 -0
- package/src/knowledge/webgpu-viability.ts +255 -0
- package/src/knowledge/writer-queue.ts +252 -0
- package/src/logger.ts +316 -122
- package/src/observers/research-observer-impl.ts +459 -0
- package/src/orchestration/deep-research-orchestrator.ts +524 -773
- package/src/orchestration/deep-research-types.ts +47 -35
- package/src/orchestration/headless-observer.ts +114 -0
- package/src/orchestration/orchestration-types.ts +33 -0
- package/src/orchestration/quick-research-orchestrator.ts +463 -0
- package/src/orchestration/research-orchestration-service.ts +549 -0
- package/src/orchestration/research-session-service.ts +178 -0
- package/src/orchestration/research-synthesis-service.ts +348 -0
- package/src/orchestration/researcher-executor.ts +394 -0
- package/src/orchestration/researcher.ts +115 -42
- package/src/orchestration/service-initialization.ts +109 -0
- package/src/orchestration/session-context.ts +7 -3
- package/src/orchestration/session-state.ts +672 -0
- package/src/prompts/research-knowledge-relevance-triage.md +35 -0
- package/src/prompts/research-knowledge-search-extractor.md +41 -0
- package/src/prompts/research-tool-usage.md +63 -16
- package/src/prompts/researcher.md +59 -136
- package/src/prompts/system-coordinator.md +64 -13
- package/src/prompts/system-lead-evaluator.md +82 -189
- package/src/research-config.ts +872 -0
- package/src/sdk.ts +847 -0
- package/src/security/cisa-kev.ts +72 -9
- package/src/security/cvss.ts +92 -0
- package/src/security/ecosystem.ts +45 -0
- package/src/security/github-advisories.ts +168 -428
- package/src/security/github-advisory-types.ts +282 -0
- package/src/security/index.ts +63 -177
- package/src/security/nvd-client.ts +550 -0
- package/src/security/nvd-types.ts +179 -0
- package/src/security/osv-client.ts +246 -0
- package/src/security/osv-types.ts +285 -0
- package/src/security/types.ts +67 -53
- package/src/skill-install/skill-installer.ts +519 -0
- package/src/stackexchange/index.ts +81 -27
- package/src/stackexchange/output/compact.ts +7 -6
- package/src/stackexchange/output/table.ts +11 -7
- package/src/stackexchange/queries.ts +0 -10
- package/src/stackexchange/rest-client.ts +141 -66
- package/src/stackexchange/types.ts +0 -17
- package/src/tool.ts +15 -423
- package/src/tools/grep.ts +32 -310
- package/src/tools/health-tool-definition.ts +101 -0
- package/src/tools/index.ts +38 -9
- package/src/tools/research-knowledge-search.ts +974 -0
- package/src/tools/research-knowledge-types.ts +77 -0
- package/src/tools/research-tool-definition.ts +494 -0
- package/src/tools/scrape.ts +286 -388
- package/src/tools/search.ts +98 -153
- package/src/tools/security.ts +107 -80
- package/src/tools/stackexchange.ts +58 -35
- package/src/tools/youtube-transcript.ts +163 -0
- package/src/tui/knowledge-search-panel.ts +86 -0
- package/src/tui/pulse.ts +86 -0
- package/src/tui/research-health.ts +124 -0
- package/src/tui/research-panel-color-utils.ts +340 -0
- package/src/tui/research-panel-format.ts +54 -0
- package/src/tui/research-panel-state.ts +404 -0
- package/src/tui/research-panel-wave.ts +280 -0
- package/src/tui/research-panel.ts +339 -435
- package/src/tui/research-tui-manager.ts +233 -0
- package/src/tui/tui-controller.ts +137 -0
- package/src/types/extension-context.ts +18 -49
- package/src/types/index.ts +61 -0
- package/src/types/llm.ts +68 -29
- package/src/types/research-panel-types.ts +61 -0
- package/src/utils/circuit-breaker.ts +146 -0
- package/src/utils/citation-utils.ts +97 -0
- package/src/utils/disk-space-checker.ts +55 -0
- package/src/utils/error-tracker.ts +265 -0
- package/src/utils/host-config.ts +45 -0
- package/src/utils/input-validation.ts +8 -10
- package/src/utils/json-utils.ts +150 -16
- package/src/utils/llm-usage.ts +54 -0
- package/src/utils/log-rotation.ts +125 -0
- package/src/utils/log-utils.ts +159 -0
- package/src/utils/make-resource-loader.ts +3 -1
- package/src/utils/metrics-summary.ts +459 -0
- package/src/utils/metrics.ts +302 -0
- package/src/utils/research-export.ts +28 -12
- package/src/utils/safe-unref.ts +31 -0
- package/src/utils/shared-links.ts +195 -90
- package/src/utils/shutdown-manager.ts +65 -0
- package/src/utils/stdio-capture.ts +407 -0
- package/src/utils/text-utils.ts +264 -7
- package/src/utils/tool-usage-tracker.ts +83 -16
- package/src/utils/url-utils.ts +255 -0
- package/src/utils/user-agent.ts +28 -0
- package/src/web-research/browser-search.ts +179 -0
- package/src/web-research/retry-utils.ts +173 -77
- package/src/web-research/scraper-types.ts +72 -0
- package/src/web-research/scraper-utils.ts +434 -0
- package/src/web-research/search.ts +78 -218
- package/src/web-research/types.ts +9 -24
- package/src/web-research/utils.ts +2 -143
- package/src/web-research/web-scraper.ts +477 -0
- package/src/youtube/index.ts +154 -0
- package/src/youtube/potoken.ts +259 -0
- package/src/youtube/transcript-client.ts +390 -0
- package/src/youtube/video-id.ts +118 -0
- package/config/searxng/default-settings.yml +0 -189
- package/config/searxng/limiter.toml +0 -34
- package/config/tooling/eslint.config.js +0 -73
- package/config/tooling/vitest.config.integration.ts +0 -17
- package/config/tooling/vitest.config.ts +0 -40
- package/config/tooling/vitest.config.unit.ts +0 -12
- package/docs/assets/README-banner.jpg +0 -0
- package/scripts/setup.js +0 -174
- package/src/infrastructure/searxng-lifecycle.ts +0 -577
- package/src/infrastructure/searxng-manager.ts +0 -1183
- package/src/infrastructure/state-manager.ts +0 -918
- package/src/orchestration/deep-research-reducer.ts +0 -129
- package/src/orchestration/id-utils.ts +0 -63
- package/src/security/nvd.ts +0 -532
- package/src/security/osv.ts +0 -487
- package/src/stackexchange/cache.ts +0 -111
- package/src/stackexchange/output/index.ts +0 -7
- package/src/stackexchange/output/json.ts +0 -21
- package/src/tui/searxng-status.ts +0 -80
- package/src/utils/inject-date.ts +0 -29
- package/src/utils/research-error.ts +0 -205
- package/src/utils/searxng-config.ts +0 -94
- package/src/utils/session-state.ts +0 -295
- package/src/web-research/dependencies.ts +0 -20
- package/src/web-research/scrapers.ts +0 -935
- package/tsconfig.json +0 -40
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL Utilities
|
|
3
|
+
*
|
|
4
|
+
* Unified functions for URL normalization and validation used across the
|
|
5
|
+
* knowledge store, writer queue, and shared link registry.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { logger } from '../logger.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Strips trailing punctuation an LLM commonly appends to a URL in prose
|
|
12
|
+
* (sentence periods, list commas, markdown emphasis/code markers) WITHOUT
|
|
13
|
+
* corrupting the URL itself:
|
|
14
|
+
* - A trailing closing bracket (')', ']', '}') is removed only when it is
|
|
15
|
+
* unbalanced (e.g. from "(see http://x)" or "[1] http://x]"). A balanced
|
|
16
|
+
* one is part of the URL — e.g. Wikipedia's
|
|
17
|
+
* "…/Python_(programming_language)" or an IPv6 "http://[::1]/" — and is
|
|
18
|
+
* preserved.
|
|
19
|
+
* - '_' and '~' are RFC 3986 unreserved characters and are legitimate at the
|
|
20
|
+
* end of a URL, so they are never stripped (in the main parse path a leading
|
|
21
|
+
* markdown marker would have made new URL() throw and diverted to the
|
|
22
|
+
* aggressive fallback, so a trailing one here is far more likely genuine).
|
|
23
|
+
* The loop is bounded to defend against pathological input (ReDoS-free).
|
|
24
|
+
*/
|
|
25
|
+
export function stripTrailingLlmPunctuation(input: string): string {
|
|
26
|
+
let s = input;
|
|
27
|
+
for (let i = 0; i < 25 && s.length > 0; i++) {
|
|
28
|
+
const last = s[s.length - 1]!;
|
|
29
|
+
if (last === '*' || last === '`' || last === '.' || last === ',') {
|
|
30
|
+
s = s.slice(0, -1);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
// Balance-aware removal of a trailing closing bracket: strip it only when it
|
|
34
|
+
// has no matching opener anywhere in the string (so a markdown/parenthetical
|
|
35
|
+
// noise bracket is removed, but a balanced one that's part of the URL —
|
|
36
|
+
// Wikipedia "..._(programming_language)", IPv6 "http://[::1]/" — is kept).
|
|
37
|
+
if (last === ')' || last === ']' || last === '}') {
|
|
38
|
+
const open = last === ')' ? '(' : last === ']' ? '[' : '{';
|
|
39
|
+
let opens = 0;
|
|
40
|
+
let closes = 0;
|
|
41
|
+
for (const ch of s) {
|
|
42
|
+
if (ch === open) opens++;
|
|
43
|
+
else if (ch === last) closes++;
|
|
44
|
+
}
|
|
45
|
+
if (closes > opens) {
|
|
46
|
+
s = s.slice(0, -1);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
return s;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Normalizes a URL for consistent deduplication and storage.
|
|
57
|
+
*
|
|
58
|
+
* Features:
|
|
59
|
+
* - Forces HTTPS (deduplicates http/https as the same content)
|
|
60
|
+
* - Lowercases hostname
|
|
61
|
+
* - Removes default ports (80 for http, 443 for https)
|
|
62
|
+
* - Removes ALL hash fragments
|
|
63
|
+
* - Sorts query parameters for consistent ordering
|
|
64
|
+
* - Strips trailing slashes from pathnames
|
|
65
|
+
* - Strips trailing markdown markers (e.g. **) or trailing punctuation
|
|
66
|
+
*
|
|
67
|
+
* @param url - The URL to normalize
|
|
68
|
+
* @returns The normalized URL string, or the original string if parsing fails
|
|
69
|
+
*/
|
|
70
|
+
export function normalizeUrl(url: string): string {
|
|
71
|
+
if (!url || typeof url !== 'string') return url;
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
// Guard against pathological inputs (ReDoS defense for subsequent regex)
|
|
75
|
+
if (url.length > 4096) return url.trim().split('#')[0]!.toLowerCase();
|
|
76
|
+
// 1. Strip trailing markdown markers and punctuation often found in LLM output
|
|
77
|
+
// (balanced parens and unreserved trailing chars are preserved).
|
|
78
|
+
const cleanUrl = stripTrailingLlmPunctuation(url.trim());
|
|
79
|
+
|
|
80
|
+
const parsed = new URL(cleanUrl);
|
|
81
|
+
|
|
82
|
+
// 2. Force HTTPS (dedup check)
|
|
83
|
+
parsed.protocol = 'https:';
|
|
84
|
+
|
|
85
|
+
// 3. Lowercase hostname
|
|
86
|
+
parsed.hostname = parsed.hostname.toLowerCase();
|
|
87
|
+
|
|
88
|
+
// 4. Remove default ports
|
|
89
|
+
if (parsed.port === '443' || parsed.port === '80') {
|
|
90
|
+
parsed.port = '';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 5. Remove all hash fragments
|
|
94
|
+
parsed.hash = '';
|
|
95
|
+
|
|
96
|
+
// 6. Sort query parameters
|
|
97
|
+
parsed.searchParams.sort();
|
|
98
|
+
|
|
99
|
+
// 7. Strip trailing slashes from pathname (URL forces / for root, so we keep length > 1)
|
|
100
|
+
while (parsed.pathname.length > 1 && parsed.pathname.endsWith('/')) {
|
|
101
|
+
const trimmed = parsed.pathname.slice(0, -1);
|
|
102
|
+
parsed.pathname = trimmed;
|
|
103
|
+
// Non-special-scheme URLs (e.g. "foo.bar:baz/") carry an opaque path whose
|
|
104
|
+
// setter silently ignores assignment. Without this guard the loop would
|
|
105
|
+
// spin forever on such input — a synchronous infinite loop that even
|
|
106
|
+
// blocks vitest's own test timeout. Bail the instant a write doesn't take.
|
|
107
|
+
if (parsed.pathname !== trimmed) break;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let result = parsed.toString();
|
|
111
|
+
|
|
112
|
+
// 8. Final cleanup: strip trailing slash and question mark
|
|
113
|
+
if (result.endsWith('/')) {
|
|
114
|
+
result = result.slice(0, -1);
|
|
115
|
+
}
|
|
116
|
+
if (result.endsWith('?')) {
|
|
117
|
+
result = result.slice(0, -1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return result;
|
|
121
|
+
} catch (_err) {
|
|
122
|
+
// Fallback for invalid URLs: aggressive best-effort normalization.
|
|
123
|
+
// LLM hallucinations often produce broken URLs — log a warning so we can
|
|
124
|
+
// trace them, then do our best to clean them for dedup purposes.
|
|
125
|
+
logger.debug(`[url-utils] normalizing unparseable URL (${url.slice(0, 200)}): ${_err instanceof Error ? _err.message : String(_err)}`);
|
|
126
|
+
|
|
127
|
+
let cleaned = url.trim()
|
|
128
|
+
// Strip leading/trailing markdown and punctuation often added by LLMs.
|
|
129
|
+
// Quantifiers are bounded ({1,20}) rather than unbounded (+) to avoid a
|
|
130
|
+
// polynomial-backtracking (ReDoS) regex on unsanitized URL input; no
|
|
131
|
+
// legitimate URL carries 20+ leading/trailing punctuation characters.
|
|
132
|
+
.replace(/^[*_~`"']{1,20}/, '')
|
|
133
|
+
.replace(/[*_~`"',.)}\]]{1,20}$/, '');
|
|
134
|
+
|
|
135
|
+
// Strip hash fragment (best-effort)
|
|
136
|
+
const hashIdx = cleaned.indexOf('#');
|
|
137
|
+
if (hashIdx >= 0) cleaned = cleaned.substring(0, hashIdx);
|
|
138
|
+
|
|
139
|
+
// Strip trailing slash
|
|
140
|
+
if (cleaned.endsWith('/') && cleaned.length > 8) {
|
|
141
|
+
cleaned = cleaned.slice(0, -1);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Lowercase everything
|
|
145
|
+
cleaned = cleaned.toLowerCase();
|
|
146
|
+
|
|
147
|
+
// Try to force https:// prefix if it looks like a domain
|
|
148
|
+
if (!cleaned.startsWith('http') && cleaned.includes('.')) {
|
|
149
|
+
cleaned = 'https://' + cleaned.replace(/^\/{2,}/, '');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return cleaned;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// Initial-links validation (shared by the CLI --initial-links flag and the
|
|
158
|
+
// research tool's initialLinks parameter, so the two paths cannot drift).
|
|
159
|
+
// These links are templated verbatim into the researcher prompt as trusted
|
|
160
|
+
// seed evidence, so they must be bounded and shape-checked at every entry
|
|
161
|
+
// point: http(s)-only, ≤ MAX_INITIAL_LINK_CHARS each, ≤ MAX_INITIAL_LINKS.
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
/** Maximum number of initial/seed links a caller may provide. */
|
|
165
|
+
export const MAX_INITIAL_LINKS = 20;
|
|
166
|
+
|
|
167
|
+
/** Maximum length (chars) of each initial/seed link. */
|
|
168
|
+
export const MAX_INITIAL_LINK_CHARS = 2048;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Validate a single initial/seed link.
|
|
172
|
+
* @returns null when valid, or a human-readable reason (no trailing period).
|
|
173
|
+
*/
|
|
174
|
+
export function validateInitialLink(link: string): string | null {
|
|
175
|
+
if (link.length > MAX_INITIAL_LINK_CHARS) {
|
|
176
|
+
return `each URL must be at most ${MAX_INITIAL_LINK_CHARS} characters`;
|
|
177
|
+
}
|
|
178
|
+
let parsed: URL;
|
|
179
|
+
try {
|
|
180
|
+
parsed = new URL(link);
|
|
181
|
+
} catch {
|
|
182
|
+
return `"${link}" is not a valid URL`;
|
|
183
|
+
}
|
|
184
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
185
|
+
return 'only http(s) URLs are allowed';
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Validate a full initialLinks array (count cap + per-link checks).
|
|
192
|
+
* @returns null when valid, or a human-readable reason (no trailing period).
|
|
193
|
+
*/
|
|
194
|
+
export function validateInitialLinks(links: readonly string[]): string | null {
|
|
195
|
+
if (links.length > MAX_INITIAL_LINKS) {
|
|
196
|
+
return `at most ${MAX_INITIAL_LINKS} URLs may be provided`;
|
|
197
|
+
}
|
|
198
|
+
for (const link of links) {
|
|
199
|
+
const err = validateInitialLink(link);
|
|
200
|
+
if (err) return err;
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Validates URL format and applies basic SSRF defense-in-depth.
|
|
207
|
+
*
|
|
208
|
+
* Rejects:
|
|
209
|
+
* - Non-HTTP/HTTPS protocols
|
|
210
|
+
* - Localhost, loopback, and private network ranges
|
|
211
|
+
* - Hostnames without a public TLD (e.g. .local, .internal)
|
|
212
|
+
* - Obvious hallucinations or malformed strings
|
|
213
|
+
*
|
|
214
|
+
* @param url - The URL to validate
|
|
215
|
+
* @returns True if the URL is considered safe and valid for research
|
|
216
|
+
*/
|
|
217
|
+
export function validateUrl(url: string): boolean {
|
|
218
|
+
if (!url || typeof url !== 'string') return false;
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const parsed = new URL(url);
|
|
222
|
+
|
|
223
|
+
// Only allow HTTP/HTTPS
|
|
224
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
|
|
225
|
+
|
|
226
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
227
|
+
if (!hostname) return false;
|
|
228
|
+
|
|
229
|
+
// SSRF Defense: Reject localhost and loopback
|
|
230
|
+
if (hostname === 'localhost' || hostname.endsWith('.localhost')) return false;
|
|
231
|
+
if (hostname === '::1' || hostname === '[::1]') return false;
|
|
232
|
+
|
|
233
|
+
// SSRF Defense: Reject private/internal IP ranges
|
|
234
|
+
// IPv4: 10.x, 172.16-31.x, 192.168.x, 127.x, 169.254.x, 0.x
|
|
235
|
+
if (/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|127\.|0\.|169\.254\.)/.test(hostname)) return false;
|
|
236
|
+
|
|
237
|
+
// IPv6 private ranges
|
|
238
|
+
// Note: new URL().hostname wraps IPv6 literals in brackets, so IPv6 ranges
|
|
239
|
+
// must be matched on the bracketed form. Matching the bracketless prefix
|
|
240
|
+
// (e.g. "fc"/"fd") would instead reject ordinary domains like fc2.com,
|
|
241
|
+
// fcc.gov, fda.gov and fdic.gov — a false-negative, not SSRF protection
|
|
242
|
+
// (the connect-time IP pin in validateUrlForSSRF is the real gate).
|
|
243
|
+
if (hostname.startsWith('fe80:') || hostname.startsWith('[fe80:')) return false;
|
|
244
|
+
if (hostname.startsWith('[fc') || hostname.startsWith('[fd')) return false;
|
|
245
|
+
if (hostname.startsWith('::ffff:') || hostname.startsWith('[::ffff:')) return false;
|
|
246
|
+
|
|
247
|
+
// Reject hostnames without a public TLD or with internal suffixes
|
|
248
|
+
if (hostname.endsWith('.local') || hostname.endsWith('.internal')) return false;
|
|
249
|
+
if (!hostname.includes('.')) return false; // Must have at least one dot (e.g. example.com)
|
|
250
|
+
|
|
251
|
+
return true;
|
|
252
|
+
} catch {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Realistic User-Agent Strings for Browser Rotation
|
|
3
|
+
*
|
|
4
|
+
* Provides current, realistic User-Agent strings that mimic real browsers.
|
|
5
|
+
* Updated regularly to match current browser versions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const USER_AGENTS = [
|
|
9
|
+
// Chrome on Windows (updated 2025)
|
|
10
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
|
|
11
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
|
|
12
|
+
|
|
13
|
+
// Chrome on macOS
|
|
14
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
|
|
15
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
|
|
16
|
+
|
|
17
|
+
// Chrome on Linux
|
|
18
|
+
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
|
|
19
|
+
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
|
|
20
|
+
|
|
21
|
+
// Firefox on Windows (updated 2025)
|
|
22
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:139.0) Gecko/20100101 Firefox/139.0',
|
|
23
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0',
|
|
24
|
+
|
|
25
|
+
// Firefox on macOS
|
|
26
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:139.0) Gecko/20100101 Firefox/139.0',
|
|
27
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:138.0) Gecko/20100101 Firefox/138.0',
|
|
28
|
+
];
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Research Extension - Browser-based Search (DuckDuckGo Lite)
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates exhaustive search bursts across multiple worker processes.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { runWorkerSearch } from '../infrastructure/browser/task-execution-service.ts';
|
|
8
|
+
import { getMaxWorkers } from '../infrastructure/browser/config.ts';
|
|
9
|
+
import { logger } from '../logger.ts';
|
|
10
|
+
import { safeUnref } from '../utils/safe-unref.ts';
|
|
11
|
+
import { normalizeUrl } from '../utils/url-utils.ts';
|
|
12
|
+
import type { SearchResult } from './types.ts';
|
|
13
|
+
import type { Config } from '../config.ts';
|
|
14
|
+
import { metrics } from '../utils/metrics.ts';
|
|
15
|
+
import { getServiceContainer } from '../core/service-registry.ts';
|
|
16
|
+
import type { ServiceContainer } from '../core/service-registry.ts';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Orchestrate high-fidelity search across multiple queries.
|
|
20
|
+
* Uses true multithreaded worker processes for maximum burst performance.
|
|
21
|
+
* Each worker maintains its own "warm" browser process.
|
|
22
|
+
*/
|
|
23
|
+
export async function performSearch(
|
|
24
|
+
queries: string[],
|
|
25
|
+
config?: Config,
|
|
26
|
+
signal?: AbortSignal,
|
|
27
|
+
onProgress?: (links: number) => void,
|
|
28
|
+
container: ServiceContainer = getServiceContainer()
|
|
29
|
+
): Promise<Map<string, SearchResult[]>> {
|
|
30
|
+
const startTime = Date.now();
|
|
31
|
+
const resultMap = new Map<string, SearchResult[]>();
|
|
32
|
+
const seenUrls = new Set<string>();
|
|
33
|
+
|
|
34
|
+
// FULL_MOCK_MODE: When both PI_RESEARCH_MOCK_SEARCH and PI_RESEARCH_MOCK_SCRAPE
|
|
35
|
+
// are true, return deterministic mock results without spawning any worker processes.
|
|
36
|
+
// This gate keeps CI tests deterministic and fast, and prevents real worker pool
|
|
37
|
+
// failures from cascading into test flakiness.
|
|
38
|
+
const fullMockMode = process.env['PI_RESEARCH_MOCK_SEARCH'] === 'true' &&
|
|
39
|
+
process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true';
|
|
40
|
+
if (fullMockMode) {
|
|
41
|
+
logger.log('[Search] FULL_MOCK_MODE enabled — returning mock results without worker pool');
|
|
42
|
+
for (const q of queries) {
|
|
43
|
+
const domain = q.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase().slice(0, 30);
|
|
44
|
+
resultMap.set(q, [{
|
|
45
|
+
title: `Mock result for: ${q}`,
|
|
46
|
+
url: `https://mock.example.com/${domain}`,
|
|
47
|
+
content: `This is a mock search result for query "${q}".`,
|
|
48
|
+
}]);
|
|
49
|
+
}
|
|
50
|
+
return resultMap;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const maxWorkers = getMaxWorkers(config);
|
|
54
|
+
|
|
55
|
+
metrics.setGauge('browser_search_max_workers', maxWorkers);
|
|
56
|
+
metrics.increment('browser_search_orchestrations_total', 1);
|
|
57
|
+
|
|
58
|
+
logger.log(`[Search] Orchestrating ${queries.length} queries across ${maxWorkers} worker processes...`);
|
|
59
|
+
|
|
60
|
+
// Call onProgress(0) immediately to clear any 'searching' placeholder in the UI
|
|
61
|
+
if (onProgress) onProgress(0);
|
|
62
|
+
|
|
63
|
+
// Hard cap per query so a single Cloudflare block or hung browser worker
|
|
64
|
+
// cannot stall the entire Promise.all burst. Derived from the configured
|
|
65
|
+
// search timeout (default 45s) so raising SEARCH_TIMEOUT_MS actually takes
|
|
66
|
+
// effect here instead of being silently overridden by a lower literal.
|
|
67
|
+
const QUERY_TIMEOUT_MS = config?.SEARCH_TIMEOUT_MS ?? 45_000;
|
|
68
|
+
|
|
69
|
+
const filteredQueries = queries.filter(q => q.trim());
|
|
70
|
+
let timeoutCount = 0;
|
|
71
|
+
let errorCount = 0;
|
|
72
|
+
// Keep one representative worker error so total-failure surfaces the real
|
|
73
|
+
// cause (e.g. a worker that cannot load its module) instead of a generic
|
|
74
|
+
// timeout guess that misdirects to the network or system load.
|
|
75
|
+
let sampleWorkerError = '';
|
|
76
|
+
|
|
77
|
+
const searchTasks = filteredQueries.map(async (query) => {
|
|
78
|
+
const queryStartTime = Date.now();
|
|
79
|
+
const timeoutController = new AbortController();
|
|
80
|
+
const timeoutId = setTimeout(() => timeoutController.abort(), QUERY_TIMEOUT_MS);
|
|
81
|
+
safeUnref(timeoutId);
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
if (signal?.aborted) {
|
|
85
|
+
resultMap.set(query, []);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const querySignal = signal
|
|
90
|
+
? AbortSignal.any([signal, timeoutController.signal])
|
|
91
|
+
: timeoutController.signal;
|
|
92
|
+
|
|
93
|
+
const results = await runWorkerSearch(query, config, querySignal, 1, undefined, container);
|
|
94
|
+
const queryDuration = Date.now() - queryStartTime;
|
|
95
|
+
metrics.observe('browser_search_query_duration_ms', queryDuration);
|
|
96
|
+
|
|
97
|
+
if (results?.length > 0) {
|
|
98
|
+
metrics.increment('browser_search_queries_total', 1, { status: 'success' });
|
|
99
|
+
metrics.increment('browser_search_results_total', results.length);
|
|
100
|
+
logger.debug(`[Search] Worker returned ${results.length} results for: ${query}`);
|
|
101
|
+
|
|
102
|
+
const uniqueResults: SearchResult[] = [];
|
|
103
|
+
for (const r of results) {
|
|
104
|
+
// Dedup across ALL queries (seenUrls), not just within this query.
|
|
105
|
+
// The same URL surfacing for several queries would otherwise repeat
|
|
106
|
+
// its snippet in the combined output, wasting context tokens; this
|
|
107
|
+
// also makes seenUrls.size a true cross-query unique count for the
|
|
108
|
+
// progress callback and the unique-URLs metric.
|
|
109
|
+
// Dedup on the NORMALIZED url (consistent with shared-links / knowledge store),
|
|
110
|
+
// so http/https, trailing-slash, and tracking-param variants of the same page
|
|
111
|
+
// collapse to one — otherwise snippets repeat across queries (wasting context)
|
|
112
|
+
// and seenUrls.size over-counts uniques for the progress callback / metric.
|
|
113
|
+
const key = r.url ? normalizeUrl(r.url) : '';
|
|
114
|
+
if (key && !seenUrls.has(key)) {
|
|
115
|
+
seenUrls.add(key);
|
|
116
|
+
uniqueResults.push(r);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
resultMap.set(query, uniqueResults);
|
|
120
|
+
} else {
|
|
121
|
+
metrics.increment('browser_search_queries_total', 1, { status: 'no_results' });
|
|
122
|
+
resultMap.set(query, []);
|
|
123
|
+
}
|
|
124
|
+
} catch (error) {
|
|
125
|
+
const queryDuration = Date.now() - queryStartTime;
|
|
126
|
+
const isTimeout = timeoutController.signal.aborted && !signal?.aborted;
|
|
127
|
+
const status = isTimeout ? 'timeout' : 'error';
|
|
128
|
+
|
|
129
|
+
if (isTimeout) timeoutCount++;
|
|
130
|
+
else errorCount++;
|
|
131
|
+
|
|
132
|
+
metrics.observe('browser_search_query_duration_ms', queryDuration, { status });
|
|
133
|
+
metrics.increment('browser_search_queries_total', 1, { status });
|
|
134
|
+
|
|
135
|
+
if (isTimeout) {
|
|
136
|
+
logger.warn(`[Search] Query timed out after ${QUERY_TIMEOUT_MS}ms (likely blocked or slow startup): "${query}"`);
|
|
137
|
+
} else {
|
|
138
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
139
|
+
if (msg !== 'Aborted') {
|
|
140
|
+
if (!sampleWorkerError) sampleWorkerError = msg;
|
|
141
|
+
logger.error(`[Search] Worker failed for "${query}": ${msg}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
resultMap.set(query, []);
|
|
145
|
+
} finally {
|
|
146
|
+
clearTimeout(timeoutId);
|
|
147
|
+
if (onProgress) onProgress(seenUrls.size);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await Promise.all(searchTasks);
|
|
152
|
+
|
|
153
|
+
// Detect total failure: if every valid query returned empty, the worker pool is likely dead.
|
|
154
|
+
const totalResults = Array.from(resultMap.values()).reduce((sum, r) => sum + r.length, 0);
|
|
155
|
+
if (totalResults === 0 && filteredQueries.length > 0) {
|
|
156
|
+
metrics.increment('browser_search_total_failures_total', 1);
|
|
157
|
+
|
|
158
|
+
let reason = `all ${filteredQueries.length} queries returned no results`;
|
|
159
|
+
if (timeoutCount === filteredQueries.length) {
|
|
160
|
+
reason = `all ${filteredQueries.length} queries timed out after ${QUERY_TIMEOUT_MS}ms`;
|
|
161
|
+
} else if (errorCount === filteredQueries.length) {
|
|
162
|
+
reason = `all ${filteredQueries.length} queries encountered worker errors`;
|
|
163
|
+
} else if (timeoutCount + errorCount === filteredQueries.length) {
|
|
164
|
+
reason = `${timeoutCount} queries timed out and ${errorCount} queries failed`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
throw new Error(
|
|
168
|
+
`Search completely failed: ${reason}. ` +
|
|
169
|
+
`Browser workers may be unavailable, DuckDuckGo is unreachable, or the system is under extreme load.` +
|
|
170
|
+
(sampleWorkerError ? ` Last worker error: ${sampleWorkerError}` : '')
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const totalDuration = Date.now() - startTime;
|
|
175
|
+
metrics.observe('browser_search_total_duration_ms', totalDuration);
|
|
176
|
+
metrics.increment('browser_search_unique_urls_total', seenUrls.size);
|
|
177
|
+
|
|
178
|
+
return resultMap;
|
|
179
|
+
}
|