@lincoln504/pi-research 0.1.13 → 1.0.1

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.
Files changed (248) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +55 -138
  3. package/dist/cli.mjs +23375 -0
  4. package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
  5. package/dist/prompts/research-knowledge-search-extractor.md +41 -0
  6. package/dist/prompts/research-tool-usage.md +92 -0
  7. package/dist/prompts/researcher.md +89 -0
  8. package/dist/prompts/system-coordinator.md +75 -0
  9. package/dist/prompts/system-lead-evaluator.md +118 -0
  10. package/dist/thread-worker.mjs +2611 -0
  11. package/dist/webgpu-probe.mjs +80 -0
  12. package/docs/AGENT-SKILL.md +130 -0
  13. package/docs/ARCHITECTURE.md +306 -80
  14. package/docs/CONFIGURATION.md +249 -0
  15. package/docs/KNOWLEDGE-STORE.md +211 -0
  16. package/docs/PI-EXTENSION.md +91 -0
  17. package/docs/SDK.md +122 -0
  18. package/docs/THIRD-PARTY-NOTICES.md +66 -0
  19. package/package.json +102 -36
  20. package/scripts/build.cjs +140 -0
  21. package/scripts/cleanup.cjs +149 -0
  22. package/scripts/prepare.cjs +28 -0
  23. package/scripts/setup.cjs +134 -0
  24. package/skills/pi-research/README.md +90 -0
  25. package/skills/pi-research/SKILL.md +102 -0
  26. package/skills/pi-research/references/configuration.md +77 -0
  27. package/skills/pi-research/scripts/run.mjs +237 -0
  28. package/src/cleanup/research-cleanup.ts +140 -0
  29. package/src/cli.ts +1195 -0
  30. package/src/config.ts +1032 -155
  31. package/src/constants.ts +115 -84
  32. package/src/core/health-check-service.ts +265 -0
  33. package/src/core/interfaces/health-check-interfaces.ts +50 -0
  34. package/src/core/interfaces/knowledge-interfaces.ts +190 -0
  35. package/src/core/interfaces/observer-interfaces.ts +67 -0
  36. package/src/core/interfaces/orchestration-interfaces.ts +86 -0
  37. package/src/core/interfaces/planning-interfaces.ts +100 -0
  38. package/src/core/interfaces/process-interfaces.ts +37 -0
  39. package/src/core/interfaces/research-plan-types.ts +52 -0
  40. package/src/core/interfaces/scheduler-interfaces.ts +91 -0
  41. package/src/core/interfaces/service-names.ts +39 -0
  42. package/src/core/interfaces/state-manager-interfaces.ts +29 -0
  43. package/src/core/interfaces/steering-interfaces.ts +31 -0
  44. package/src/core/llm/agentic-repair.ts +169 -0
  45. package/src/core/llm/inject-date.ts +52 -0
  46. package/src/core/llm/llm-timeout.ts +64 -0
  47. package/src/core/llm/llm-utils.ts +86 -0
  48. package/src/core/llm/model-registry-factory.ts +287 -0
  49. package/src/core/llm/pi-ai-completion.ts +29 -0
  50. package/src/core/llm/prompts.ts +50 -0
  51. package/src/core/llm/research-model-resolver.ts +91 -0
  52. package/src/core/planning-constants.ts +13 -0
  53. package/src/core/planning-service.ts +649 -0
  54. package/src/core/planning-utils.ts +299 -0
  55. package/src/core/scheduler-factory.ts +48 -0
  56. package/src/core/scheduler-service.ts +123 -0
  57. package/src/core/service-initialization.ts +194 -0
  58. package/src/core/service-interfaces.ts +48 -0
  59. package/src/core/service-registry.ts +659 -0
  60. package/src/healthcheck/index.ts +271 -265
  61. package/src/healthcheck/registry.ts +130 -0
  62. package/src/index.ts +639 -50
  63. package/src/infrastructure/browser/browser-cleanup.ts +309 -0
  64. package/src/infrastructure/browser/browser-client.ts +199 -0
  65. package/src/infrastructure/browser/browser-error-utils.ts +160 -0
  66. package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
  67. package/src/infrastructure/browser/browser-server.ts +174 -0
  68. package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
  69. package/src/infrastructure/browser/cleanup-utils.ts +155 -0
  70. package/src/infrastructure/browser/client-agent.ts +31 -0
  71. package/src/infrastructure/browser/config.ts +288 -0
  72. package/src/infrastructure/browser/ensure-browser.ts +205 -0
  73. package/src/infrastructure/browser/index.ts +37 -0
  74. package/src/infrastructure/browser/priority-task-queue.ts +226 -0
  75. package/src/infrastructure/browser/scheduler-factory.ts +354 -0
  76. package/src/infrastructure/browser/task-execution-service.ts +209 -0
  77. package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
  78. package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
  79. package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
  80. package/src/infrastructure/browser/thread-worker-types.ts +25 -0
  81. package/src/infrastructure/browser/thread-worker.mjs +2611 -0
  82. package/src/infrastructure/browser/thread-worker.ts +168 -0
  83. package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
  84. package/src/infrastructure/embedding/embedding-client.ts +205 -0
  85. package/src/infrastructure/embedding/embedding-factory.ts +324 -0
  86. package/src/infrastructure/embedding/embedding-server.ts +535 -0
  87. package/src/infrastructure/file-lock-service.ts +592 -0
  88. package/src/infrastructure/gpu-resource-service.ts +191 -0
  89. package/src/infrastructure/knowledge-store-service.ts +475 -0
  90. package/src/infrastructure/metrics-service.ts +129 -0
  91. package/src/infrastructure/process-lifecycle-service.ts +253 -0
  92. package/src/infrastructure/scheduler-factory-service.ts +58 -0
  93. package/src/infrastructure/service-initialization.ts +292 -0
  94. package/src/infrastructure/state/state-backup-manager.ts +290 -0
  95. package/src/infrastructure/state/state-browser-api.ts +62 -0
  96. package/src/infrastructure/state/state-browser-manager.ts +70 -0
  97. package/src/infrastructure/state/state-manager-service.ts +264 -0
  98. package/src/infrastructure/state/state-manager.ts +547 -0
  99. package/src/infrastructure/state/state-metrics.ts +92 -0
  100. package/src/infrastructure/state/state-migration.ts +103 -0
  101. package/src/infrastructure/state/state-path-configuration.ts +119 -0
  102. package/src/infrastructure/state/state-session-api.ts +94 -0
  103. package/src/infrastructure/state/state-session-manager.ts +127 -0
  104. package/src/infrastructure/state/state-validator.ts +93 -0
  105. package/src/infrastructure/types/state-types.ts +85 -0
  106. package/src/knowledge/chunker.ts +157 -0
  107. package/src/knowledge/embedder-init.ts +413 -0
  108. package/src/knowledge/embedder-utils.ts +296 -0
  109. package/src/knowledge/embedder.ts +592 -0
  110. package/src/knowledge/index.ts +126 -0
  111. package/src/knowledge/lancedb-loader.ts +35 -0
  112. package/src/knowledge/migration.ts +17 -0
  113. package/src/knowledge/model-config.ts +132 -0
  114. package/src/knowledge/onnx-env.ts +48 -0
  115. package/src/knowledge/store-operations.ts +291 -0
  116. package/src/knowledge/store-schema.ts +79 -0
  117. package/src/knowledge/store.ts +1251 -0
  118. package/src/knowledge/transformers-loader.ts +31 -0
  119. package/src/knowledge/webgpu-probe.mjs +80 -0
  120. package/src/knowledge/webgpu-viability.ts +255 -0
  121. package/src/knowledge/writer-queue.ts +252 -0
  122. package/src/logger.ts +316 -122
  123. package/src/observers/research-observer-impl.ts +459 -0
  124. package/src/orchestration/deep-research-orchestrator.ts +524 -773
  125. package/src/orchestration/deep-research-types.ts +47 -35
  126. package/src/orchestration/headless-observer.ts +114 -0
  127. package/src/orchestration/orchestration-types.ts +33 -0
  128. package/src/orchestration/quick-research-orchestrator.ts +463 -0
  129. package/src/orchestration/research-orchestration-service.ts +549 -0
  130. package/src/orchestration/research-session-service.ts +178 -0
  131. package/src/orchestration/research-synthesis-service.ts +348 -0
  132. package/src/orchestration/researcher-executor.ts +394 -0
  133. package/src/orchestration/researcher.ts +115 -42
  134. package/src/orchestration/service-initialization.ts +109 -0
  135. package/src/orchestration/session-context.ts +7 -3
  136. package/src/orchestration/session-state.ts +672 -0
  137. package/src/prompts/research-knowledge-relevance-triage.md +35 -0
  138. package/src/prompts/research-knowledge-search-extractor.md +41 -0
  139. package/src/prompts/research-tool-usage.md +63 -16
  140. package/src/prompts/researcher.md +59 -136
  141. package/src/prompts/system-coordinator.md +64 -13
  142. package/src/prompts/system-lead-evaluator.md +82 -189
  143. package/src/research-config.ts +872 -0
  144. package/src/sdk.ts +847 -0
  145. package/src/security/cisa-kev.ts +72 -9
  146. package/src/security/cvss.ts +92 -0
  147. package/src/security/ecosystem.ts +45 -0
  148. package/src/security/github-advisories.ts +168 -428
  149. package/src/security/github-advisory-types.ts +282 -0
  150. package/src/security/index.ts +63 -177
  151. package/src/security/nvd-client.ts +550 -0
  152. package/src/security/nvd-types.ts +179 -0
  153. package/src/security/osv-client.ts +246 -0
  154. package/src/security/osv-types.ts +285 -0
  155. package/src/security/types.ts +67 -53
  156. package/src/skill-install/skill-installer.ts +519 -0
  157. package/src/stackexchange/index.ts +81 -27
  158. package/src/stackexchange/output/compact.ts +7 -6
  159. package/src/stackexchange/output/table.ts +11 -7
  160. package/src/stackexchange/queries.ts +0 -10
  161. package/src/stackexchange/rest-client.ts +141 -66
  162. package/src/stackexchange/types.ts +0 -17
  163. package/src/tool.ts +15 -423
  164. package/src/tools/grep.ts +32 -310
  165. package/src/tools/health-tool-definition.ts +101 -0
  166. package/src/tools/index.ts +38 -9
  167. package/src/tools/research-knowledge-search.ts +974 -0
  168. package/src/tools/research-knowledge-types.ts +77 -0
  169. package/src/tools/research-tool-definition.ts +494 -0
  170. package/src/tools/scrape.ts +286 -388
  171. package/src/tools/search.ts +98 -153
  172. package/src/tools/security.ts +107 -80
  173. package/src/tools/stackexchange.ts +58 -35
  174. package/src/tools/youtube-transcript.ts +163 -0
  175. package/src/tui/knowledge-search-panel.ts +86 -0
  176. package/src/tui/pulse.ts +86 -0
  177. package/src/tui/research-health.ts +124 -0
  178. package/src/tui/research-panel-color-utils.ts +340 -0
  179. package/src/tui/research-panel-format.ts +54 -0
  180. package/src/tui/research-panel-state.ts +404 -0
  181. package/src/tui/research-panel-wave.ts +280 -0
  182. package/src/tui/research-panel.ts +339 -435
  183. package/src/tui/research-tui-manager.ts +233 -0
  184. package/src/tui/tui-controller.ts +137 -0
  185. package/src/types/extension-context.ts +18 -49
  186. package/src/types/index.ts +61 -0
  187. package/src/types/llm.ts +68 -29
  188. package/src/types/research-panel-types.ts +61 -0
  189. package/src/utils/circuit-breaker.ts +146 -0
  190. package/src/utils/citation-utils.ts +97 -0
  191. package/src/utils/disk-space-checker.ts +55 -0
  192. package/src/utils/error-tracker.ts +265 -0
  193. package/src/utils/host-config.ts +45 -0
  194. package/src/utils/input-validation.ts +8 -10
  195. package/src/utils/json-utils.ts +150 -16
  196. package/src/utils/llm-usage.ts +54 -0
  197. package/src/utils/log-rotation.ts +125 -0
  198. package/src/utils/log-utils.ts +159 -0
  199. package/src/utils/make-resource-loader.ts +3 -1
  200. package/src/utils/metrics-summary.ts +459 -0
  201. package/src/utils/metrics.ts +302 -0
  202. package/src/utils/research-export.ts +28 -12
  203. package/src/utils/safe-unref.ts +31 -0
  204. package/src/utils/shared-links.ts +195 -90
  205. package/src/utils/shutdown-manager.ts +65 -0
  206. package/src/utils/stdio-capture.ts +407 -0
  207. package/src/utils/text-utils.ts +258 -11
  208. package/src/utils/tool-usage-tracker.ts +83 -16
  209. package/src/utils/url-utils.ts +255 -0
  210. package/src/utils/user-agent.ts +28 -0
  211. package/src/web-research/browser-search.ts +179 -0
  212. package/src/web-research/retry-utils.ts +173 -77
  213. package/src/web-research/scraper-types.ts +72 -0
  214. package/src/web-research/scraper-utils.ts +434 -0
  215. package/src/web-research/search.ts +78 -218
  216. package/src/web-research/types.ts +9 -24
  217. package/src/web-research/utils.ts +2 -143
  218. package/src/web-research/web-scraper.ts +477 -0
  219. package/src/youtube/index.ts +154 -0
  220. package/src/youtube/potoken.ts +259 -0
  221. package/src/youtube/transcript-client.ts +390 -0
  222. package/src/youtube/video-id.ts +118 -0
  223. package/config/searxng/default-settings.yml +0 -189
  224. package/config/searxng/limiter.toml +0 -34
  225. package/config/tooling/eslint.config.js +0 -73
  226. package/config/tooling/vitest.config.integration.ts +0 -17
  227. package/config/tooling/vitest.config.ts +0 -40
  228. package/config/tooling/vitest.config.unit.ts +0 -12
  229. package/docs/assets/README-banner.jpg +0 -0
  230. package/scripts/setup.js +0 -212
  231. package/src/infrastructure/searxng-lifecycle.ts +0 -644
  232. package/src/infrastructure/searxng-manager.ts +0 -1183
  233. package/src/infrastructure/state-manager.ts +0 -918
  234. package/src/orchestration/deep-research-reducer.ts +0 -129
  235. package/src/orchestration/id-utils.ts +0 -63
  236. package/src/security/nvd.ts +0 -532
  237. package/src/security/osv.ts +0 -487
  238. package/src/stackexchange/cache.ts +0 -111
  239. package/src/stackexchange/output/index.ts +0 -7
  240. package/src/stackexchange/output/json.ts +0 -21
  241. package/src/tui/searxng-status.ts +0 -80
  242. package/src/utils/inject-date.ts +0 -29
  243. package/src/utils/research-error.ts +0 -205
  244. package/src/utils/searxng-config.ts +0 -94
  245. package/src/utils/session-state.ts +0 -295
  246. package/src/web-research/dependencies.ts +0 -20
  247. package/src/web-research/scrapers.ts +0 -935
  248. 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
+ }