@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,209 @@
1
+ /**
2
+ * Task Execution Service
3
+ *
4
+ * Handles task execution with retry logic and circuit breaker.
5
+ * Refactored from browser-manager.ts for better separation of concerns.
6
+ */
7
+
8
+ import type { Config } from '../../config.ts';
9
+ import type { SearchResult } from '../../web-research/types.ts';
10
+ import type { BrowserTask } from '../../types/index.ts';
11
+ import { logger } from '../../logger.ts';
12
+ import { errorTracker } from '../../utils/error-tracker.ts';
13
+ import { getBrowserCircuitBreaker, browserCircuitBreaker, isTransientSocketError, isPoolShutdownError, isTaskTimeoutError, isCloudflareBlockError } from './browser-error-utils.ts';
14
+ import { getScheduler as _getScheduler, forceSchedulerRestart as _forceSchedulerRestart } from './scheduler-factory.ts';
15
+ import { waitForBrowserPoolIdle } from './browser-lifecycle.ts';
16
+ import { getServiceContainer, getService } from '../../core/service-registry.ts';
17
+ import type { ServiceContainer } from '../../core/service-registry.ts';
18
+ import { ServiceNames } from '../../core/service-interfaces.ts';
19
+ import type { ISchedulerFactory, IScheduler } from '../../core/scheduler-factory.ts';
20
+
21
+ // Cooldown to prevent cascading scheduler restarts (thundering herd)
22
+ let lastRestartTime = 0;
23
+ const RESTART_COOLDOWN_MS = 10000;
24
+
25
+ /**
26
+ * Get the scheduler from the factory, optionally using a specific container.
27
+ */
28
+ async function getScheduler(config?: Config, container: ServiceContainer = getServiceContainer()): Promise<IScheduler> {
29
+ try {
30
+ const factory = await getService<ISchedulerFactory>(ServiceNames.SCHEDULER_FACTORY, undefined, container);
31
+ return await factory.getScheduler(config);
32
+ } catch {
33
+ // Fallback to factory function if service resolution fails (e.g. during early init or tests)
34
+ return await _getScheduler(config, container);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Force a scheduler restart, optionally using a specific container.
40
+ */
41
+ async function forceSchedulerRestart(forceClearRemoteState: boolean = false, container: ServiceContainer = getServiceContainer()): Promise<void> {
42
+ try {
43
+ const factory = await getService<ISchedulerFactory>(ServiceNames.SCHEDULER_FACTORY, undefined, container);
44
+ return await factory.forceSchedulerRestart(forceClearRemoteState);
45
+ } catch {
46
+ return await _forceSchedulerRestart(forceClearRemoteState, container);
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Dispatches a browser task to the unified worker pool.
52
+ */
53
+ export async function runBrowserTask<T>(
54
+ taskOrUrl: string | BrowserTask,
55
+ type: 'search' | 'scrape' = 'scrape',
56
+ config?: Config,
57
+ signal?: AbortSignal,
58
+ retries = 1,
59
+ container: ServiceContainer = getServiceContainer()
60
+ ): Promise<T> {
61
+ if (signal?.aborted) throw new Error('Aborted');
62
+
63
+ // Extract sessionId for circuit breaker scoping
64
+ const sessionId = typeof taskOrUrl === 'object' ? taskOrUrl.sessionId : undefined;
65
+ const breaker = sessionId ? getBrowserCircuitBreaker(sessionId) : browserCircuitBreaker;
66
+
67
+ try {
68
+ return await breaker.execute(async () => {
69
+ if (signal?.aborted) throw new Error('Aborted');
70
+ const scheduler = await getScheduler(config, container);
71
+ if (type === 'search') {
72
+ const query = typeof taskOrUrl === 'string' ? taskOrUrl : (taskOrUrl as BrowserTask).query;
73
+ if (!query) throw new Error('Search task requires a query');
74
+ return (await scheduler.runSearch(query, config, signal)) as T;
75
+ }
76
+
77
+ const url = typeof taskOrUrl === 'string' ? taskOrUrl : (taskOrUrl as BrowserTask).url;
78
+ if (url) {
79
+ return (await scheduler.runScrape(url, config, signal)) as T;
80
+ }
81
+
82
+ throw new Error('Unified browser manager requires data-driven tasks (URLs/Queries)');
83
+ });
84
+ } catch (error: unknown) {
85
+ if (signal?.aborted || (error instanceof Error && error.message === 'Aborted')) throw new Error('Aborted', { cause: error });
86
+
87
+ if (retries > 0 && isTransientSocketError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error)) {
88
+ errorTracker.trackError(error, {
89
+ component: 'browser-manager',
90
+ operation: type,
91
+ taskType: type,
92
+ errorType: 'transient_socket_error',
93
+ });
94
+ logger.warn(`[BrowserManager] Transient socket error during ${type} task (retries left: ${retries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
95
+
96
+ if (isPoolShutdownError(error)) {
97
+ // Pool is temporarily draining — wait for the drain to finish.
98
+ logger.warn(`[BrowserManager] Pool is draining — waiting for pool idle before retry...`);
99
+ await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
100
+ } else {
101
+ // True socket/connection error — restart the scheduler with thundering herd guard.
102
+ const now = Date.now();
103
+ if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
104
+ lastRestartTime = now;
105
+ logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
106
+ await forceSchedulerRestart(true, container);
107
+ await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
108
+ } else {
109
+ logger.warn(`[BrowserManager] Scheduler restart recently triggered, waiting for pool idle...`);
110
+ await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
111
+ }
112
+ }
113
+
114
+ // Retry with jitter (100-500ms) to prevent thundering herd
115
+ const jitter = 100 + Math.floor(Math.random() * 400);
116
+ await new Promise(resolve => setTimeout(resolve, jitter));
117
+ return runBrowserTask<T>(taskOrUrl, type, config, signal, retries - 1, container);
118
+ }
119
+ throw error;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Run a browser health check with retry logic.
125
+ */
126
+ export async function runBrowserHealthCheck(config?: Config, retries = 1, signal?: AbortSignal, container: ServiceContainer = getServiceContainer()): Promise<{ success: boolean }> {
127
+ try {
128
+ return await browserCircuitBreaker.execute(async () => {
129
+ const scheduler = await getScheduler(config, container);
130
+ return await scheduler.runHealthCheck(config, signal);
131
+ });
132
+ } catch (error: unknown) {
133
+ if (retries > 0 && isTransientSocketError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error)) {
134
+ errorTracker.trackError(error, {
135
+ component: 'browser-manager',
136
+ operation: 'healthcheck',
137
+ errorType: 'transient_socket_error',
138
+ });
139
+ logger.warn(`[BrowserManager] Transient socket error during healthcheck (retries left: ${retries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
140
+
141
+ if (isPoolShutdownError(error)) {
142
+ await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
143
+ } else {
144
+ const now = Date.now();
145
+ if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
146
+ lastRestartTime = now;
147
+ logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
148
+ await forceSchedulerRestart(true, container);
149
+ await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
150
+ } else {
151
+ await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
152
+ }
153
+ }
154
+
155
+ const jitter = 100 + Math.floor(Math.random() * 400);
156
+ await new Promise(resolve => setTimeout(resolve, jitter));
157
+ return runBrowserHealthCheck(config, retries - 1, signal, container);
158
+ }
159
+ throw error;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Run a worker search query with retry logic.
165
+ */
166
+ export async function runWorkerSearch(query: string, config?: Config, signal?: AbortSignal, retries = 1, sessionId?: string, container: ServiceContainer = getServiceContainer()): Promise<SearchResult[]> {
167
+ if (signal?.aborted) throw new Error('Aborted');
168
+
169
+ const breaker = sessionId ? getBrowserCircuitBreaker(sessionId) : browserCircuitBreaker;
170
+
171
+ try {
172
+ return await breaker.execute(async () => {
173
+ if (signal?.aborted) throw new Error('Aborted');
174
+ const scheduler = await getScheduler(config, container);
175
+ return await scheduler.runSearch(query, config, signal);
176
+ });
177
+ } catch (error: unknown) {
178
+ if (signal?.aborted || (error instanceof Error && error.message === 'Aborted')) throw new Error('Aborted', { cause: error });
179
+
180
+ if (retries > 0 && isTransientSocketError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error)) {
181
+ errorTracker.trackError(error, {
182
+ component: 'browser-manager',
183
+ operation: 'search',
184
+ query,
185
+ errorType: 'transient_socket_error',
186
+ });
187
+ logger.warn(`[BrowserManager] Transient socket error during search (retries left: ${retries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
188
+
189
+ if (isPoolShutdownError(error)) {
190
+ await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
191
+ } else {
192
+ const now = Date.now();
193
+ if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
194
+ lastRestartTime = now;
195
+ logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
196
+ await forceSchedulerRestart(true, container);
197
+ await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
198
+ } else {
199
+ await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
200
+ }
201
+ }
202
+
203
+ const jitter = 100 + Math.floor(Math.random() * 400);
204
+ await new Promise(resolve => setTimeout(resolve, jitter));
205
+ return runWorkerSearch(query, config, signal, retries - 1, sessionId, container);
206
+ }
207
+ throw error;
208
+ }
209
+ }
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Thread Worker Browser Management
3
+ *
4
+ * Handles browser initialization, context management, and cleanup.
5
+ *
6
+ * NOTE: This file uses logToDebugFile() instead of the main-process logger because
7
+ * worker threads run in separate isolated processes. All diagnostic output is
8
+ * written to the file path in PI_RESEARCH_LOG_FILE (set by the worker pool manager).
9
+ */
10
+
11
+ import { platform } from 'node:os';
12
+ import { setupMocking } from './thread-worker-messaging.ts';
13
+ import { redactSecrets } from '../../utils/log-utils.ts';
14
+ import { resolveHeadlessMode } from './config.ts';
15
+
16
+ let browser: any = null;
17
+ let context: any = null;
18
+ let initPromise: Promise<void> | null = null;
19
+ let workerId: string = '';
20
+
21
+ /**
22
+ * Set the worker ID for logging purposes
23
+ */
24
+ export function setWorkerId(id: string): void {
25
+ workerId = id;
26
+ }
27
+
28
+ /**
29
+ * Check if browser is connected
30
+ */
31
+ const isBrowserConnected = () => {
32
+ try {
33
+ return browser && typeof browser.isConnected === 'function' && browser.isConnected();
34
+ } catch {
35
+ logToDebugFile('DEBUG', '[ThreadWorker] browser.isConnected check threw, treating as disconnected');
36
+ return false;
37
+ }
38
+ };
39
+
40
+ /**
41
+ * Log to the worker debug file (PI_RESEARCH_LOG_FILE).
42
+ * This is the only logging mechanism available to worker processes —
43
+ * the main-process logger is not accessible from isolated worker threads.
44
+ */
45
+ function logToDebugFile(level: string, ...args: any[]): void {
46
+ const logFile = process.env['PI_RESEARCH_LOG_FILE'];
47
+ if (!logFile) return;
48
+ // Match the main-process logger: WARN/ERROR are always recorded (crash diagnostics);
49
+ // INFO/DEBUG only when verbose. Without this gate a non-verbose scaling run still
50
+ // streamed thousands of worker DEBUG lines to PI_RESEARCH_LOG_FILE — often a tmpfs
51
+ // (RAM) path — adding memory and I/O pressure for no benefit during sustained loops.
52
+ if ((level === 'INFO' || level === 'DEBUG') && process.env['PI_RESEARCH_DEBUG'] !== 'true') return;
53
+
54
+ try {
55
+ const timestamp = new Date().toISOString();
56
+ const entry = {
57
+ timestamp,
58
+ level,
59
+ workerId,
60
+ message: redactSecrets(args.map(arg => {
61
+ if (arg instanceof Error) return arg.stack || arg.message;
62
+ if (typeof arg === 'object' && arg !== null) return JSON.stringify(arg);
63
+ return String(arg);
64
+ }).join(' '))
65
+ };
66
+ // FIX (#32): Use async fs.appendFile to avoid blocking the event loop.
67
+ import('node:fs/promises').then(fsp => fsp.appendFile(logFile, `${JSON.stringify(entry)}\n`)).catch(() => {});
68
+ } catch {
69
+ // ignore
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Initialize the browser instance
75
+ */
76
+ export async function initBrowser(): Promise<void> {
77
+ if (isBrowserConnected() && context) return;
78
+ if (initPromise) return initPromise;
79
+
80
+ initPromise = (async () => {
81
+ try {
82
+ if (!isBrowserConnected() || !context) {
83
+ logToDebugFile('INFO', `[Worker-${workerId}] Initializing browser instance...`);
84
+
85
+ // ---------------------------------------------------------------------
86
+ // Pinned browser stack — DO NOT caret-bump these three in isolation.
87
+ // They are one coupled set (see package.json + docs/ARCHITECTURE.md).
88
+ // Each was a real fresh-install/runtime break that our lockfile masked
89
+ // (consumers install without our lockfile, so a floating range resolves
90
+ // to a newer, broken version):
91
+ //
92
+ // • camoufox-js ^0.10.2 → fetches camoufox Firefox 135 / beta.24, the
93
+ // NEWEST camoufox with binaries for every OS we support (Linux x64/
94
+ // arm64, macOS x64/arm64, Windows x64). FF146/FF150 dropped the
95
+ // Windows build entirely, so the 0.11.x line breaks Windows installs.
96
+ // • playwright-core 1.60.0 (exact) → playwright-core and the camoufox
97
+ // binary are one matched Juggler-protocol pair. 1.61 added a
98
+ // viewport.isMobile field the FF135 build REJECTS ("property
99
+ // viewport.isMobile not described in this scheme"), failing every
100
+ // launch below. 1.60.0 is the newest playwright the FF135 Juggler
101
+ // accepts.
102
+ // • impit 0.13.0 (exact, a direct dep though only camoufox-js uses it)
103
+ // → impit 0.13.1/0.14.0 shipped a `preinstall: npx only-allow pnpm`
104
+ // guard (an upstream mistake, fixed in 0.14.1+) that hard-fails
105
+ // `npm install -g`. Within camoufox-js's ^0.13.0 range 0.13.0 is the
106
+ // only guard-free version; a direct-dep pin is the ONLY way to force
107
+ // it for consumers (npm `overrides` don't propagate to installers of
108
+ // a published package).
109
+ //
110
+ // Removal: upgrade all three together when camoufox ships stable
111
+ // cross-platform (incl. Windows) FF150 binaries — bumping camoufox-js to
112
+ // ≥0.11.1 also drops the impit pin (0.11.1 requires the fixed impit
113
+ // ^0.14.1) and needs playwright re-pinned to the new build's Juggler.
114
+ // Always re-verify a real headless run on Linux/macOS/Windows after.
115
+ let CamoufoxModule: any;
116
+ try {
117
+ CamoufoxModule = await import('camoufox-js');
118
+ } catch (e: unknown) {
119
+ throw new Error(`[Worker] camoufox-js not found in node_modules. Please run 'npm install'. Original error: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
120
+ }
121
+
122
+ const { Camoufox } = CamoufoxModule;
123
+
124
+ // Launch browser without user_data_dir so Playwright creates an isolated
125
+ // temp profile per instance. This avoids persistent-context semantics
126
+ // (where context.browser() returns null) and the profile-lock contention
127
+ // that came with sharing a single user_data_dir path.
128
+ // Camoufox provides built-in fingerprint spoofing via humanize, os, locale,
129
+ // screen, and geoip options. The addons[] option is for custom Firefox
130
+ // extension directories only (not named strings like 'stealth').
131
+ // Camoufox's built-in fingerprinting handles UA, timezone, geolocation, etc.
132
+ // coherently. Manual overrides for those via launch options are silently
133
+ // ignored by Playwright; set them at context level if needed, or rely on
134
+ // camoufox defaults for a consistent, coordinated fingerprint.
135
+ //
136
+ // 90s launch timeout: CI runners (2 vCPU) need up to 60s to start Firefox
137
+ // when it has both CPUs. The old 45s limit was too tight and caused silent
138
+ // init failures under resource pressure.
139
+ const launchTimeoutMs = 90000;
140
+ const launchOnce = async (headless: boolean | 'virtual') => {
141
+ const launchPromise = Camoufox({
142
+ headless,
143
+ humanize: true,
144
+ locale: 'en-US',
145
+ screen: {
146
+ width: 1920,
147
+ height: 1080,
148
+ colorDepth: 24,
149
+ pixelRatio: 1,
150
+ },
151
+ });
152
+ // Guard with a hard timeout. We catch the launchPromise rejection so it
153
+ // doesn't surface as an UnhandledPromiseRejection if it rejects after the
154
+ // timeout races ahead.
155
+ launchPromise.catch((err: Error) => logToDebugFile('DEBUG', `[ThreadWorker] Background browser launch rejection: ${err.message}`));
156
+ let launchTimeoutId: NodeJS.Timeout | undefined;
157
+ try {
158
+ return await Promise.race([
159
+ launchPromise,
160
+ new Promise<never>((_, reject) => {
161
+ launchTimeoutId = setTimeout(() => reject(new Error(`Browser launch timed out after ${launchTimeoutMs}ms`)), launchTimeoutMs);
162
+ })
163
+ ]);
164
+ } finally {
165
+ if (launchTimeoutId) clearTimeout(launchTimeoutId);
166
+ }
167
+ };
168
+
169
+ // Prefer true headless (no visible window — see resolveHeadlessMode).
170
+ // Windows safety net: on a minority of Windows machines camoufox crashes
171
+ // immediately in headless mode (exit 0x80000003 / STATUS_BREAKPOINT,
172
+ // camoufox issue #614). If the headless launch fails for a non-timeout
173
+ // reason there, fall back ONCE to a headed (visible) window so research
174
+ // still works on those machines. Everywhere else the error propagates.
175
+ const primaryHeadless = resolveHeadlessMode();
176
+ let launchedBrowser: Awaited<ReturnType<typeof launchOnce>>;
177
+ try {
178
+ launchedBrowser = await launchOnce(primaryHeadless);
179
+ } catch (launchErr: unknown) {
180
+ const lmsg = launchErr instanceof Error ? launchErr.message : String(launchErr);
181
+ const wasTimeout = lmsg.includes('timed out');
182
+ if (platform() === 'win32' && primaryHeadless === true && !wasTimeout) {
183
+ logToDebugFile('WARN', `[Worker-${workerId}] Headless browser launch failed on Windows (${lmsg}); falling back to a visible window so scraping still works (camoufox issue #614).`);
184
+ launchedBrowser = await launchOnce(false);
185
+ } else {
186
+ throw launchErr;
187
+ }
188
+ }
189
+
190
+ browser = launchedBrowser;
191
+ // newContext() can hang if the browser process becomes unresponsive immediately
192
+ // after launch (e.g. OOM, GPU crash). Guard with a hard timeout.
193
+ const contextPromise = browser.newContext();
194
+ contextPromise.catch((err: Error) => logToDebugFile('DEBUG', `[ThreadWorker] Background browser context rejection: ${err.message}`));
195
+ let contextTimeoutId: NodeJS.Timeout | undefined;
196
+ context = await Promise.race([
197
+ contextPromise,
198
+ new Promise<never>((_, reject) => {
199
+ contextTimeoutId = setTimeout(() => reject(new Error('Browser context creation timed out after 30000ms')), 30000);
200
+ })
201
+ ]);
202
+ if (contextTimeoutId) clearTimeout(contextTimeoutId);
203
+
204
+ // Setup mocking for CI if enabled
205
+ await setupMocking(context);
206
+
207
+ logToDebugFile('INFO', `[Worker-${workerId}] Browser initialized.`);
208
+ }
209
+ } catch (e: unknown) {
210
+ // Close any partially-launched browser to avoid orphaning the process.
211
+ // Promise.resolve() wraps the call safely: in headless:'virtual' mode,
212
+ // camoufox-js browser.close() is synchronous (returns void), not a Promise.
213
+ if (browser && typeof browser.close === 'function') {
214
+ Promise.resolve(browser.close()).catch((err: Error) => logToDebugFile('DEBUG', `[Worker-${workerId}] Swallowed browser close error during failed init: ${err.message}`));
215
+ }
216
+ browser = null;
217
+ context = null;
218
+ const msg = e instanceof Error ? e.message : String(e);
219
+
220
+ if (msg.includes('Camoufox is not installed') || msg.includes('Version information not found')) {
221
+ throw new Error(`[Worker] Browser binaries not found. Please run 'npx camoufox-js fetch' to install them.`, { cause: e });
222
+ }
223
+
224
+ // camoufox headless: "virtual" spawns Xvfb on Linux when no DISPLAY is set.
225
+ // CannotFindXvfb is thrown when `which Xvfb` fails (xvfb not installed).
226
+ const errName = e instanceof Error ? (e as any).constructor?.name ?? '' : '';
227
+ if (errName === 'CannotFindXvfb' || errName === 'CannotExecuteXvfb' || msg.includes('Xvfb') || msg.includes('virtual display')) {
228
+ throw new Error(
229
+ '[Worker] No display server found on Linux. Install Xvfb for headless use (TTY/Wayland): sudo apt install xvfb',
230
+ { cause: e }
231
+ );
232
+ }
233
+
234
+ throw e;
235
+ } finally {
236
+ initPromise = null;
237
+ }
238
+ })();
239
+
240
+ return initPromise;
241
+ }
242
+
243
+ /**
244
+ * Get the current browser context
245
+ */
246
+ export function getContext(): any {
247
+ return context;
248
+ }
249
+
250
+ /**
251
+ * Reset browser and context (used after crashes)
252
+ */
253
+ export function resetBrowser(): void {
254
+ if (context) Promise.resolve(context.close()).catch((err: Error) => logToDebugFile('DEBUG', `[Worker-${workerId}] Swallowed context close error during reset: ${err.message}`));
255
+ if (browser) Promise.resolve(browser.close()).catch((err: Error) => logToDebugFile('DEBUG', `[Worker-${workerId}] Swallowed browser close error during reset: ${err.message}`));
256
+ context = null;
257
+ browser = null;
258
+ }
259
+
260
+ /**
261
+ * Clean up browser resources with a timeout to prevent hanging
262
+ */
263
+ export async function cleanupBrowser(): Promise<void> {
264
+ const timeoutMs = 2000;
265
+
266
+ const cleanup = async () => {
267
+ if (context) await Promise.resolve(context.close()).catch((err: Error) => logToDebugFile('DEBUG', `[Worker-${workerId}] Swallowed context close error during cleanup: ${err.message}`));
268
+ if (browser) await Promise.resolve(browser.close()).catch((err: Error) => logToDebugFile('DEBUG', `[Worker-${workerId}] Swallowed browser close error during cleanup: ${err.message}`));
269
+ };
270
+
271
+ try {
272
+ await Promise.race([
273
+ cleanup(),
274
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Browser cleanup timed out')), timeoutMs))
275
+ ]);
276
+ } catch {
277
+ // Ignore timeout — we just want to ensure cleanup doesn't hang indefinitely
278
+ } finally {
279
+ context = null;
280
+ browser = null;
281
+ }
282
+ }