@lincoln504/pi-research 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/config/searxng/default-settings.yml +189 -0
  4. package/config/searxng/limiter.toml +34 -0
  5. package/config/tooling/eslint.config.js +73 -0
  6. package/config/tooling/vitest.config.integration.ts +17 -0
  7. package/config/tooling/vitest.config.ts +40 -0
  8. package/config/tooling/vitest.config.unit.ts +12 -0
  9. package/docs/ARCHITECTURE.md +109 -0
  10. package/docs/assets/README-banner.jpg +0 -0
  11. package/package.json +92 -0
  12. package/scripts/setup.js +174 -0
  13. package/src/config.ts +206 -0
  14. package/src/constants.ts +148 -0
  15. package/src/healthcheck/index.ts +309 -0
  16. package/src/index.ts +71 -0
  17. package/src/infrastructure/searxng-lifecycle.ts +577 -0
  18. package/src/infrastructure/searxng-manager.ts +1173 -0
  19. package/src/infrastructure/state-manager.ts +918 -0
  20. package/src/logger.ts +246 -0
  21. package/src/orchestration/deep-research-orchestrator.ts +848 -0
  22. package/src/orchestration/deep-research-reducer.ts +129 -0
  23. package/src/orchestration/deep-research-types.ts +54 -0
  24. package/src/orchestration/id-utils.ts +63 -0
  25. package/src/orchestration/researcher.ts +104 -0
  26. package/src/orchestration/session-context.ts +43 -0
  27. package/src/prompts/research-tool-usage.md +45 -0
  28. package/src/prompts/researcher.md +166 -0
  29. package/src/prompts/system-coordinator.md +24 -0
  30. package/src/prompts/system-lead-evaluator.md +225 -0
  31. package/src/security/cisa-kev.ts +242 -0
  32. package/src/security/github-advisories.ts +607 -0
  33. package/src/security/index.ts +406 -0
  34. package/src/security/nvd.ts +532 -0
  35. package/src/security/osv.ts +487 -0
  36. package/src/security/types.ts +138 -0
  37. package/src/stackexchange/cache.ts +111 -0
  38. package/src/stackexchange/index.ts +352 -0
  39. package/src/stackexchange/output/compact.ts +64 -0
  40. package/src/stackexchange/output/index.ts +7 -0
  41. package/src/stackexchange/output/json.ts +21 -0
  42. package/src/stackexchange/output/table.ts +123 -0
  43. package/src/stackexchange/queries.ts +168 -0
  44. package/src/stackexchange/rest-client.ts +127 -0
  45. package/src/stackexchange/types.ts +129 -0
  46. package/src/tool.ts +430 -0
  47. package/src/tools/grep.ts +335 -0
  48. package/src/tools/index.ts +77 -0
  49. package/src/tools/scrape.ts +451 -0
  50. package/src/tools/search.ts +188 -0
  51. package/src/tools/security.ts +292 -0
  52. package/src/tools/stackexchange.ts +101 -0
  53. package/src/tui/research-panel.ts +576 -0
  54. package/src/tui/searxng-status.ts +80 -0
  55. package/src/types/extension-context.ts +60 -0
  56. package/src/types/llm.ts +111 -0
  57. package/src/utils/inject-date.ts +29 -0
  58. package/src/utils/input-validation.ts +103 -0
  59. package/src/utils/json-utils.ts +262 -0
  60. package/src/utils/make-resource-loader.ts +43 -0
  61. package/src/utils/research-error.ts +205 -0
  62. package/src/utils/research-export.ts +199 -0
  63. package/src/utils/searxng-config.ts +94 -0
  64. package/src/utils/session-state.ts +295 -0
  65. package/src/utils/shared-links.ts +123 -0
  66. package/src/utils/shutdown-manager.ts +54 -0
  67. package/src/utils/text-utils.ts +83 -0
  68. package/src/utils/tool-usage-tracker.ts +135 -0
  69. package/src/web-research/dependencies.ts +20 -0
  70. package/src/web-research/retry-utils.ts +232 -0
  71. package/src/web-research/scrapers.ts +935 -0
  72. package/src/web-research/search.ts +235 -0
  73. package/src/web-research/types.ts +47 -0
  74. package/src/web-research/utils.ts +158 -0
  75. package/tsconfig.json +40 -0
