@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,463 @@
1
+ /**
2
+ * Quick Research Orchestrator
3
+ *
4
+ * Implements the single-agent research loop.
5
+ * Optimized for speed and efficiency for simple queries.
6
+ */
7
+
8
+ import {
9
+ type ExtensionContext,
10
+ type AgentSessionEvent,
11
+ type AgentToolResult,
12
+ } from '@earendil-works/pi-coding-agent';
13
+ import { type Model } from '@earendil-works/pi-ai';
14
+ import { injectCurrentDate } from '../core/llm/inject-date.ts';
15
+ import { loadPrompt } from '../core/llm/prompts.ts';
16
+ import { extractUsage } from '../types/llm.ts';
17
+ import { logger } from '../logger.ts';
18
+ import { getConfig, type Config } from '../config.ts';
19
+ import { createResearcherSession } from './researcher.ts';
20
+ import { ensureAssistantResponse, parseCitations } from '../utils/text-utils.ts';
21
+ import { getMaxScrapeBatches } from '../constants.ts';
22
+ import type { ResearchObserver } from '../core/interfaces/observer-interfaces.ts';
23
+ import { HeadlessObserver, type HeadlessObserverOptions } from './headless-observer.ts';
24
+ import { getService, tryGetServiceContainerFromCtx } from '../core/service-registry.ts';
25
+ import { ServiceNames, type IWriterQueue, type IKnowledgeStoreService, type IResearchSynthesisService, type IResearchOrchestration } from '../core/service-interfaces.ts';
26
+ import type { ResearchSessionService } from './research-session-service.ts';
27
+ import { normalizeUrl, registerScrapedLinks, getCachedScrapedContent } from '../utils/shared-links.ts';
28
+ import { runHealthCheck, isBusyPoolHealthFailure } from '../healthcheck/index.ts';
29
+ import { metrics } from '../utils/metrics.ts';
30
+ import { getSteeringMessages, consumeQueuedMessages, getActiveSteeringMessages } from './session-state.ts';
31
+ import type { ResearchMessage } from '../types/index.ts';
32
+ import type { SystemResearchState } from './deep-research-types.ts';
33
+
34
+ export interface QuickResearchOrchestratorOptions {
35
+ ctx: ExtensionContext;
36
+ model: Model<any>;
37
+ query: string;
38
+ sessionId: string;
39
+ researchId: string;
40
+ observer?: ResearchObserver | HeadlessObserverOptions;
41
+ onUpdate?: (update: AgentToolResult<any>) => void;
42
+ config?: Config;
43
+ excludeTools?: string[];
44
+ initialLinks?: string[];
45
+ }
46
+
47
+ export class QuickResearchOrchestrator {
48
+ private config: Config;
49
+ private observer: ResearchObserver | undefined;
50
+
51
+ constructor(private options: QuickResearchOrchestratorOptions) {
52
+ this.config = options.config || getConfig(options.ctx.cwd);
53
+
54
+ // Resolve observer: if options were provided instead of an instance, create the instance
55
+ if (options.observer && typeof (options.observer as any).onProgress === 'function' && !(options.observer instanceof HeadlessObserver)) {
56
+ this.observer = new HeadlessObserver(options.observer as HeadlessObserverOptions);
57
+ } else {
58
+ this.observer = options.observer as ResearchObserver | undefined;
59
+ }
60
+ }
61
+
62
+ async run(signal?: AbortSignal): Promise<string> {
63
+ const { query, model, ctx, researchId } = this.options;
64
+ const observer = this.observer;
65
+ const container = tryGetServiceContainerFromCtx(ctx);
66
+ const sessionStart = Date.now();
67
+ logger.log(`[QuickOrchestrator] Starting research: "${query}"`);
68
+ observer?.onStart?.(query, 0);
69
+
70
+ let subscription: (() => void) | undefined;
71
+ // Tracks whether a terminal callback (onComplete/onError) has fired, so the outer
72
+ // catch below can fire onError for an EARLY throw (healthcheck/session-setup, before
73
+ // the inner research try) without double-firing when the inner catch already handled it.
74
+ let terminalCallbackFired = false;
75
+
76
+ try {
77
+ // Pre-flight health check to ensure browser pool is operational.
78
+ // Consumers running sustained batch load (where the probe both costs up to a
79
+ // full queue-wait of latency AND competes for a pool slot with real work) can
80
+ // opt out with PI_RESEARCH_SKIP_HEALTHCHECK=1/true and rely on the per-task
81
+ // search/scrape timeouts as the safety net. Honoring this flag also keeps the
82
+ // SDK consistent with callers that already set it expecting a skip.
83
+ const skipHealthcheck = process.env['PI_RESEARCH_SKIP_HEALTHCHECK'] === '1' ||
84
+ process.env['PI_RESEARCH_SKIP_HEALTHCHECK'] === 'true';
85
+ const health = skipHealthcheck
86
+ ? { success: true as boolean, error: undefined as string | undefined, components: [] as Array<{ component?: string; healthy?: boolean; error?: string }> }
87
+ : await runHealthCheck({ ctx });
88
+ if (skipHealthcheck) {
89
+ logger.debug('[QuickOrchestrator] Pre-flight healthcheck skipped (PI_RESEARCH_SKIP_HEALTHCHECK); relying on per-task timeouts.');
90
+ }
91
+ if (!health.success) {
92
+ // Distinguish a DEAD/uninitialized pool (must abort) from one that is merely BUSY —
93
+ // under sustained concurrent load the BrowserRuntime probe queues behind in-flight
94
+ // scrapes and times out even though the pool is operational. This discrimination
95
+ // (the dominant 0-result cause under load) is a pure function so it is unit-tested.
96
+ if (isBusyPoolHealthFailure(health)) {
97
+ logger.warn(`[QuickOrchestrator] Browser pool busy — healthcheck probe queued out under load, but the pool is operational. Proceeding; per-task timeouts bound the work.`);
98
+ metrics.increment('research_sessions_total', 1, { mode: 'quick', complexity: '0', status: 'health_check_busy_proceed' });
99
+ } else {
100
+ const error = health.error || 'Unknown health check failure';
101
+ logger.error(`[QuickOrchestrator] Health check failed: ${error}`);
102
+ metrics.increment('research_sessions_total', 1, { mode: 'quick', complexity: '0', status: 'health_check_failed' });
103
+ throw new Error(`Research cannot start: ${error}`);
104
+ }
105
+ }
106
+
107
+ // Knowledge Store Context Injection — skipped entirely when the store is
108
+ // disabled (KNOWLEDGE_STORE_MODE='none'), so we never touch LanceDB. Without
109
+ // this gate, concurrent quick runs sharing one DB corrupt it ("Bad file
110
+ // descriptor"). Matches the gates in scrape.ts and the deep orchestrator.
111
+ let storeSection = '';
112
+ if (this.config.KNOWLEDGE_STORE_MODE !== 'none') {
113
+ try {
114
+ const ksService = await getService<IKnowledgeStoreService>(ServiceNames.KNOWLEDGE_STORE, ctx, container);
115
+ if (ksService.isReady()) {
116
+ const store = await ksService.getStore();
117
+ if (store && typeof store.findRelevantUrls === 'function') {
118
+ const historicalEntries = await store.findRelevantUrls(query, { limit: 5 });
119
+ if (historicalEntries.length > 0) {
120
+ storeSection = '\n## Historical Knowledge Store (Discovery)\n' +
121
+ 'The following URLs were found in your local knowledge store from previous research sessions. ' +
122
+ 'Scrape them to retrieve their full current content. Each summary describes what was previously found:\n' +
123
+ historicalEntries.map((e: { url: string; description: string }) =>
124
+ `- ${e.url}\n Previous summary: ${e.description}`
125
+ ).join('\n');
126
+ }
127
+ }
128
+ }
129
+ } catch (err) {
130
+ logger.warn('[QuickOrchestrator] Failed to fetch historical URLs (non-fatal):', err);
131
+ }
132
+ }
133
+
134
+ const researcherPromptTemplate = loadPrompt('researcher');
135
+ const maxScrapeBatches = getMaxScrapeBatches(this.config);
136
+ const maxScrapeBatchesDisplay = maxScrapeBatches > 99 ? 'unlimited' : maxScrapeBatches.toString();
137
+
138
+ const quickEvidenceSection =
139
+ '## Search\n' +
140
+ 'You have access to the `search` tool. You get EXACTLY ONE search call — make it count.\n' +
141
+ 'Submit **5–10 diverse, specific, and non-overlapping queries** covering the most important angles of the topic.\n' +
142
+ 'Each query must target a distinct piece of information. Avoid generic queries.\n' +
143
+ `For roughly one in ${this.config.YOUTUBE_QUERY_EVERY_N} of your queries, append the word 'youtube' (e.g. "<topic> explained youtube") — DuckDuckGo rarely surfaces YouTube otherwise, and YouTube links let you read video transcripts.\n` +
144
+ 'Your goal is to gather a focused, high-quality pool of initial links.\n\n' +
145
+ '## Scrape\n' +
146
+ `After searching, scrape the best sources using the \`scrape\` tool (up to ${maxScrapeBatchesDisplay} batches, up to 4 URLs each).\n` +
147
+ 'Prioritize primary sources and authoritative data.';
148
+
149
+ // Consume queued steering messages before starting
150
+ consumeQueuedMessages(this.options.sessionId);
151
+ const steeringMessages = getSteeringMessages(this.options.sessionId);
152
+ let steeringSection = '';
153
+ if (steeringMessages.length > 0) {
154
+ steeringSection = '\n\n### ADDITIONAL CONSIDERATIONS\n' +
155
+ steeringMessages.map(m => `- ${m.text}`).join('\n');
156
+ }
157
+
158
+ const evidenceLines: string[] = [];
159
+ if (this.options.initialLinks && this.options.initialLinks.length > 0) {
160
+ evidenceLines.push('## Initial Links\nInvestigate these provided URLs first:\n' + this.options.initialLinks.map(l => `- ${l}`).join('\n'));
161
+ }
162
+ evidenceLines.push(quickEvidenceSection);
163
+
164
+ const prompt = injectCurrentDate(researcherPromptTemplate, 'researcher')
165
+ // Function replacers: insert `$`-bearing dynamic content (scraped evidence,
166
+ // store descriptions, the user query) literally, not as a substitution pattern.
167
+ .replace('{{goal}}', () => query + steeringSection)
168
+ .replace('{{store_section}}', () => storeSection)
169
+ .replace('{{evidence_section}}', () => evidenceLines.join('\n\n'))
170
+ .replace('{{coordination_section}}', '')
171
+ .replace('{{extra_tool_guidelines}}', '- `search`: Perform broad web searches (Round 1 only).');
172
+
173
+ logger.debug(`[QuickOrchestrator] System Prompt:\n${prompt}`);
174
+
175
+ let lastSeenSearchCount = 0;
176
+ const { session, resolvedModel } = await createResearcherSession({
177
+ cwd: ctx.cwd,
178
+ ctxModel: model,
179
+ modelRegistry: ctx.modelRegistry,
180
+ systemPrompt: prompt,
181
+ extensionCtx: ctx,
182
+ excludeTools: this.options.excludeTools || ['grep'],
183
+ config: this.config,
184
+ getGlobalState: (): SystemResearchState => ({
185
+ version: 1,
186
+ researchId: this.options.researchId,
187
+ rootQuery: query,
188
+ complexity: 1,
189
+ currentRound: 1,
190
+ status: 'researching',
191
+ lastUpdated: Date.now(),
192
+ initialAgenda: [],
193
+ allScrapedLinks: [],
194
+ aspects: {},
195
+ }),
196
+ updateGlobalLinks: (links) => registerScrapedLinks(this.options.researchId, links),
197
+ onSearchProgress: (links) => {
198
+ lastSeenSearchCount = links;
199
+ observer?.onSearchProgress?.(links);
200
+ },
201
+ onUrlScrapeResult: (_url, success) => {
202
+ observer?.onToolResult?.('quick', success);
203
+ },
204
+ });
205
+
206
+ // Hand the freshly-created AgentSession to the cleanup machinery before
207
+ // any other work. If resolving/registering the session service throws
208
+ // (container disposed mid-run, race), the session is created but not yet
209
+ // owned by cleanupResearchServices and would leak unaborted. Abort it
210
+ // directly on any failure in this window, then rethrow. (Same
211
+ // acquire-before-teardown-wired class as the ghost-panel fix.)
212
+ let sessionService: ResearchSessionService;
213
+ try {
214
+ sessionService = await getService<ResearchSessionService>(ServiceNames.RESEARCH_SESSION_SERVICE, ctx, container);
215
+ sessionService.registerSession(this.options.researchId, 'quick', session, () => session.abort().catch((err) => logger.warn('[QuickOrchestrator] Session abort failed:', err)));
216
+ } catch (registrationError) {
217
+ await session.abort().catch(() => { /* best-effort: session is being discarded */ });
218
+ throw registrationError;
219
+ }
220
+
221
+ let lastSteeringCheck = Date.now();
222
+ subscription = session.subscribe((event: AgentSessionEvent) => {
223
+ // Check for new steering messages periodically (at most every 500ms to avoid spam)
224
+ const now = Date.now();
225
+ if (now - lastSteeringCheck > 500) {
226
+ lastSteeringCheck = now;
227
+ const newSteering = consumeQueuedMessages(this.options.sessionId);
228
+ for (const msg of newSteering) {
229
+ session.steer(msg.text).catch(e => logger.warn('[QuickOrchestrator] Failed to deliver steering:', e));
230
+ logger.debug(`[QuickOrchestrator] Delivered mid-flight steering message: ${msg.text}`);
231
+ }
232
+ }
233
+
234
+ if (event.type === 'message_end') {
235
+ const msg = event.message as unknown as ResearchMessage;
236
+ if (msg?.['role'] !== 'assistant') return;
237
+
238
+ const rawUsage = msg['usage'] as any;
239
+ if (rawUsage) {
240
+ const { tokens, cost } = extractUsage(resolvedModel, rawUsage);
241
+
242
+ if (tokens > 0 || cost > 0) {
243
+ metrics.increment('llm_tokens_total', tokens, { component: 'quick_researcher', complexity: '0' });
244
+ metrics.increment('llm_cost_total', cost, { component: 'quick_researcher', complexity: '0' });
245
+ observer?.onResearcherProgress?.('quick', undefined, tokens, cost);
246
+ observer?.onTokensConsumed?.(tokens, cost);
247
+ }
248
+ }
249
+ } else if (event.type === 'tool_execution_start') {
250
+ observer?.onResearcherProgress?.('quick', event.toolName);
251
+ if (event.toolName === 'search') {
252
+ metrics.increment('research_searches_total', 1, { mode: 'quick' });
253
+ observer?.onSearchStart?.(event.args.queries || []);
254
+ }
255
+ } else if (event.type === 'tool_execution_end') {
256
+ observer?.onResearcherProgress?.('quick', `done:${event.toolName}`);
257
+ // Per-tool flash for non-scrape tools (scrape uses per-URL callback)
258
+ if (event.toolName !== 'scrape') {
259
+ observer?.onToolResult?.('quick', !event.isError);
260
+ }
261
+ if (event.toolName === 'search') {
262
+ observer?.onSearchComplete?.(lastSeenSearchCount);
263
+ }
264
+ }
265
+ });
266
+
267
+ try {
268
+ let timeoutId: NodeJS.Timeout | undefined;
269
+ const timeoutPromise = new Promise<void>((_, reject) => {
270
+ timeoutId = setTimeout(() => {
271
+ const msg = `Quick research timed out after ${this.config.RESEARCHER_TIMEOUT_MS}ms`;
272
+ session.abort().catch((err) => {
273
+ logger.warn('[QuickOrchestrator] Failed to abort timed-out session:', err);
274
+ }).finally(() => reject(new Error(msg)));
275
+ }, this.config.RESEARCHER_TIMEOUT_MS);
276
+ });
277
+
278
+ // Keep onAbort in outer scope so the listener can be removed in finally
279
+ // whether the race resolves via session.prompt, timeout, or abort.
280
+ let abortCleanup: (() => void) | undefined;
281
+ try {
282
+ const promptPromise = session.prompt(query);
283
+ promptPromise.catch((err: Error) => logger.debug(`[QuickOrchestrator] Background session prompt rejection: ${err.message}`));
284
+ await Promise.race([
285
+ promptPromise,
286
+ timeoutPromise,
287
+ ...(signal ? [
288
+ new Promise<never>((_, reject) => {
289
+ const onAbort = () => {
290
+ session.abort().catch(err => logger.warn('[QuickOrchestrator] Failed to abort session on signal:', err));
291
+ reject(new Error('Aborted'));
292
+ };
293
+ if (signal.aborted) {
294
+ onAbort();
295
+ } else {
296
+ signal.addEventListener('abort', onAbort, { once: true });
297
+ abortCleanup = () => signal.removeEventListener('abort', onAbort);
298
+ }
299
+ })
300
+ ] : []),
301
+ ]);
302
+ } finally {
303
+ clearTimeout(timeoutId);
304
+ if (abortCleanup) abortCleanup();
305
+ }
306
+
307
+ // The session has settled — the subscribe() poller above stops consuming
308
+ // steering from here on. Flip steering OFF first (synchronously, before the
309
+ // final consume, so there is no window where a new message can be queued
310
+ // with the affirmative "will steer the next research round" toast and then
311
+ // stranded): a steer typed from now on takes the input handler's
312
+ // fall-through path to pi instead. Then consume anything queued in the last
313
+ // poller window (≤500ms) so it is marked active and surfaced via
314
+ // appendSteeringGuidance below rather than silently destroyed at teardown.
315
+ observer?.onSynthesisStart?.();
316
+ consumeQueuedMessages(this.options.sessionId);
317
+
318
+ let result = ensureAssistantResponse(session, 'Quick');
319
+
320
+ // Store report in synthesis service so citations can be verified/processed
321
+ const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
322
+ synthesisService.storeReport(this.options.researchId, 'quick', result);
323
+
324
+ // Ensure CITED LINKS section is accurate and consistent
325
+ result = synthesisService.ensureCitedLinks(this.options.researchId, result);
326
+
327
+ // Append steering guidance — only active (consumed by orchestrator) messages
328
+ const finalSteeringMessages = getActiveSteeringMessages(this.options.sessionId);
329
+ result = synthesisService.appendSteeringGuidance(result, finalSteeringMessages);
330
+
331
+ // NOTE: Model metadata (appendMetadata) is now applied at the very end
332
+ // of the result chain in research-tool-definition.ts (or sdk.ts for SDK
333
+ // users) so it always appears after metrics/summaries.
334
+
335
+ const sessionDuration = Date.now() - sessionStart;
336
+ metrics.observe('research_session_duration_ms', sessionDuration, { mode: 'quick', complexity: '0', status: 'success' });
337
+ logger.debug(`[QuickOrchestrator] Researcher Final Response:\n${result}`);
338
+
339
+ // Extract citations and store agent-synthesized descriptions for vector/semantic search
340
+ // Quick research is single-pass, so this is the final synthesis point
341
+ try {
342
+ if (this.config.KNOWLEDGE_STORE_MODE !== 'none') {
343
+ const ksService = await getService<IKnowledgeStoreService>(ServiceNames.KNOWLEDGE_STORE, ctx, container);
344
+ if (ksService.isReady()) {
345
+ const writer = await getService<IWriterQueue>(ServiceNames.WRITER_QUEUE, ctx, container);
346
+ const citations = parseCitations(result);
347
+ if (citations.length === 0) {
348
+ logger.warn('[QuickOrchestrator] Researcher produced no parseable CITED LINKS — no descriptions stored for this session');
349
+ }
350
+ let enqueued = 0;
351
+ for (const cit of citations) {
352
+ if (cit.url) {
353
+ const fullContent = getCachedScrapedContent(this.options.researchId, cit.url);
354
+ const markdown = cit.description || `(source: ${cit.url})`;
355
+ writer.enqueue({
356
+ url: normalizeUrl(cit.url),
357
+ markdown: markdown,
358
+ content: fullContent,
359
+ metadata: {
360
+ ingestionType: 'synthesis-description',
361
+ source: 'researcher',
362
+ synthesizedAt: new Date().toISOString(),
363
+ description: cit.description || '',
364
+ fullContentSnippet: fullContent?.substring(0, 5000)
365
+ }
366
+ });
367
+ enqueued++;
368
+ }
369
+ }
370
+ // Drain so concurrent or subsequent sessions see these entries immediately
371
+ // rather than relying solely on shutdownKnowledgeStore's drain.
372
+ if (enqueued > 0) await writer.drain();
373
+ }
374
+ }
375
+ } catch (err) {
376
+ logger.warn('[QuickOrchestrator] Failed to store link descriptions (non-fatal):', err);
377
+ }
378
+
379
+ metrics.increment('research_sessions_total', 1, { mode: 'quick', complexity: '0', status: 'success' });
380
+ // Complete the researcher slice before the run-level onComplete. Quick mode
381
+ // never emitted this, so its single box was only ever completed as a side
382
+ // effect of onSearchComplete — leaving it stuck when the researcher answered
383
+ // without searching. Now the lifecycle mirrors deep mode.
384
+ observer?.onResearcherComplete?.('quick', result);
385
+ terminalCallbackFired = true;
386
+ // A throwing user observer must not divert into the inner catch (which
387
+ // would fire onError too — a double terminal callback — and discard the
388
+ // result). Isolate it, mirroring the deep orchestrator + researcher-executor.
389
+ try {
390
+ observer?.onComplete?.(result);
391
+ } catch (obsErr) {
392
+ logger.debug('[QuickOrchestrator] onComplete observer threw:', obsErr);
393
+ }
394
+ return result;
395
+ } catch (error) {
396
+ // Terminal-callback contract (shared with DeepResearchOrchestrator):
397
+ // fire EXACTLY ONE of onComplete / onError. Quick mode produces its
398
+ // result atomically at the end and keeps no per-round reports, so a
399
+ // mid-run failure or cancel has nothing partial to return — it is an
400
+ // onError + throw, mirroring Deep's "no collected reports" branch.
401
+ const sessionDuration = Date.now() - sessionStart;
402
+ const aborted = signal?.aborted === true;
403
+ metrics.observe('research_session_duration_ms', sessionDuration, { mode: 'quick', complexity: '0', status: aborted ? 'cancelled' : 'error' });
404
+ metrics.increment('research_sessions_total', 1, { mode: 'quick', complexity: '0', status: aborted ? 'cancelled' : 'error' });
405
+ // Mark the researcher slice failed for a genuine fault (not a user cancel, which
406
+ // is not a failure). Deep mode fails its slices via onResearcherFailure too.
407
+ if (!aborted) {
408
+ observer?.onResearcherFailure?.('quick', error instanceof Error ? error.message : String(error));
409
+ }
410
+ terminalCallbackFired = true;
411
+ try {
412
+ observer?.onError?.(error instanceof Error ? error : new Error(String(error)));
413
+ } catch (obsErr) {
414
+ logger.debug('[QuickOrchestrator] onError observer threw:', obsErr);
415
+ }
416
+ throw error;
417
+ }
418
+ } catch (error) {
419
+ // Any throw BEFORE the inner research try (healthcheck failure at :99, session
420
+ // registration at :212, container disposed mid-setup) skips the inner catch, so
421
+ // without this run() would reject with onStart fired but no terminal callback —
422
+ // leaving the TUI/headless quick slice stuck "in progress" forever. Fire onError
423
+ // exactly once (the inner catch sets the flag for its own scope).
424
+ if (!terminalCallbackFired) {
425
+ const aborted = signal?.aborted === true;
426
+ if (!aborted) {
427
+ observer?.onResearcherFailure?.('quick', error instanceof Error ? error.message : String(error));
428
+ }
429
+ try {
430
+ observer?.onError?.(error instanceof Error ? error : new Error(String(error)));
431
+ } catch (obsErr) {
432
+ logger.debug('[QuickOrchestrator] onError observer threw (early-throw path):', obsErr);
433
+ }
434
+ }
435
+ throw error;
436
+ } finally {
437
+ if (subscription) subscription();
438
+
439
+ // Restore the default thinking label now that the researcher is done.
440
+ // Otherwise "Researcher X" persists for all subsequent agent turns.
441
+ // Symmetric with where the label is set (researcher.ts): a no-op in the
442
+ // research TUI, where the label is never applied in the first place.
443
+ if (ctx.mode !== 'tui' && ctx.hasUI && typeof ctx.ui.setHiddenThinkingLabel === 'function') {
444
+ ctx.ui.setHiddenThinkingLabel();
445
+ }
446
+
447
+ try {
448
+ const orch = await getService<IResearchOrchestration>(ServiceNames.RESEARCH_ORCHESTRATION, ctx, container);
449
+ // Pass ctx so cleanup resolves the SAME container-scoped services (synthesis,
450
+ // session) the run used — without it the container-local instances never get
451
+ // cleared and reports accumulate toward MAX_SESSIONS eviction (matches the
452
+ // DeepResearchOrchestrator cleanup call).
453
+ // On user abort, skip the FTS rebuild + optimize (non-signal-aware
454
+ // optimization passes) so Esc returns promptly — mirrors deep mode; the
455
+ // next run's cleanup performs the same rebuild.
456
+ await orch.cleanupResearchServices(undefined, researchId, ctx, this.config,
457
+ { skipStoreMaintenance: signal?.aborted === true });
458
+ } catch (err) {
459
+ logger.warn('[QuickOrchestrator] Failed to cleanup research services:', err);
460
+ }
461
+ }
462
+ }
463
+ }