@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,549 @@
1
+ /**
2
+ * Research Orchestration Service
3
+ *
4
+ * Handles the core orchestration logic for multi-round research.
5
+ * Responsible for:
6
+ * - Multi-round research coordination
7
+ * - Agent spawning and concurrent execution management
8
+ * - Search result distribution to researchers
9
+ * - Health check integration
10
+ * - Knowledge store integration for link descriptions
11
+ */
12
+
13
+ import type { Model } from '@earendil-works/pi-ai';
14
+ import { resolveResearchModel } from '../core/llm/research-model-resolver.ts';
15
+ import type { QueryResultWithError } from '../web-research/types.ts';
16
+ import type { RunResearchersOptions } from './orchestration-types.ts';
17
+ import { RESEARCHER_LAUNCH_DELAY_MS } from '../constants.ts';
18
+ import { search } from '../web-research/search.ts';
19
+ import { parseCitations } from '../utils/text-utils.ts';
20
+ import { logger, resetLogger } from '../logger.ts';
21
+ import { healthRegistry } from '../healthcheck/index.ts';
22
+ import { clearSessionCircuitBreaker } from '../infrastructure/browser/browser-error-utils.ts';
23
+ import { getService, ServiceLifecycle, tryGetService, tryGetServiceContainerFromCtx } from '../core/service-registry.ts';
24
+ import { ServiceNames } from '../core/service-interfaces.ts';
25
+ import type {
26
+ IWriterQueue,
27
+ IKnowledgeStoreService,
28
+ IHealthRegistryService,
29
+ IResearchOrchestration,
30
+ StoreUrlEntry,
31
+ ResearchOptions,
32
+ ResearchPlan,
33
+ IPlanningService,
34
+ IResearchSynthesisService,
35
+ } from '../core/service-interfaces.ts';
36
+ import type { Config } from '../config.ts';
37
+ import { getConfig, DEFAULTS } from '../config.ts';
38
+ import { getCachedScrapedContent, normalizeUrl, cleanupSharedLinks } from '../utils/shared-links.ts';
39
+ import { runResearcher } from './researcher-executor.ts';
40
+ import { recordResearcherFailure, shouldStopResearch, getResearchStopMessage } from './session-state.ts';
41
+ import type { ResearchSessionService } from './research-session-service.ts';
42
+ import { QuickResearchOrchestrator } from './quick-research-orchestrator.ts';
43
+ import { DeepResearchOrchestrator } from './deep-research-orchestrator.ts';
44
+ import { metrics } from '../utils/metrics.ts';
45
+
46
+ /**
47
+ * Research Orchestration Service
48
+ *
49
+ * Handles core orchestration logic for multi-round research.
50
+ */
51
+ export class ResearchOrchestrationService implements IResearchOrchestration {
52
+ readonly name = ServiceNames.RESEARCH_ORCHESTRATION;
53
+ lifecycle = ServiceLifecycle.UNINITIALIZED;
54
+
55
+ async initialize(): Promise<void> {
56
+ this.lifecycle = ServiceLifecycle.INITIALIZED;
57
+ }
58
+ async dispose(): Promise<void> {
59
+ this.lifecycle = ServiceLifecycle.DISPOSED;
60
+ }
61
+
62
+ /**
63
+ * Resolve the model for research based on options and config.
64
+ */
65
+ async resolveResearchModel(options: ResearchOptions): Promise<Model<any>> {
66
+ const { ctx, model, config } = options;
67
+ // If a fully-resolved model was already provided (carries both provider and
68
+ // id), use it verbatim. Re-resolving from its id alone can land on a
69
+ // different provider's entry that happens to share the same id — e.g. the
70
+ // host's authed `glm-coding/glm-4.7` vs pi's built-in unauthed `zai/glm-4.7`
71
+ // — silently dropping the provider that actually has an API key.
72
+ if (model && (model as any).provider && (model as any).id) {
73
+ return model as Model<any>;
74
+ }
75
+ return resolveResearchModel({
76
+ modelRegistry: ctx.modelRegistry,
77
+ config: config || getConfig(ctx.cwd),
78
+ modelId: (model as any)?.id,
79
+ hostModel: ctx.model as Model<any>,
80
+ cwd: ctx.cwd,
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Run a research task (Quick or Deep)
86
+ */
87
+ async runResearch(options: ResearchOptions, signal?: AbortSignal): Promise<string> {
88
+ const { ctx, query, depth = 0, observer, onUpdate, sessionId, researchId, config, excludeTools } = options;
89
+
90
+ const researchConfig = config || getConfig(ctx.cwd);
91
+
92
+ // Fold the config-level per-tool disable list (PI_RESEARCH_DISABLED_TOOLS) into the
93
+ // per-run excludeTools stream. This is the single chokepoint that constructs both the
94
+ // Quick and Deep orchestrators, so the merged list reaches BOTH each researcher's tool
95
+ // allowlist AND the coordinator/evaluator "DISABLED TOOLS" prompt section for free.
96
+ // Disable-only and additive: any per-call excludeTools (e.g. CLI --exclude-tools) is preserved.
97
+ const configDisabledTools = researchConfig.DISABLED_TOOLS
98
+ ? researchConfig.DISABLED_TOOLS.split(',').map((s) => s.trim()).filter(Boolean)
99
+ : [];
100
+ const mergedExcludeTools = configDisabledTools.length > 0
101
+ ? [...new Set([...(excludeTools ?? []), ...configDisabledTools])]
102
+ : excludeTools;
103
+
104
+ // Resolve model using centralized priority logic
105
+ const selectedModel = await this.resolveResearchModel(options);
106
+ logger.info(`[ResearchOrchestrationService] Using model: ${selectedModel.provider}/${selectedModel.id}`);
107
+
108
+ const researchStart = Date.now();
109
+
110
+ let result: string;
111
+ try {
112
+ if (depth === 0) {
113
+ const orchestrator = new QuickResearchOrchestrator({
114
+ ctx,
115
+ model: selectedModel,
116
+ query,
117
+ sessionId,
118
+ researchId,
119
+ observer,
120
+ onUpdate,
121
+ config: researchConfig,
122
+ excludeTools: mergedExcludeTools,
123
+ initialLinks: options.initialLinks,
124
+ });
125
+ result = await orchestrator.run(signal);
126
+ } else {
127
+ const orchestrator = new DeepResearchOrchestrator({
128
+ ctx,
129
+ model: selectedModel,
130
+ query,
131
+ complexity: depth as 1 | 2 | 3,
132
+ sessionId,
133
+ researchId,
134
+ observer,
135
+ onUpdate,
136
+ config: researchConfig,
137
+ excludeTools: mergedExcludeTools,
138
+ orchestrationService: this,
139
+ initialLinks: options.initialLinks,
140
+ });
141
+ result = await orchestrator.run(signal);
142
+ }
143
+ const researchDuration = Date.now() - researchStart;
144
+ metrics.observe('research_manager_latency_ms', researchDuration, { depth: String(depth), status: 'success', source: 'extension' });
145
+ metrics.increment('research_manager_requests_total', 1, { depth: String(depth), status: 'success', source: 'extension' });
146
+ return result;
147
+ } catch (error) {
148
+ const researchDuration = Date.now() - researchStart;
149
+ metrics.observe('research_manager_latency_ms', researchDuration, { depth: String(depth), status: 'error', source: 'extension' });
150
+ metrics.increment('research_manager_requests_total', 1, { depth: String(depth), status: 'error', source: 'extension' });
151
+ throw error;
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Cleanup and reset services for the current research run
157
+ * @param opts.skipStoreMaintenance - skip the post-run FTS rebuild + optimize
158
+ * (used on user abort: both are non-signal-aware optimization passes; row
159
+ * durability lives in the LanceDB commits already made, and the next run's
160
+ * cleanup performs the same rebuild).
161
+ */
162
+ async cleanupResearchServices(sessionId?: string, researchId?: string, ctx?: any, config?: Config, opts?: { skipStoreMaintenance?: boolean }): Promise<void> {
163
+ const targetId = researchId || sessionId;
164
+ const container = tryGetServiceContainerFromCtx(ctx);
165
+ // Use the run's RESOLVED config when the caller has it — an SDK caller's
166
+ // options.config override (e.g. KNOWLEDGE_STORE_MODE:'none' to stay off the native
167
+ // vector stack) must govern this post-run FTS/optimize gate too. Falling back to a
168
+ // fresh getConfig(cwd) would ignore that override and either load the native stack on
169
+ // a platform that lacks it, or skip the rebuild a store-enabled run legitimately needs.
170
+ const resolvedConfig = config ?? getConfig(ctx?.cwd);
171
+
172
+ // Cleanup session service
173
+ try {
174
+ const sessionService = await getService<ResearchSessionService>(ServiceNames.RESEARCH_SESSION_SERVICE, ctx, container);
175
+ if (sessionService && targetId) {
176
+ await sessionService.cleanup(targetId);
177
+ }
178
+
179
+ // Perform FTS index rebuild here after synthesis/completion — but only when
180
+ // the knowledge store is enabled. With KNOWLEDGE_STORE_MODE='none' (or on a
181
+ // platform missing the native @lancedb binding, e.g. Intel macOS) merely
182
+ // resolving the service loads the native vector stack and throws; gating it
183
+ // lets the rest of cleanup proceed instead of jumping to the catch below.
184
+ if (opts?.skipStoreMaintenance) {
185
+ logger.debug('[ResearchOrchestrationService] Skipping FTS rebuild/optimize (run aborted); next run\'s cleanup will rebuild');
186
+ } else if (resolvedConfig.KNOWLEDGE_STORE_MODE !== 'none') {
187
+ const ksService = await getService<IKnowledgeStoreService>(ServiceNames.KNOWLEDGE_STORE, ctx, container);
188
+ if (ksService && ksService.isReady()) {
189
+ const store = await ksService.getStore();
190
+ if (store) {
191
+ logger.info('[ResearchOrchestrationService] Rebuilding FTS index after research run');
192
+ const rebuilt = await store.rebuildFtsIndex();
193
+ // Only compact/prune when the FTS index was actually rebuilt — i.e. the
194
+ // run changed the data. A no-op run skips both, so steady-state loops no
195
+ // longer accumulate orphaned index/version files (the disk-bloat fix).
196
+ if (rebuilt) {
197
+ await store.optimize();
198
+ }
199
+ }
200
+ }
201
+ }
202
+ } catch (_err) {
203
+ logger.debug('[ResearchOrchestrationService] Service cleanup failed:', _err);
204
+ }
205
+
206
+ // NOTE: synthesis reports are intentionally NOT cleared here. This cleanup runs in
207
+ // the orchestrator's own finally, i.e. INSIDE the run() call — clearing here erased
208
+ // the per-researcher reports before the SDK's runDeepResearch() promise even resolved,
209
+ // making getResearchReports()/runResearchDetailed().reports always return an empty Map.
210
+ // Retention is bounded instead by ResearchSynthesisService's MAX_SESSIONS LRU cap, its
211
+ // designed backstop; reports for a completed run linger only until evicted or the SDK
212
+ // reads them. See getResearchReports() in sdk.ts.
213
+
214
+ // Clear planning state
215
+ const planningService = tryGetService<IPlanningService>(ServiceNames.PLANNING, container);
216
+ if (planningService && targetId) {
217
+ planningService.clearPlanningState(targetId);
218
+ logger.debug(`[ResearchOrchestrationService] Cleared planning state for ${targetId}`);
219
+ }
220
+
221
+ if (targetId) {
222
+ cleanupSharedLinks(targetId);
223
+ resetLogger(targetId);
224
+ clearSessionCircuitBreaker(targetId);
225
+ }
226
+
227
+ logger.debug(`[ResearchOrchestrationService] Cleaned up research services for ${targetId}`);
228
+ }
229
+
230
+ /**
231
+ * Distribute search results to researchers based on query matching
232
+ * @param plan - Research plan with researchers and queries
233
+ * @param results - Search results from queries
234
+ * @param _ctx - Optional context for container isolation
235
+ * @returns Map of researcher ID -> array of URLs
236
+ */
237
+ async distributeSearchResults(plan: ResearchPlan, results: QueryResultWithError[], _ctx?: any): Promise<Map<string, string[]>> {
238
+ const startTime = Date.now();
239
+ const queryToResults = new Map(results.map(r => [r.query, r.results || []]));
240
+ const linkMap = new Map<string, string[]>();
241
+
242
+ for (const researcher of plan.researchers || []) {
243
+ const researcherUrls = new Set<string>();
244
+ for (const query of researcher.queries || []) {
245
+ const queryResults = queryToResults.get(query) || [];
246
+ for (const res of queryResults) {
247
+ researcherUrls.add(res.url);
248
+ }
249
+ }
250
+ linkMap.set(String(researcher.id), Array.from(researcherUrls));
251
+ }
252
+
253
+ logger.debug(`[ResearchOrchestrationService] Distributed ${results.length} results in ${Date.now() - startTime}ms`);
254
+ return linkMap;
255
+ }
256
+
257
+ /**
258
+ * Run researchers concurrently with launch delay
259
+ * @param options - Run options
260
+ * @param researcherLinks - Optional map of researcher ID -> search results
261
+ * @param storeLinks - Optional map of researcher ID -> store results
262
+ * @param _ctx - Optional context for container isolation
263
+ */
264
+ async runResearchers(
265
+ options: RunResearchersOptions,
266
+ researcherLinks?: Map<string, string[]>,
267
+ storeLinks?: Map<string, StoreUrlEntry[]>,
268
+ _ctx?: any
269
+ ): Promise<void> {
270
+ const { plan, options: orchestratorOptions, currentRound, signal } = options;
271
+ const { sessionId, researchId, observer, ctx } = orchestratorOptions;
272
+ const container = tryGetServiceContainerFromCtx(ctx);
273
+
274
+ // Obtain the planning service once for all researchers in this round
275
+ let planningService: IPlanningService;
276
+ try {
277
+ planningService = await getService<IPlanningService>(ServiceNames.PLANNING, ctx, container);
278
+ } catch (err) {
279
+ const errMsg = err instanceof Error ? err.message : String(err);
280
+ // If the service container is already disposing (SIGTERM during active research),
281
+ // return gracefully rather than throwing — the research run is ending anyway.
282
+ if (errMsg.includes('during container disposal') || options.signal?.aborted) {
283
+ logger.info('[ResearchOrchestrationService] Service container disposing — skipping researchers gracefully');
284
+ return;
285
+ }
286
+ logger.error('[ResearchOrchestrationService] Failed to get planning service:', err);
287
+ throw new Error('Planning service not available. Research cannot continue.', { cause: err });
288
+ }
289
+
290
+ const researchers = plan.researchers || [];
291
+ const active = new Set<Promise<void>>();
292
+ // Honour MAX_CONCURRENT_RESEARCHERS — prevents resource spikes when a plan has many researchers.
293
+ const maxConcurrent: number = (orchestratorOptions.config as Config)?.MAX_CONCURRENT_RESEARCHERS ?? DEFAULTS.MAX_CONCURRENT_RESEARCHERS;
294
+
295
+ for (const configItem of researchers) {
296
+ if (signal?.aborted) break;
297
+
298
+ // Stop BEFORE launching if the failure threshold is already crossed. The
299
+ // post-launch check below only fires after a researcher is in-flight, so
300
+ // without this an early check, up to maxConcurrent-1 extra researchers could
301
+ // start past the stop threshold and waste a browser slot + an LLM call.
302
+ if (shouldStopResearch(sessionId, researchId)) {
303
+ const sessionService = await getService<ResearchSessionService>(ServiceNames.RESEARCH_SESSION_SERVICE, ctx, container);
304
+ await sessionService.abortAllSessions(researchId);
305
+ throw new Error(getResearchStopMessage(sessionId, researchId));
306
+ }
307
+
308
+ // Enforce the concurrency cap before launching the next researcher.
309
+ // Wait for one slot to free up when we're at capacity, or abort immediately if signalled.
310
+ if (active.size >= maxConcurrent) {
311
+ if (signal?.aborted) break;
312
+ // Hoist onAbort so the listener is removed once the race settles normally (a researcher
313
+ // finished). The previous {once:true} listener was only cleaned up if abort actually
314
+ // fired, so every capacity-wait that resolved by completion leaked a listener on `signal`
315
+ // — accumulating across a long run toward MaxListenersExceededWarning.
316
+ let onAbort: (() => void) | undefined;
317
+ const abortPromise = signal ? new Promise<void>((_, reject) => {
318
+ onAbort = () => reject(new Error('Aborted'));
319
+ if (signal.aborted) onAbort();
320
+ else signal.addEventListener('abort', onAbort, { once: true });
321
+ }) : null;
322
+ try {
323
+ await Promise.race([...active, ...(abortPromise ? [abortPromise] : [])])
324
+ .catch(err => { if (err.message !== 'Aborted') throw err; });
325
+ } finally {
326
+ if (signal && onAbort) signal.removeEventListener('abort', onAbort);
327
+ }
328
+ if (signal?.aborted) break;
329
+ }
330
+
331
+ const promise = (async () => {
332
+ const id = String(configItem.id);
333
+ try {
334
+ const initialLinks = researcherLinks?.get(id) || [];
335
+ const historicalUrls = storeLinks?.get(id) || [];
336
+
337
+ await runResearcher({
338
+ ...orchestratorOptions,
339
+ // Correct field mappings — orchestratorOptions.config is the app Config;
340
+ // the per-researcher plan item goes into 'config' (overriding the spread),
341
+ // and the app Config moves to 'researchConfig'.
342
+ config: configItem,
343
+ researchConfig: orchestratorOptions.config ?? getConfig(ctx.cwd),
344
+ round: currentRound,
345
+ planningService,
346
+ initialLinks,
347
+ historicalUrls,
348
+ signal,
349
+ excludeTools: orchestratorOptions.excludeTools,
350
+ });
351
+ } catch (err) {
352
+ const errMsg = err instanceof Error ? err.message : String(err);
353
+ // A user cancellation (quit mid-run) surfaces here as an 'Aborted' throw.
354
+ // That is a clean stop, not a researcher failure: don't log it at ERROR,
355
+ // don't count it toward the fast-stop threshold, and don't paint the TUI
356
+ // researcher slice red. Mirrors the guard in researcher-executor.ts.
357
+ if (signal?.aborted || errMsg === 'Aborted') {
358
+ logger.debug(`[ResearchOrchestrationService] Researcher ${id} cancelled (aborted).`);
359
+ } else {
360
+ logger.error(`[ResearchOrchestrationService] Researcher ${id} failed: ${errMsg}`);
361
+ // Record failure for stopping logic
362
+ recordResearcherFailure(sessionId, researchId, id);
363
+ // Notify observer
364
+ observer?.onResearcherFailure?.(id, errMsg);
365
+ }
366
+ }
367
+ })();
368
+
369
+ // Prune the promise from the active set when it settles so Promise.race works
370
+ // correctly and active.size stays accurate for the concurrency cap.
371
+ promise.finally(() => active.delete(promise));
372
+ active.add(promise);
373
+
374
+ // Throttled launch to prevent resource spikes (stagger by RESEARCHER_LAUNCH_DELAY_MS,
375
+ // skip after last). Abortable: an inert setTimeout would keep the loop alive for up to
376
+ // RESEARCHER_LAUNCH_DELAY_MS per pending researcher after a cancel, delaying shutdown.
377
+ if (RESEARCHER_LAUNCH_DELAY_MS > 0 && researchers.indexOf(configItem) < researchers.length - 1) {
378
+ if (signal?.aborted) break;
379
+ await new Promise<void>((resolve) => {
380
+ const timer = setTimeout(() => {
381
+ if (signal) signal.removeEventListener('abort', onAbort);
382
+ resolve();
383
+ }, RESEARCHER_LAUNCH_DELAY_MS);
384
+ const onAbort = () => { clearTimeout(timer); resolve(); };
385
+ if (signal) {
386
+ if (signal.aborted) onAbort();
387
+ else signal.addEventListener('abort', onAbort, { once: true });
388
+ }
389
+ });
390
+ if (signal?.aborted) break;
391
+ }
392
+
393
+ if (shouldStopResearch(sessionId, researchId)) {
394
+ const sessionService = await getService<ResearchSessionService>(ServiceNames.RESEARCH_SESSION_SERVICE, ctx, container);
395
+ // Abort sessions specifically for this researchId, not the whole piSessionId
396
+ await sessionService.abortAllSessions(researchId);
397
+
398
+ const stopMessage = getResearchStopMessage(sessionId, researchId);
399
+ throw new Error(stopMessage);
400
+ }
401
+ }
402
+
403
+ // Wait for remaining researchers
404
+ await Promise.all(active);
405
+ }
406
+
407
+ /**
408
+ * Run a search burst for the given queries
409
+ * @param queries - Array of search queries
410
+ * @param config - Research configuration
411
+ * @param signal - Optional abort signal
412
+ * @param onProgress - Optional progress callback
413
+ * @param ctx - Optional context for container isolation
414
+ * @returns Search results
415
+ */
416
+ async runSearchBurst(
417
+ queries: string[],
418
+ config: Config,
419
+ signal?: AbortSignal,
420
+ onProgress?: (links: number) => void,
421
+ ctx?: any
422
+ ): Promise<QueryResultWithError[]> {
423
+ const container = tryGetServiceContainerFromCtx(ctx);
424
+ const results = await search(queries, config, signal, onProgress, container);
425
+ const totalResults = results.reduce((sum, r) => sum + (r.results?.length || 0), 0);
426
+ logger.info(`[ResearchOrchestrationService] Search burst completed. Total results: ${totalResults}`);
427
+ return results;
428
+ }
429
+
430
+ /**
431
+ * Store link descriptions to knowledge store for a specific round
432
+ * @param sessionId - Session identifier
433
+ * @param round - Round number
434
+ * @param researchId - Research ID
435
+ * @param config - Research configuration
436
+ * @param ctx - Optional extension context for container isolation
437
+ */
438
+ async storeLinkDescriptions(_sessionId: string, round: number, researchId: string, config: Config, ctx?: any): Promise<void> {
439
+ // Knowledge store disabled — never load the native vector stack. Mirrors the
440
+ // gates in the quick orchestrator and scrape.ts; without this, resolving the
441
+ // service throws on platforms lacking the native @lancedb binding (e.g. Intel
442
+ // macOS) and needlessly opens LanceDB even when the user set MODE='none'.
443
+ if (config.KNOWLEDGE_STORE_MODE === 'none') return;
444
+
445
+ const container = tryGetServiceContainerFromCtx(ctx);
446
+
447
+ try {
448
+ const ksService = await getService<IKnowledgeStoreService>(ServiceNames.KNOWLEDGE_STORE, ctx, container);
449
+ if (!ksService.isReady()) {
450
+ logger.debug('[ResearchOrchestrationService] Knowledge store not ready, skipping link descriptions');
451
+ return;
452
+ }
453
+
454
+ const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
455
+ const writer = await getService<IWriterQueue>(ServiceNames.WRITER_QUEUE, ctx, container);
456
+
457
+ const roundPrefix = `${round}.`;
458
+ let enqueued = 0;
459
+ let researcherCount = 0;
460
+
461
+ const allReports = synthesisService.getAllReports(researchId);
462
+ if (allReports.size === 0) {
463
+ logger.warn(`[ResearchOrchestrationService] No reports found in synthesis service for researchId ${researchId} round ${round}`);
464
+ }
465
+
466
+ for (const [key, report] of allReports.entries()) {
467
+ if (!key.startsWith(roundPrefix)) continue;
468
+ researcherCount++;
469
+
470
+ const links = parseCitations(report);
471
+ if (links.length === 0) {
472
+ logger.warn(`[ResearchOrchestrationService] Researcher ${key} produced no parseable CITED LINKS - no descriptions stored`);
473
+ continue;
474
+ }
475
+
476
+ logger.debug(`[ResearchOrchestrationService] Storing ${links.length} citations for researcher ${key}`);
477
+
478
+ for (const link of links) {
479
+ if (link.url) {
480
+ const cachedContent = getCachedScrapedContent(researchId, link.url) ?? undefined;
481
+ const markdown = link.description || `(source: ${link.url})`;
482
+ writer.enqueue({
483
+ url: normalizeUrl(link.url),
484
+ markdown: markdown,
485
+ content: cachedContent,
486
+ metadata: {
487
+ researchId,
488
+ round,
489
+ researcherId: key,
490
+ description: link.description || '',
491
+ sourceOrigin: link.url,
492
+ source: link.source || 'unknown',
493
+ }
494
+ });
495
+ enqueued++;
496
+ }
497
+ }
498
+ }
499
+
500
+ if (enqueued > 0) {
501
+ logger.info(`[ResearchOrchestrationService] Enqueued ${enqueued} citations from ${researcherCount} researchers for round ${round}`);
502
+ await writer.drain();
503
+ } else if (researcherCount > 0) {
504
+ logger.warn(`[ResearchOrchestrationService] No valid citations found among ${researcherCount} researchers in round ${round}`);
505
+ }
506
+ } catch (err) {
507
+ logger.warn('[ResearchOrchestrationService] Failed to store link descriptions (non-fatal):', err);
508
+ }
509
+ }
510
+
511
+
512
+ /**
513
+ * Run the infrastructure health check and log its status. Purely ADVISORY:
514
+ * research never aborts on an unhealthy result. This is deliberate — the health
515
+ * check has historically produced false negatives (e.g. a single-endpoint DDG
516
+ * probe, or native deps unavailable on Intel macOS) and an abort-on-unhealthy
517
+ * would needlessly kill runs that would otherwise succeed on cached/partial
518
+ * data. The return type is `void` so a future edit can't quietly start
519
+ * "honoring" a boolean and reintroduce that false-negative abort.
520
+ * @param round - Current round number
521
+ * @param ctx - Optional extension context for container isolation
522
+ */
523
+ async checkHealth(round: number, _researchId?: string, ctx?: any): Promise<void> {
524
+ if (round <= 1) return;
525
+
526
+ try {
527
+ const container = tryGetServiceContainerFromCtx(ctx);
528
+ let registry: IHealthRegistryService;
529
+ try {
530
+ registry = await getService<IHealthRegistryService>(ServiceNames.HEALTH_REGISTRY, ctx, container);
531
+ } catch {
532
+ registry = healthRegistry;
533
+ }
534
+
535
+ const health = await registry.runAll();
536
+
537
+ if (health.status === 'healthy') {
538
+ logger.debug(`[ResearchOrchestrationService] Health status at Round ${round}: [OK] All systems operational`);
539
+ } else {
540
+ // Degraded or unhealthy — log for visibility but never stop the run.
541
+ const failed = health.components.filter(c => !c.healthy).map(c => c.component);
542
+ const label = health.status === 'degraded' ? 'Degraded' : 'Unhealthy';
543
+ logger.warn(`[ResearchOrchestrationService] Health status at Round ${round}: [WARN] ${label} (${failed.join(', ')}). Continuing.`);
544
+ }
545
+ } catch (err) {
546
+ logger.warn('[ResearchOrchestrationService] Failed to check health status:', err);
547
+ }
548
+ }
549
+ }