@@ -0,0 +1,848 @@
1
+ /**
2
+ * Deep Research Orchestrator
3
+ *
4
+ * Manages the decentralized deep research lifecycle:
5
+ * 1. Initial Planning (One-shot) - exhaustive agenda creation
6
+ * 2. Parallel Sibling Execution - with mid-flight report injection
7
+ * 3. Last-Man-Standing Promotion - the final sibling handles evaluation/synthesis
8
+ *
9
+ * State transitions are handled by the pure deepResearchReducer.
10
+ */
11
+
12
+ import { join } from 'node:path';
13
+ import { readFileSync } from 'node:fs';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { dirname } from 'node:path';
16
+ import type { ExtensionContext, AgentSession } from '@mariozechner/pi-coding-agent';
17
+ import { convertToLlm } from '@mariozechner/pi-coding-agent';
18
+ import type { ExtendedAgentSessionEvent } from '../types/extension-context.ts';
19
+ import { complete, type Model } from '@mariozechner/pi-ai';
20
+ import { deepResearchReducer, isRoundComplete } from './deep-research-reducer.ts';
21
+ import type { SystemResearchState, ResearchSibling } from './deep-research-types.ts';
22
+ import { TextContentBlock, isTextContentBlock, parseTokenUsage, calculateTotalTokens } from '../types/llm.ts';
23
+ import {
24
+ INITIAL_RESEARCHERS_LEVEL_1,
25
+ INITIAL_RESEARCHERS_LEVEL_2,
26
+ INITIAL_RESEARCHERS_LEVEL_3,
27
+ MAX_ROUNDS_LEVEL_1,
28
+ MAX_ROUNDS_LEVEL_2,
29
+ MAX_ROUNDS_LEVEL_3,
30
+ MAX_CONCURRENT_RESEARCHERS,
31
+ MAX_REPORT_LENGTH,
32
+ DEFAULT_MODEL_CONTEXT_WINDOW,
33
+ MAX_EXTRA_ROUNDS,
34
+ MAX_EVALUATOR_REPORT_LENGTH,
35
+ RESEARCHER_LAUNCH_DELAY_MS,
36
+ STATE_PROPAGATION_DELAY_MS,
37
+ STREAMING_UPDATE_THRESHOLD_CHARS,
38
+ } from '../constants.ts';
39
+ import { extractJson, extractJsonFromCodeBlocks, extractJsonObject, normalizeStringArrayDetailed } from '../utils/json-utils.ts';
40
+ import { createResearcherSession } from './researcher.ts';
41
+ import { injectCurrentDate } from '../utils/inject-date.ts';
42
+ import { buildSessionContext } from '@mariozechner/pi-coding-agent';
43
+ import { formatSharedLinksFromState } from '../utils/shared-links.ts';
44
+ import { logger } from '../logger.ts';
45
+ import { ensureAssistantResponse } from '../utils/text-utils.ts';
46
+ import { addSlice, activateSlice, completeSlice, removeSlice, flashSlice, updateSliceTokens, type ResearchPanelState } from '../tui/research-panel.ts';
47
+ import { getDisplayNumber, getResearcherRoleContext } from './id-utils.ts';
48
+
49
+ const __filename = fileURLToPath(import.meta.url);
50
+ const __dirname = dirname(__filename);
51
+
52
+ /**
53
+ * Strict schema for evaluator delegation response
54
+ */
55
+ interface EvaluatorDelegateDecision {
56
+ action: 'delegate';
57
+ queries: string[];
58
+ }
59
+
60
+ /**
61
+ * Strict schema for evaluator synthesis decision (implicit)
62
+ * Synthesis is any non-delegation response, so no explicit schema needed.
63
+ */
64
+
65
+ type EvalDecision =
66
+ | { action: 'delegate'; queries: string[] }
67
+ | { action: 'synthesize'; content: string };
68
+
69
+ /**
70
+ * Parse the lead evaluator's response into a typed decision.
71
+ *
72
+ * Delegation format (the response IS the JSON, nothing else):
73
+ * {"action": "delegate", "queries": ["q1", "q2"]}
74
+ *
75
+ * Synthesis format: any markdown text.
76
+ * Synthesis is the safe fallback — ambiguous or unparseable output is treated
77
+ * as synthesis to avoid prematurely extending research.
78
+ *
79
+ * Extraction order (most → least reliable):
80
+ * 1. Direct JSON.parse of the trimmed response (clean model output)
81
+ * 2. Fenced ```json code block (model wrapped it)
82
+ * 3. Raw brace extraction — only when '{' appears within the first 200 chars,
83
+ * meaning the JSON is at the start of the response. JSON buried deep in a
84
+ * long document is almost certainly a synthesis with an inline example and
85
+ * must not be extracted as delegation.
86
+ */
87
+ function parseEvaluatorResponse(text: string): EvalDecision {
88
+ const trimmed = text.trim();
89
+
90
+ const tryDelegate = (parsed: unknown): EvalDecision | null => {
91
+ if (
92
+ parsed !== null &&
93
+ typeof parsed === 'object' &&
94
+ (parsed as EvaluatorDelegateDecision).action === 'delegate' &&
95
+ Array.isArray((parsed as EvaluatorDelegateDecision).queries) &&
96
+ (parsed as EvaluatorDelegateDecision).queries.length > 0
97
+ ) {
98
+ const normalized = normalizeStringArrayDetailed((parsed as EvaluatorDelegateDecision).queries);
99
+ if (normalized.warnings.length > 0) {
100
+ logger.debug('[deep-research] Evaluator query normalization warnings:', normalized.warnings.join('; '));
101
+ }
102
+ if (normalized.strings.length > 0) {
103
+ return { action: 'delegate', queries: normalized.strings };
104
+ }
105
+ logger.warn('[deep-research] Evaluator delegation had no valid queries after normalization');
106
+ }
107
+ return null;
108
+ };
109
+
110
+ // 1. Fast path: response IS the JSON object
111
+ if (trimmed.startsWith('{')) {
112
+ try {
113
+ const decision = tryDelegate(JSON.parse(trimmed));
114
+ if (decision) return decision;
115
+ } catch { /* not pure JSON */ }
116
+ }
117
+
118
+ // 2. Fenced code block (```json ... ```)
119
+ const codeBlockResult = extractJsonFromCodeBlocks<EvaluatorDelegateDecision>(trimmed);
120
+ if (codeBlockResult.success && codeBlockResult.value) {
121
+ const decision = tryDelegate(codeBlockResult.value);
122
+ if (decision) return decision;
123
+ }
124
+
125
+ // 3. Raw brace extraction — only if '{' is near the start (model added prose prefix
126
+ // but still intended to delegate). JSON buried deep in a long synthesis is ignored.
127
+ const bracePos = trimmed.indexOf('{');
128
+ if (bracePos !== -1 && bracePos < 200) {
129
+ const objectResult = extractJsonObject<EvaluatorDelegateDecision>(trimmed);
130
+ if (objectResult.success && objectResult.value) {
131
+ const decision = tryDelegate(objectResult.value);
132
+ if (decision) {
133
+ logger.debug('[deep-research] Extracted delegation from prose-wrapped response (brace at pos', bracePos, ')');
134
+ return decision;
135
+ }
136
+ }
137
+ }
138
+
139
+ logger.debug('[deep-research] No valid delegation found — treating response as synthesis');
140
+ return { action: 'synthesize', content: text };
141
+ }
142
+
143
+ /**
144
+ * Truncate a researcher report body to maxChars while preserving the CITED LINKS
145
+ * and SCRAPE CANDIDATES sections that appear at the end of the document.
146
+ * Without this, a naive slice cuts off the very URLs the evaluator needs to see.
147
+ */
148
+ function truncatePreservingLinks(report: string, maxChars: number): string {
149
+ if (report.length <= maxChars) return report;
150
+ const linkSectionMatch = report.match(/\n###\s*CITED\s*LINKS[\s\S]*/i);
151
+ const linkSection = linkSectionMatch ? linkSectionMatch[0] : '';
152
+ const availableForBody = maxChars - linkSection.length - 40;
153
+ if (availableForBody <= 0) {
154
+ return report.slice(0, maxChars) + '\n... (truncated) ...';
155
+ }
156
+ return report.slice(0, availableForBody) + '\n\n... (body truncated for length) ...' + linkSection;
157
+ }
158
+
159
+ export interface DeepResearchOrchestratorOptions {
160
+ ctx: ExtensionContext;
161
+ model: Model<any>;
162
+ query: string;
163
+ complexity: 1 | 2 | 3;
164
+ onTokens: (n: number) => void;
165
+ onUpdate: () => void;
166
+ searxngUrl: string;
167
+ panelState: ResearchPanelState;
168
+ }
169
+
170
+ export class DeepResearchOrchestrator {
171
+ private state: SystemResearchState;
172
+ private activeSessions = new Map<string, AgentSession>();
173
+ private resolveCompletion: (result: string) => void = () => {};
174
+ private rejectCompletion: (err: Error) => void = () => {};
175
+ private completionResolved = false; // Track if we've already resolved to prevent duplicate resolutions
176
+
177
+ /** Per-sibling current context occupation (overwritten on every message_end). */
178
+ private siblingTokens = new Map<string, number>();
179
+ /** Tracks progress units already credited per sibling/evaluator to ensure full budget is met. */
180
+ private progressCredits = new Map<string, number>();
181
+ /** Resolved context window size for the selected model. */
182
+ private contextWindowSize: number;
183
+
184
+ private static readonly UNITS_PER_RESEARCHER = 10;
185
+ private static readonly LEAD_EVAL_UNITS = 5;
186
+
187
+ constructor(private options: DeepResearchOrchestratorOptions) {
188
+ this.state = {
189
+ version: 1,
190
+ rootQuery: options.query,
191
+ complexity: options.complexity,
192
+ currentRound: 1,
193
+ status: 'planning',
194
+ lastUpdated: Date.now(),
195
+ initialAgenda: [],
196
+ allScrapedLinks: [],
197
+ aspects: {},
198
+ };
199
+ this.contextWindowSize = (options.model as any)?.contextWindow ?? DEFAULT_MODEL_CONTEXT_WINDOW;
200
+ }
201
+
202
+ private resolveResult(result: string) {
203
+ // Prevent duplicate resolutions
204
+ if (this.completionResolved) {
205
+ logger.warn('[deep-research] Attempting to resolve already completed research, ignoring');
206
+ return;
207
+ }
208
+ this.completionResolved = true;
209
+
210
+ if (this.options.panelState.progress) {
211
+ // Snap progress to 100% to ensure clean completion
212
+ this.options.panelState.progress.made = this.options.panelState.progress.expected;
213
+ this.options.onUpdate();
214
+ }
215
+ const final = result.length > MAX_REPORT_LENGTH
216
+ ? result.slice(0, MAX_REPORT_LENGTH) + '\n\n... (final report truncated for length) ...'
217
+ : result;
218
+ logger.log('[deep-research] Research resolved with result of length:', final.length);
219
+ this.resolveCompletion(final);
220
+ }
221
+
222
+ async run(signal?: AbortSignal): Promise<string> {
223
+ return new Promise((resolve, reject) => {
224
+ this.resolveCompletion = resolve;
225
+ this.rejectCompletion = (err: Error) => {
226
+ if (this.completionResolved) {
227
+ logger.warn('[deep-research] Attempting to reject already completed research, ignoring');
228
+ return;
229
+ }
230
+ this.completionResolved = true;
231
+ reject(err);
232
+ };
233
+ if (signal?.aborted) return reject(new Error('Research aborted'));
234
+ signal?.addEventListener('abort', () => reject(new Error('Research aborted')));
235
+ this.kickoff(signal).catch(reject);
236
+ });
237
+ }
238
+
239
+ private updateState(event: Parameters<typeof deepResearchReducer>[1]) {
240
+ this.state = deepResearchReducer(this.state, event);
241
+ }
242
+
243
+ private calculateUsageCost(usage: Partial<import('../types/llm').TokenUsage>): number {
244
+ if (!usage || !this.options.model?.cost) return 0;
245
+ const modelCost = this.options.model.cost;
246
+ const inputCost = (modelCost.input / 1_000_000) * (usage.input ?? 0);
247
+ const outputCost = (modelCost.output / 1_000_000) * (usage.output ?? 0);
248
+ const cacheReadCost = (modelCost.cacheRead / 1_000_000) * (usage.cacheRead ?? 0);
249
+ const cacheWriteCost = (modelCost.cacheWrite / 1_000_000) * (usage.cacheWrite ?? 0);
250
+ return inputCost + outputCost + cacheReadCost + cacheWriteCost;
251
+ }
252
+
253
+ private async kickoff(signal?: AbortSignal) {
254
+ logger.log(`[deep-research] Kickoff: status=${this.state.status}`);
255
+ if (this.state.status === 'planning') await this.doPlanning(signal);
256
+ if (this.state.status === 'researching') await this.startRound(signal);
257
+ if (this.state.status === 'completed') {
258
+ logger.log('[deep-research] Already in completed state, resolving with synthesis');
259
+ this.resolveResult(this.state.finalSynthesis || '');
260
+ }
261
+ }
262
+
263
+ private getInitialSiblingCount(complexity: 1 | 2 | 3): number {
264
+ switch(complexity) {
265
+ case 1: return INITIAL_RESEARCHERS_LEVEL_1;
266
+ case 2: return INITIAL_RESEARCHERS_LEVEL_2;
267
+ case 3: return INITIAL_RESEARCHERS_LEVEL_3;
268
+ }
269
+ }
270
+
271
+ private getMaxRounds(complexity: 1 | 2 | 3): number {
272
+ switch(complexity) {
273
+ case 1: return MAX_ROUNDS_LEVEL_1;
274
+ case 2: return MAX_ROUNDS_LEVEL_2;
275
+ case 3: return MAX_ROUNDS_LEVEL_3;
276
+ }
277
+ }
278
+
279
+ private async doPlanning(signal?: AbortSignal) {
280
+ this.options.panelState.statusMessage = 'planning...';
281
+ this.options.onUpdate();
282
+
283
+ try {
284
+ // Get full parent conversation context for's coordinator (emulates fork mode)
285
+ const branch = this.options.ctx.sessionManager.getBranch();
286
+ const sessionContext = buildSessionContext(branch);
287
+ const parentMessages = sessionContext.messages;
288
+
289
+ logger.log(`[deep-research] Planning with ${parentMessages.length} parent messages (fork mode emulation)`);
290
+
291
+ const coordinatorPromptRaw = readFileSync(join(__dirname, '..', 'prompts', 'system-coordinator.md'), 'utf-8');
292
+ const plannerPrompt = coordinatorPromptRaw + `\n\nQUERY: ${this.state.rootQuery}`;
293
+
294
+ const auth = await this.options.ctx.modelRegistry.getApiKeyAndHeaders(this.options.model);
295
+ if (!auth.ok) throw new Error(`Failed to get API credentials: ${auth.error}`);
296
+
297
+ // Convert AgentMessages to LLM-compatible Messages
298
+ const llmMessages = await convertToLlm(parentMessages);
299
+
300
+ const response = await complete(this.options.model, {
301
+ messages: [
302
+ // Include full parent conversation history (converted) + current query
303
+ ...llmMessages,
304
+ { role: 'user', content: [{ type: 'text', text: plannerPrompt }], timestamp: Date.now() }
305
+ ]
306
+ }, { apiKey: auth.apiKey!, headers: auth.headers, signal });
307
+
308
+ const usage = response.usage;
309
+ if (usage) {
310
+ const tokens = usage.totalTokens || (usage.input || 0) + (usage.output || 0) + (usage.cacheRead || 0) + (usage.cacheWrite || 0);
311
+ if (tokens > 0) {
312
+ this.options.onTokens(tokens);
313
+ this.options.onUpdate();
314
+ }
315
+ }
316
+
317
+ const text = response.content
318
+ .filter((c): c is TextContentBlock => isTextContentBlock(c))
319
+ .map(c => c.text)
320
+ .join('');
321
+ let agenda: string[] = [];
322
+ try {
323
+ // Extract JSON array from coordinator response
324
+ const result = extractJson<string[]>(text, 'array');
325
+
326
+ if (result.success && result.value && result.value.length > 0) {
327
+ // Normalize queries with detailed feedback
328
+ const normalized = normalizeStringArrayDetailed(result.value);
329
+
330
+ if (normalized.strings.length > 0) {
331
+ agenda = normalized.strings;
332
+ logger.debug(`[deep-research] Parsed ${agenda.length} agenda items from coordinator response`);
333
+
334
+ // Log any warnings
335
+ if (normalized.warnings.length > 0) {
336
+ logger.debug('[deep-research] Coordinator query normalization warnings:',
337
+ normalized.warnings.join('; '));
338
+ }
339
+ }
340
+ } else {
341
+ logger.debug('[deep-research] Could not parse agenda JSON:', result.error ?? 'unknown error');
342
+ }
343
+ } catch (err) {
344
+ logger.error('[deep-research] Failed to parse planning agenda JSON:', err);
345
+ }
346
+
347
+ if (agenda.length === 0) {
348
+ agenda = [this.state.rootQuery];
349
+ logger.warn('[deep-research] No valid agenda items found, using root query as fallback');
350
+ }
351
+
352
+ const initialCount = this.getInitialSiblingCount(this.state.complexity);
353
+ logger.log(`[deep-research] Planning complete: ${agenda.length} agenda items. Initial count: ${initialCount}`);
354
+
355
+ this.updateState({
356
+ type: 'PLANNING_COMPLETE',
357
+ agenda,
358
+ initialCount
359
+ });
360
+
361
+ // Initialise progress budget — start with only round 1 + one lead-eval; expand dynamically
362
+ // when the lead evaluator spawns additional rounds in promoteToLead.
363
+ const round1Count = Math.min(initialCount, agenda.length);
364
+ const initialExpected = round1Count * DeepResearchOrchestrator.UNITS_PER_RESEARCHER
365
+ + DeepResearchOrchestrator.LEAD_EVAL_UNITS;
366
+
367
+ this.options.panelState.progress = { expected: initialExpected, made: 0, extended: false };
368
+ this.options.onUpdate();
369
+
370
+ } catch (err) {
371
+ const errorMsg = err instanceof Error ? err.message : String(err);
372
+ logger.error('[deep-research] Planning failed:', err);
373
+ this.rejectCompletion(new Error(`Research planning failed: ${errorMsg}`));
374
+ } finally {
375
+ this.options.panelState.statusMessage = undefined;
376
+ this.options.onUpdate();
377
+ }
378
+ }
379
+
380
+ private async startRound(signal?: AbortSignal) {
381
+ const currentRound = this.state.currentRound;
382
+ const roundAspects = Object.values(this.state.aspects).filter(a => a.id.startsWith(`${currentRound}.`));
383
+ const runningAspects = roundAspects.filter(a => a.status === 'running');
384
+ const pendingAspects = roundAspects.filter(a => a.status === 'pending');
385
+
386
+ if (pendingAspects.length > 0) {
387
+ // TUI Optimization: If we are starting a fresh round (generation 1),
388
+ // remove slices from previous rounds to prevent horizontal crowding.
389
+ const isFirstGenOfRound = roundAspects.length === pendingAspects.length;
390
+ if (isFirstGenOfRound && currentRound > 1) {
391
+ const prevRoundPrefix = `${currentRound - 1}.`;
392
+ const prevCoordId = `coord.${currentRound - 1}`;
393
+ const toRemove = Array.from(this.options.panelState.slices.keys())
394
+ .filter(id => id.startsWith(prevRoundPrefix) || id === prevCoordId);
395
+ for (const id of toRemove) {
396
+ removeSlice(this.options.panelState, id);
397
+ }
398
+ this.options.onUpdate();
399
+ }
400
+
401
+ const allowedToLaunch = Math.max(0, MAX_CONCURRENT_RESEARCHERS - runningAspects.length);
402
+
403
+ if (allowedToLaunch > 0) {
404
+ const toLaunch = pendingAspects.slice(0, allowedToLaunch);
405
+ logger.log(`[deep-research] Round ${currentRound}: Launching ${toLaunch.length} siblings (Concurrency limit: ${MAX_CONCURRENT_RESEARCHERS}).`);
406
+
407
+ for (let i = 0; i < toLaunch.length; i++) {
408
+ if (i > 0) await new Promise(resolve => setTimeout(resolve, RESEARCHER_LAUNCH_DELAY_MS));
409
+ if (signal?.aborted) break;
410
+ this.executeSibling(toLaunch[i]!, signal);
411
+ }
412
+ }
413
+ return;
414
+ }
415
+
416
+ if (runningAspects.length > 0) {
417
+ return;
418
+ }
419
+
420
+ if (roundAspects.length > 0 && isRoundComplete(this.state, currentRound)) {
421
+ if (!this.state.promotedId && !this.state.finalSynthesis) {
422
+ const allCompletedWithReports = roundAspects.filter(a => a.status === 'completed' && a.report);
423
+ if (allCompletedWithReports.length === 0) {
424
+ const allFailed = roundAspects.filter(a => a.status === 'failed');
425
+ const errors = allFailed.map(a => `• Researcher ${getDisplayNumber(this.state, a.id)}: ${a.error}`).join('\n');
426
+ this.rejectCompletion(new Error(`Research failed. All researchers encountered errors:\n\n${errors}`));
427
+ return;
428
+ }
429
+ this.runCoordinatorEval(signal);
430
+ return;
431
+ } else if (this.state.promotedId) {
432
+ return;
433
+ }
434
+ }
435
+
436
+ if (this.state.status !== 'completed') {
437
+ if (this.state.finalSynthesis) {
438
+ this.resolveResult(this.state.finalSynthesis);
439
+ } else {
440
+ this.state.status = 'completed';
441
+ this.resolveResult('Research ended.');
442
+ }
443
+ }
444
+ }
445
+
446
+ private async executeSibling(aspect: ResearchSibling, signal?: AbortSignal) {
447
+ this.updateState({ type: 'SIBLING_STARTED', id: aspect.id });
448
+
449
+ const displayNum = getDisplayNumber(this.state, aspect.id);
450
+ const roleContext = getResearcherRoleContext(this.state, aspect.id);
451
+
452
+ addSlice(this.options.panelState, aspect.id, displayNum, false);
453
+ activateSlice(this.options.panelState, aspect.id);
454
+ this.options.onUpdate();
455
+
456
+ const researcherPromptRaw = readFileSync(join(__dirname, '..', 'prompts', 'researcher.md'), 'utf-8');
457
+ const sharedLinksMarkdown = formatSharedLinksFromState(this.state.aspects);
458
+ const allFindingsMarkdown = this.buildAllFindingsContext();
459
+ const roleContextMarkdown = this.buildRoleContext(aspect, roleContext);
460
+ const researcherPrompt = injectCurrentDate(researcherPromptRaw, 'researcher')
461
+ + '\n\n' + roleContextMarkdown
462
+ + '\n\n' + allFindingsMarkdown
463
+ + '\n\n' + sharedLinksMarkdown;
464
+
465
+ const initialPromptEstimate = Math.ceil(researcherPrompt.length / 4);
466
+ this.siblingTokens.set(aspect.id, initialPromptEstimate);
467
+ const getTokensUsed = () => this.siblingTokens.get(aspect.id) ?? 0;
468
+
469
+ updateSliceTokens(this.options.panelState, aspect.id, initialPromptEstimate, 0);
470
+ this.options.onUpdate();
471
+
472
+ let session: AgentSession | undefined;
473
+ try {
474
+ session = await createResearcherSession({
475
+ cwd: this.options.ctx.cwd,
476
+ ctxModel: this.options.model,
477
+ modelRegistry: this.options.ctx.modelRegistry,
478
+ settingsManager: (this.options.ctx as any).settingsManager,
479
+ systemPrompt: researcherPrompt,
480
+ searxngUrl: this.options.searxngUrl,
481
+ extensionCtx: this.options.ctx,
482
+ getGlobalState: () => this.state,
483
+ updateGlobalLinks: (links: string[]) => {
484
+ this.updateState({ type: 'LINKS_SCRAPED', links });
485
+ },
486
+ getTokensUsed,
487
+ contextWindowSize: this.contextWindowSize,
488
+ });
489
+
490
+ this.activeSessions.set(aspect.id, session);
491
+
492
+ const subscription = session.subscribe((event: ExtendedAgentSessionEvent) => {
493
+ if (event.type === 'message_end') {
494
+ const usage = parseTokenUsage(event.message?.usage);
495
+ const tokens = calculateTotalTokens(usage);
496
+ const cost = this.calculateUsageCost(usage);
497
+
498
+ if (tokens > 0) {
499
+ if (event.message?.role === 'assistant') {
500
+ this.siblingTokens.set(aspect.id, tokens);
501
+ this.options.onTokens(tokens);
502
+ updateSliceTokens(this.options.panelState, aspect.id, tokens, cost);
503
+ this.updateState({ type: 'SIBLING_TOKENS', id: aspect.id, tokens, cost });
504
+ this.options.onUpdate();
505
+ }
506
+ }
507
+ } else if (event.type === 'message_update') {
508
+ // Estimate context growth from streaming text length during model generation.
509
+ // Cost is NOT updated here (requires exact usage from message_end).
510
+ // Tokens are SET (not accumulated), so we pass cost=0 to leave cost unchanged.
511
+ const updateMsg = event.message;
512
+ if (updateMsg?.role === 'assistant') {
513
+ // Content may be in the message object but not typed in ExtendedAgentSessionEvent
514
+ const content = (updateMsg as any).content;
515
+ if (Array.isArray(content)) {
516
+ const textLen = content
517
+ .filter(isTextContentBlock)
518
+ .reduce((sum, b) => sum + b.text.length, 0);
519
+ if (textLen > STREAMING_UPDATE_THRESHOLD_CHARS) {
520
+ const base = this.siblingTokens.get(aspect.id) ?? initialPromptEstimate;
521
+ updateSliceTokens(this.options.panelState, aspect.id, base + Math.ceil(textLen / 4), 0);
522
+ this.options.onUpdate();
523
+ }
524
+ }
525
+ }
526
+ } else if (event.type === 'tool_execution_end') {
527
+ const isError = event.isError ?? false;
528
+ const color = isError ? 'red' : 'green';
529
+ const duration = isError ? 1000 : 500;
530
+ flashSlice(this.options.panelState, aspect.id, color, duration, this.options.onUpdate);
531
+
532
+ if (this.options.panelState.progress) {
533
+ const current = this.progressCredits.get(aspect.id) ?? 0;
534
+ if (current < 8) {
535
+ const newVal = current + 1;
536
+ this.progressCredits.set(aspect.id, newVal);
537
+ this.options.panelState.progress.made += 1;
538
+ this.options.onUpdate();
539
+ }
540
+ }
541
+ }
542
+ });
543
+
544
+ const queryText = typeof aspect.query === 'string' ? aspect.query : String(aspect.query);
545
+ await session.prompt(queryText);
546
+
547
+ if (signal?.aborted) {
548
+ if (typeof subscription === 'function') subscription();
549
+ this.updateState({ type: 'SIBLING_FAILED', id: aspect.id, error: 'Aborted' });
550
+ return;
551
+ }
552
+
553
+ const report = ensureAssistantResponse(session, aspect.id);
554
+ if (typeof subscription === 'function') subscription();
555
+
556
+ this.updateState({ type: 'SIBLING_COMPLETED', id: aspect.id, report });
557
+
558
+ const currentRound = this.state.currentRound;
559
+ const isLastStanding = isRoundComplete(this.state, currentRound);
560
+
561
+ completeSlice(this.options.panelState, aspect.id);
562
+
563
+ if (this.options.panelState.progress) {
564
+ const alreadyCredited = this.progressCredits.get(aspect.id) ?? 0;
565
+ const topUp = DeepResearchOrchestrator.UNITS_PER_RESEARCHER - alreadyCredited;
566
+ if (topUp > 0) {
567
+ this.options.panelState.progress.made += topUp;
568
+ this.progressCredits.set(aspect.id, DeepResearchOrchestrator.UNITS_PER_RESEARCHER);
569
+ }
570
+ }
571
+ this.options.onUpdate();
572
+
573
+ if (isLastStanding) {
574
+ await this.handleSiblingCompletion(aspect, signal);
575
+ } else {
576
+ await this.injectFindingsIntoRunningSiblings(aspect);
577
+ }
578
+
579
+ } catch (err) {
580
+ if (signal?.aborted) return;
581
+ const errorMsg = String(err);
582
+ logger.error(`[deep-research] Sibling ${aspect.id} failed:`, err);
583
+ this.updateState({ type: 'SIBLING_FAILED', id: aspect.id, error: errorMsg });
584
+ flashSlice(this.options.panelState, aspect.id, 'red', 1000, this.options.onUpdate);
585
+ completeSlice(this.options.panelState, aspect.id);
586
+
587
+ if (this.options.panelState.progress) {
588
+ const alreadyCredited = this.progressCredits.get(aspect.id) ?? 0;
589
+ const topUp = DeepResearchOrchestrator.UNITS_PER_RESEARCHER - alreadyCredited;
590
+ if (topUp > 0) {
591
+ this.options.panelState.progress.made += topUp;
592
+ this.progressCredits.set(aspect.id, DeepResearchOrchestrator.UNITS_PER_RESEARCHER);
593
+ }
594
+ }
595
+ this.options.onUpdate();
596
+
597
+ const currentRound = this.state.currentRound;
598
+ if (isRoundComplete(this.state, currentRound)) {
599
+ await this.handleSiblingCompletion(aspect, signal);
600
+ }
601
+ } finally {
602
+ this.activeSessions.delete(aspect.id);
603
+ this.startRound(signal).catch(() => {});
604
+ }
605
+ }
606
+
607
+ private async injectFindingsIntoRunningSiblings(finished: ResearchSibling) {
608
+ if (!finished.report) return;
609
+ const currentRound = this.state.currentRound;
610
+ const allInRound = Object.values(this.state.aspects).filter(a => a.id.startsWith(`${currentRound}.`));
611
+ const runningSiblings = allInRound.filter(s => s.status === 'running' && s.id !== finished.id);
612
+
613
+ for (const target of runningSiblings) {
614
+ const targetSession = this.activeSessions.get(target.id);
615
+ if (targetSession) {
616
+ const finishedDisplayNum = getDisplayNumber(this.state, finished.id);
617
+ const truncatedReport = truncatePreservingLinks(finished.report, MAX_EVALUATOR_REPORT_LENGTH);
618
+ const injectionMessage = `## UPDATE: Sibling ${finishedDisplayNum} Completed Research\n\n${truncatedReport}`;
619
+ try {
620
+ await targetSession.steer(injectionMessage);
621
+ } catch (err) {
622
+ logger.warn(`[deep-research] Failed to inject findings from ${finished.id} into ${target.id}:`, err);
623
+ }
624
+ }
625
+ }
626
+ }
627
+
628
+ private async handleSiblingCompletion(_finished: ResearchSibling, signal?: AbortSignal) {
629
+ if (signal?.aborted) return;
630
+ if (this.state.promotedId || this.state.finalSynthesis) return;
631
+
632
+ const currentRound = this.state.currentRound;
633
+ const roundAspects = Object.values(this.state.aspects).filter(a => a.id.startsWith(`${currentRound}.`));
634
+ const allCompletedWithReports = roundAspects.filter(a => a.status === 'completed' && a.report);
635
+
636
+ if (allCompletedWithReports.length === 0) {
637
+ const allFailed = roundAspects.filter(a => a.status === 'failed');
638
+ const errors = allFailed.map(a => `• Researcher ${getDisplayNumber(this.state, a.id)}: ${a.error}`).join('\n');
639
+ this.rejectCompletion(new Error(`Research failed. All researchers encountered errors:\n\n${errors}`));
640
+ return;
641
+ }
642
+
643
+ await this.runCoordinatorEval(signal);
644
+ }
645
+
646
+ private async runCoordinatorEval(signal?: AbortSignal) {
647
+ const evaluatedRound = this.state.currentRound;
648
+ const coordId = `coord.${evaluatedRound}`;
649
+
650
+ // Add evaluator slice AFTER all previous round slices are complete (grey)
651
+ // The evaluator does not count toward the 3 max concurrent researchers limit
652
+ addSlice(this.options.panelState, coordId, 'Eval', false);
653
+ activateSlice(this.options.panelState, coordId);
654
+ this.options.onUpdate();
655
+
656
+ // An agenda item leaves remainingAgenda once assigned to a non-failed aspect.
657
+ // - pending/running/completed: considered in-progress or done — drop from list
658
+ // - failed: NOT excluded — evaluator must be able to re-delegate failed topics
659
+ // This also fixes orphaned items (beyond initialCount): they remain visible until
660
+ // the evaluator delegates them as exact-string queries, at which point they become
661
+ // pending aspects and drop off.
662
+ const assignedAspectQueries = new Set(
663
+ Object.values(this.state.aspects)
664
+ .filter(a => a.status !== 'failed')
665
+ .map(a => a.query)
666
+ );
667
+ const remainingAgenda = this.state.initialAgenda.filter(q => !assignedAspectQueries.has(q));
668
+ const targetRounds = this.getMaxRounds(this.state.complexity);
669
+ const hardLimit = targetRounds + MAX_EXTRA_ROUNDS;
670
+ const isAtTarget = this.state.currentRound >= targetRounds;
671
+ const isAtHardLimit = this.state.currentRound >= hardLimit;
672
+
673
+ // Provide ALL completed findings (including the lead's own) — fresh context via complete()
674
+ // This includes ALL researchers from ALL rounds that have completed with reports
675
+ const allCompleted = Object.values(this.state.aspects).filter(a => a.status === 'completed' && a.report);
676
+
677
+ logger.log(`[deep-research] Evaluator Round ${evaluatedRound}: Analyzing ${allCompleted.length} completed researchers from all rounds`);
678
+
679
+ const allReportsContext = allCompleted.length > 0
680
+ ? `## All Research Findings:\n\n` +
681
+ allCompleted.map(a => {
682
+ const report = a.report || '';
683
+ const truncated = truncatePreservingLinks(report, MAX_EVALUATOR_REPORT_LENGTH);
684
+ const roundNum = a.id.split('.')[0] ?? '?';
685
+ return `### Researcher ${getDisplayNumber(this.state, a.id)} (Round ${roundNum}): ${a.query}\n${truncated}`;
686
+ }).join('\n\n---\n\n')
687
+ : '';
688
+
689
+ const leadPromptRaw = readFileSync(join(__dirname, '..', 'prompts', 'system-lead-evaluator.md'), 'utf-8');
690
+ const originalAgendaStr = this.state.initialAgenda.map(q => `• ${q}`).join('\n');
691
+ let leadPrompt = leadPromptRaw
692
+ .replace('{ROOT_QUERY}', this.state.rootQuery)
693
+ .replace('{ORIGINAL_AGENDA}', originalAgendaStr)
694
+ .replace('{ROUND_NUMBER}', this.state.currentRound.toString())
695
+ .replace('{MAX_ROUNDS}', targetRounds.toString());
696
+
697
+ if (isAtHardLimit) {
698
+ leadPrompt += '\n\n⚠️ **CRITICAL: ABSOLUTE MAXIMUM REACHED.** You MUST provide a FINAL SYNTHESIS in Markdown format. Do NOT return a JSON delegation object.';
699
+ } else if (isAtTarget) {
700
+ leadPrompt += '\n\n⚠️ **NOTICE: TARGET DEPTH REACHED.** Synthesize if the agenda is substantially covered. Only delegate if CRITICAL GAPS remain unexplored.';
701
+ }
702
+
703
+ const agendaSection = remainingAgenda.length > 0
704
+ ? `\n### Unfulfilled Agenda Items\n${remainingAgenda.map(q => `- ${q}`).join('\n')}`
705
+ : '\n### All Agenda Items Covered\nAll initial research agenda items have been addressed.';
706
+
707
+ const promotionPrompt = leadPrompt + '\n\n' + allReportsContext + agendaSection;
708
+
709
+ try {
710
+ this.updateState({ type: 'PROMOTION_STARTED', id: coordId });
711
+
712
+ const auth = await this.options.ctx.modelRegistry.getApiKeyAndHeaders(this.options.model);
713
+ if (!auth.ok) throw new Error(`Failed to get API credentials: ${auth.error}`);
714
+
715
+ const heartbeat = setInterval(() => this.options.onUpdate(), 500);
716
+ let response: Awaited<ReturnType<typeof complete>>;
717
+ try {
718
+ response = await complete(this.options.model, {
719
+ messages: [{ role: 'user', content: [{ type: 'text', text: promotionPrompt }], timestamp: Date.now() }]
720
+ }, { apiKey: auth.apiKey!, headers: auth.headers, signal });
721
+ } finally {
722
+ clearInterval(heartbeat);
723
+ }
724
+
725
+ const decision = response.content
726
+ .filter((c): c is TextContentBlock => isTextContentBlock(c))
727
+ .map(c => c.text)
728
+ .join('');
729
+ const usage = parseTokenUsage(response.usage);
730
+ const tokens = calculateTotalTokens(usage);
731
+ const cost = this.calculateUsageCost(usage);
732
+ if (tokens > 0) {
733
+ this.options.onTokens(tokens);
734
+ updateSliceTokens(this.options.panelState, coordId, tokens, cost);
735
+ }
736
+
737
+ const evalDecision = parseEvaluatorResponse(decision);
738
+
739
+ logger.log(`[deep-research] Evaluator decision for Round ${evaluatedRound}: ${
740
+ evalDecision.action === 'synthesize'
741
+ ? 'SYNTHESIS - completing research'
742
+ : `DELEGATION - spawning ${evalDecision.queries.length} new researchers in Round ${evaluatedRound + 1}`
743
+ }`);
744
+
745
+ this.updateState({
746
+ type: 'PROMOTION_DECISION',
747
+ finalSynthesis: evalDecision.action === 'synthesize' ? evalDecision.content : undefined,
748
+ nextQueries: evalDecision.action === 'delegate' ? evalDecision.queries : [],
749
+ maxRounds: hardLimit
750
+ });
751
+
752
+ // Credit the evaluator work using the round number captured BEFORE the state transition
753
+ if (this.options.panelState.progress) {
754
+ const evalKey = `eval.${evaluatedRound}`;
755
+ if (!this.progressCredits.has(evalKey)) {
756
+ this.options.panelState.progress.made += DeepResearchOrchestrator.LEAD_EVAL_UNITS;
757
+ this.progressCredits.set(evalKey, DeepResearchOrchestrator.LEAD_EVAL_UNITS);
758
+ }
759
+ // Extend the progress budget if the evaluator spawned a new round
760
+ if (evalDecision.action === 'delegate' && evalDecision.queries.length > 0) {
761
+ this.options.panelState.progress.expected +=
762
+ evalDecision.queries.length * DeepResearchOrchestrator.UNITS_PER_RESEARCHER
763
+ + DeepResearchOrchestrator.LEAD_EVAL_UNITS;
764
+ }
765
+ }
766
+
767
+ completeSlice(this.options.panelState, coordId);
768
+ this.options.onUpdate();
769
+
770
+ // CRITICAL: Must trigger next step after evaluator completes
771
+ // The evaluator does NOT run through executeSibling(), so there's no
772
+ // finally block to call startRound(). We must call it explicitly.
773
+
774
+ // Give state a moment to propagate the update
775
+ await new Promise(resolve => setTimeout(resolve, STATE_PROPAGATION_DELAY_MS));
776
+
777
+ if (evalDecision.action === 'synthesize') {
778
+ logger.log('[deep-research] Evaluator chose SYNTHESIS - resolving research');
779
+ if (evalDecision.content) {
780
+ this.resolveResult(evalDecision.content);
781
+ } else {
782
+ logger.error('[deep-research] Evaluator synthesis was empty - falling back to historical findings');
783
+ this.resolveResult(this.buildAllFindingsContext());
784
+ }
785
+ } else if (isAtHardLimit) {
786
+ // Evaluator returned delegation despite the mandatory synthesis instruction.
787
+ // Enforce completion by falling back to concatenated researcher findings.
788
+ logger.warn('[deep-research] Evaluator returned delegation at hard limit — forcing synthesis from findings');
789
+ this.resolveResult(this.buildAllFindingsContext());
790
+ } else {
791
+ // Evaluator delegated more researchers - start next round
792
+ logger.log(`[deep-research] Evaluator chose DELEGATION - starting Round ${evaluatedRound + 1}`);
793
+ await this.startRound(signal);
794
+ }
795
+
796
+ } catch (err) {
797
+ const error = err instanceof Error ? err : new Error(String(err));
798
+ logger.error(`[deep-research] Coordinator evaluation failed for round ${evaluatedRound}:`, error);
799
+ this.rejectCompletion(error);
800
+ }
801
+ }
802
+
803
+ private buildRoleContext(aspect: ResearchSibling, roleContext: ReturnType<typeof getResearcherRoleContext>): string {
804
+ const { displayNumber, roundNumber, totalInRound } = roleContext;
805
+ const siblingNumInRound = parseInt(aspect.id.split('.')[1] ?? '1');
806
+ return `## Your Role
807
+
808
+ You are **Researcher ${displayNumber}** (${siblingNumInRound} of ${totalInRound} in Round ${roundNumber}).
809
+ - **Topic**: ${aspect.query}
810
+ - **Round**: ${roundNumber} of ${this.getMaxRounds(this.state.complexity)}
811
+ - **Tool Usage**: 4 gathering calls + up to 3 context-gated scrape batches (Batch 1 ≤3 URLs, Batch 2 ≤2 URLs, Batch 3 ≤3 URLs)`;
812
+ }
813
+
814
+ private buildAllFindingsContext(): string {
815
+ const allCompleted = Object.values(this.state.aspects)
816
+ .filter(a => a.status === 'completed' && a.report)
817
+ .sort((a, b) => {
818
+ const aRound = parseInt(a.id.split('.')[0] ?? '0');
819
+ const bRound = parseInt(b.id.split('.')[0] ?? '0');
820
+ if (aRound !== bRound) return aRound - bRound;
821
+ const aNum = parseInt(a.id.split('.')[1] ?? '0');
822
+ const bNum = parseInt(b.id.split('.')[1] ?? '0');
823
+ return aNum - bNum;
824
+ });
825
+
826
+ if (allCompleted.length === 0) {
827
+ return '## Research Findings So Far\n\n*No earlier researchers have completed their findings yet.*';
828
+ }
829
+
830
+ let output = '## Research Findings So Far\n\n';
831
+ let currentRoundNum = 0;
832
+
833
+ for (const completed of allCompleted) {
834
+ const round = parseInt(completed.id.split('.')[0] ?? '0');
835
+ if (round !== currentRoundNum) {
836
+ currentRoundNum = round;
837
+ output += `\n### Round ${round}\n\n`;
838
+ }
839
+ const displayNum = getDisplayNumber(this.state, completed.id);
840
+ const report = completed.report || '';
841
+ const truncatedReport = truncatePreservingLinks(report, MAX_EVALUATOR_REPORT_LENGTH);
842
+
843
+ output += `#### Researcher ${displayNum}: ${completed.query}\n\n${truncatedReport}\n\n`;
844
+ }
845
+
846
+ return output;
847
+ }
848
+ }