@lincoln504/pi-research 0.1.12 → 1.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 (248) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +64 -115
  3. package/dist/cli.mjs +23211 -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 +102 -0
  13. package/docs/ARCHITECTURE.md +306 -80
  14. package/docs/CONFIGURATION.md +249 -0
  15. package/docs/KNOWLEDGE-STORE.md +213 -0
  16. package/docs/PI-EXTENSION.md +91 -0
  17. package/docs/SDK.md +113 -0
  18. package/docs/THIRD-PARTY-NOTICES.md +64 -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 +1044 -0
  30. package/src/config.ts +1036 -147
  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 +264 -7
  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 -174
  231. package/src/infrastructure/searxng-lifecycle.ts +0 -577
  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,394 @@
1
+ /**
2
+ * Researcher Executor
3
+ *
4
+ * Handles execution of individual researchers
5
+ */
6
+
7
+ import type { ResearchMessage } from '../types/index.ts';
8
+ import type { SystemResearchState } from './deep-research-types.ts';
9
+ import { createResearcherSession } from './researcher.ts';
10
+ import { registerScrapedLinks } from '../utils/shared-links.ts';
11
+ import { ensureAssistantResponse } from '../utils/text-utils.ts';
12
+ import { extractUsage } from '../types/llm.ts';
13
+ import { logger } from '../logger.ts';
14
+ import { metrics } from '../utils/metrics.ts';
15
+ import { ServiceNames,
16
+ type IResearchSynthesisService,
17
+ } from '../core/service-interfaces.ts';
18
+ import { getService, tryGetServiceContainerFromCtx } from '../core/service-registry.ts';
19
+ import type { ResearchSessionService } from './research-session-service.ts';
20
+ import { loadPrompt } from '../core/llm/prompts.ts';
21
+ import { injectCurrentDate } from '../core/llm/inject-date.ts';
22
+ import { recordResearcherFailure, getSteeringMessages } from './session-state.ts';
23
+ import type { RunResearcherOptions } from './orchestration-types.ts';
24
+
25
+ /**
26
+ * Run a single researcher with retries
27
+ */
28
+ export async function runResearcher(options: RunResearcherOptions): Promise<void> {
29
+ const {
30
+ config: researcherConfig,
31
+ initialLinks,
32
+ historicalUrls,
33
+ researchId,
34
+ round,
35
+ query,
36
+ complexity,
37
+ ctx,
38
+ model,
39
+ researchConfig: config,
40
+ planningService,
41
+ observer,
42
+ signal,
43
+ sessionId,
44
+ } = options;
45
+
46
+ const id = String(researcherConfig.id);
47
+ const container = tryGetServiceContainerFromCtx(ctx);
48
+ observer?.onResearcherStart?.(id, researcherConfig.name, researcherConfig.goal, round);
49
+ metrics.increment('researchers_launched_total', 1, { mode: 'deep', complexity: String(complexity), round: String(round) });
50
+
51
+ const currentPlan = planningService.getCurrentPlan(researchId);
52
+ const previousQueriesSection = currentPlan?.allQueries && currentPlan.allQueries.length > 0
53
+ ? `\n### Previous Queries (Sibling Researchers)\n${currentPlan.allQueries.map((q: string) => `- ${q}`).join('\n')}\n`
54
+ : '';
55
+
56
+ let storeSection = '';
57
+ if (historicalUrls.length > 0) {
58
+ storeSection = '\n## Knowledge Store\n' +
59
+ 'The following URLs were found in the knowledge store from previous research sessions. ' +
60
+ 'Scrape each URL to retrieve its full current content. The summary below describes what was previously found at that URL — use it as a guide for what to expect.\n' +
61
+ historicalUrls.map(e => `- ${e.url}\n Previous summary: ${e.description}`).join('\n');
62
+ }
63
+
64
+ const researcherPromptTemplate = loadPrompt('researcher');
65
+ if (initialLinks.length === 0 && historicalUrls.length === 0) {
66
+ logger.warn(`[ResearcherExecutor] Researcher ${id} has no initial search results or historical links; skipping.`);
67
+ // Use the resolved sessionId (= piSessionId), NOT the raw ctx.sessionId: when
68
+ // ctx.sessionId is unset but sessionManager supplies a real id (common in SDK
69
+ // use), recording under ctx.sessionId would file this no-links skip under a
70
+ // different id than shouldStopResearch() checks, so the fast-stop guard would
71
+ // under-count exactly the broad-search-failure case it exists to catch.
72
+ recordResearcherFailure(sessionId, researchId, id);
73
+ metrics.increment('researcher_skipped_total', 1, { mode: 'deep', complexity: String(complexity), reason: 'no_initial_links' });
74
+ observer?.onResearcherFailure?.(id, 'No initial search results or historical links available');
75
+ return;
76
+ }
77
+
78
+ let evidenceSection = '';
79
+ if (initialLinks.length > 0) {
80
+ evidenceSection = `## Evidence Provided\nInitial search results provided the following URLs to investigate:\n${initialLinks.map(l => `- ${l}`).join('\n')}`;
81
+ }
82
+
83
+ const maxAttempts = config.RESEARCHER_MAX_RETRIES + 1;
84
+ let lastError: unknown;
85
+ const researcherExecutionStartMs = Date.now();
86
+ const deliveredSteeringIds = new Set<string>();
87
+
88
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
89
+ if (attempt > 1) {
90
+ const delay = Math.min(1000 * Math.pow(2, attempt - 2), config.RESEARCHER_MAX_RETRY_DELAY_MS);
91
+ logger.warn(`[ResearcherExecutor] Researcher ${id} retry ${attempt - 1}/${config.RESEARCHER_MAX_RETRIES} after ${delay}ms`);
92
+ observer?.onResearcherProgress?.(id, 'retry');
93
+ await new Promise(r => setTimeout(r, delay));
94
+ }
95
+
96
+ // Generate prompt for this attempt, incorporating ALL steering messages delivered so far
97
+ const allSteering = getSteeringMessages(sessionId);
98
+ let steeringSection = '';
99
+ if (allSteering.length > 0) {
100
+ steeringSection = '\n\n### ADDITIONAL USER GUIDANCE (Mandatory directional rules for your research)\n' +
101
+ allSteering.map(m => {
102
+ deliveredSteeringIds.add(m.id);
103
+ return `- ${m.text}`;
104
+ }).join('\n');
105
+ }
106
+
107
+ const prompt = injectCurrentDate(researcherPromptTemplate, 'researcher')
108
+ // Function replacers so a `$`-bearing dynamic value (scraped evidence, store
109
+ // descriptions, or an LLM-authored goal containing $&, $$, $1, …) is inserted
110
+ // literally instead of being interpreted as a String.replace substitution pattern.
111
+ .replace('{{goal}}', () => researcherConfig.goal)
112
+ .replace('{{store_section}}', () => storeSection)
113
+ .replace('{{evidence_section}}', () => evidenceSection)
114
+ .replace('{{coordination_section}}', () => previousQueriesSection)
115
+ .replace('{{extra_tool_guidelines}}', '')
116
+ .trim() + steeringSection;
117
+
118
+ logger.debug(`[ResearcherExecutor] Researcher ${id} attempt ${attempt} System Prompt:\n${prompt}`);
119
+
120
+ const workerExclude = ['search'];
121
+ const mergedExclude = [...new Set([...workerExclude, ...(options.excludeTools || [])])];
122
+
123
+ // Grounding gate: when the scrape tool is enabled for this researcher, a report grounded in
124
+ // NO real source (and no knowledge-store summaries to fall back on) is ungrounded.
125
+ // A researcher is grounded when it retrieved real content from any content-retrieval tool:
126
+ // - successfulScrapeCount: successes the scrape tool reports (fresh fetches AND cache hits)
127
+ // via the onUrlScrapeResult callback below. youtube_transcript routes its per-video
128
+ // successes through the SAME callback, so transcript fetches count here too.
129
+ // - nonScrapeGroundingHits: real items returned by the non-URL grounding tools
130
+ // (security_search, stackexchange), reported uniformly as details.groundingHits on the
131
+ // tool_execution_end event. (grep is local code search, not a web-research source, and
132
+ // wraps an opaque SDK result, so it is intentionally NOT a grounding signal.)
133
+ // - historicalUrls: knowledge-store summaries supplied to this researcher.
134
+ // Per-attempt: each retry builds a fresh session, so both counts reset at the top of the body.
135
+ //
136
+ // The gate is a PRODUCTION guard against ungrounded real research. It does not apply when
137
+ // scraping is mocked: PI_RESEARCH_MOCK_SCRAPE (a TEST-ONLY mode, not a product feature)
138
+ // serves a tiny stub that never passes content validation, so there are zero real scrapes
139
+ // by design — applying the gate there would fail every researcher even with a capable model
140
+ // (which is exactly what made mocked demo/test runs look like "the model can't research").
141
+ const scrapeEnabled = !mergedExclude.includes('scrape') && process.env['PI_RESEARCH_MOCK_SCRAPE'] !== 'true';
142
+ let successfulScrapeCount = 0;
143
+ let nonScrapeGroundingHits = 0;
144
+
145
+ const { session, resolvedModel } = await createResearcherSession({
146
+ cwd: ctx.cwd,
147
+ ctxModel: model,
148
+ modelRegistry: ctx.modelRegistry,
149
+ systemPrompt: prompt,
150
+ extensionCtx: ctx,
151
+ excludeTools: mergedExclude,
152
+ researcherId: id,
153
+ config,
154
+ getGlobalState: (): SystemResearchState => ({
155
+ version: 1,
156
+ researchId,
157
+ rootQuery: query,
158
+ complexity,
159
+ currentRound: round,
160
+ status: 'researching',
161
+ lastUpdated: Date.now(),
162
+ initialAgenda: [],
163
+ allScrapedLinks: [],
164
+ aspects: {},
165
+ }),
166
+ updateGlobalLinks: (links) => registerScrapedLinks(researchId, links),
167
+ onSearchProgress: (links) => {
168
+ observer?.onResearcherProgress?.(id, `${links} results`);
169
+ },
170
+ onUrlScrapeResult: (_url, success) => {
171
+ if (success) successfulScrapeCount++;
172
+ observer?.onToolResult?.(id, success);
173
+ },
174
+ });
175
+
176
+ // Hand the freshly-created AgentSession to the cleanup machinery before any
177
+ // other work. createResearcherSession() above already allocated a live
178
+ // session; if resolving/registering the session service throws (container
179
+ // disposed mid-run, race), the session is not yet owned by
180
+ // cleanupResearchServices and would leak unaborted. Abort it directly on any
181
+ // failure in this window, then rethrow. (Same acquire-before-teardown-wired
182
+ // class as the ghost-panel fix.)
183
+ // Undefined until the registration below succeeds: if getService throws, the
184
+ // finally must NOT dereference it (that TypeError would mask the real error).
185
+ let sessionService: ResearchSessionService | undefined;
186
+ try {
187
+ sessionService = await getService<ResearchSessionService>(ServiceNames.RESEARCH_SESSION_SERVICE, ctx, container);
188
+ sessionService.registerSession(researchId, id, session, () => session.abort().catch((err) => logger.warn('[ResearcherExecutor] Session abort failed:', err)));
189
+ } catch (registrationError) {
190
+ await session.abort().catch(() => { /* best-effort: session is being discarded */ });
191
+ throw registrationError;
192
+ }
193
+
194
+ let lastSteeringCheck = Date.now();
195
+
196
+ // User-supplied observer callbacks (SDK consumers) are invoked from inside
197
+ // session event dispatch — a throw there would propagate into the session's
198
+ // event loop. Isolate them the way TuiPulse isolates its subscribers.
199
+ const safeObserve = (fn: () => void): void => {
200
+ try { fn(); } catch (err) { logger.debug('[ResearcherExecutor] Observer callback threw:', err); }
201
+ };
202
+
203
+ const subscription = session.subscribe((event: any) => {
204
+ // Check for new steering messages periodically
205
+ const now = Date.now();
206
+ if (now - lastSteeringCheck > 500) {
207
+ lastSteeringCheck = now;
208
+ const allSteering = getSteeringMessages(sessionId);
209
+ for (const msg of allSteering) {
210
+ if (!deliveredSteeringIds.has(msg.id)) {
211
+ deliveredSteeringIds.add(msg.id);
212
+ session.steer(msg.text).catch(e => logger.warn('[ResearcherExecutor] Failed to deliver steering:', e));
213
+ logger.debug(`[ResearcherExecutor] Delivered mid-flight steering message to ${id}: ${msg.text}`);
214
+ }
215
+ }
216
+ }
217
+
218
+ if (event.type === 'message_update') {
219
+ const ame = event.assistantMessageEvent as any;
220
+ if (ame?.type === 'start') {
221
+ const inputTokens: number = ame.partial?.usage?.input ?? 0;
222
+ if (inputTokens > 0) {
223
+ safeObserve(() => observer?.onResearcherTokensHint?.(id, inputTokens));
224
+ }
225
+ }
226
+ } else if (event.type === 'message_end') {
227
+ const msg = event.message as unknown as ResearchMessage;
228
+ if (msg?.['role'] !== 'assistant') return;
229
+
230
+ const rawUsage = msg['usage'] as any;
231
+ if (rawUsage) {
232
+ const { tokens, cost } = extractUsage(resolvedModel, rawUsage);
233
+
234
+ if (tokens > 0 || cost > 0) {
235
+ metrics.increment('llm_tokens_total', tokens, { component: 'researcher', complexity: String(complexity) });
236
+ metrics.increment('llm_cost_total', cost, { component: 'researcher', complexity: String(complexity) });
237
+ safeObserve(() => observer?.onResearcherProgress?.(id, undefined, tokens, cost));
238
+ safeObserve(() => observer?.onTokensConsumed?.(tokens, cost));
239
+ }
240
+ }
241
+ } else if (event.type === 'tool_execution_start') {
242
+ safeObserve(() => observer?.onResearcherProgress?.(id, `${event.toolName}`));
243
+ } else if (event.type === 'tool_execution_end') {
244
+ safeObserve(() => observer?.onResearcherProgress?.(id, `done:${event.toolName}`));
245
+ // Per-tool flash for non-scrape tools (scrape uses per-URL callback instead)
246
+ if (event.toolName !== 'scrape') {
247
+ safeObserve(() => observer?.onToolResult?.(id, !event.isError));
248
+ }
249
+ // Grounding accumulation for the non-URL grounding tools. They don't go through
250
+ // onUrlScrapeResult, so they report how many real items they retrieved via a uniform
251
+ // details.groundingHits field (e.g. security_search = vulnerabilities found,
252
+ // stackexchange = questions/answers returned). Soft-failures (rate-limit, API error,
253
+ // empty result) omit it or report 0, so they correctly do NOT count as grounding.
254
+ const details = (event.result as { details?: { groundingHits?: unknown } } | undefined)?.details;
255
+ const hits = details?.groundingHits;
256
+ if (typeof hits === 'number' && hits > 0) {
257
+ nonScrapeGroundingHits += hits;
258
+ }
259
+ }
260
+ });
261
+
262
+ try {
263
+ let timeoutId: NodeJS.Timeout | undefined;
264
+ const timeoutPromise = new Promise<void>((_, reject) => {
265
+ timeoutId = setTimeout(() => {
266
+ const msg = `Researcher ${id} (${researcherConfig.name}) timed out after ${config.RESEARCHER_TIMEOUT_MS}ms`;
267
+ session.abort().catch((err) => {
268
+ logger.warn('[ResearcherExecutor] Failed to abort timed-out researcher session:', err);
269
+ }).finally(() => reject(new Error(msg)));
270
+ }, config.RESEARCHER_TIMEOUT_MS);
271
+ });
272
+
273
+ let abortCleanup: (() => void) | undefined;
274
+ try {
275
+ const promptPromise = session.prompt(`Topic: ${researcherConfig.name}\nGoal: ${researcherConfig.goal}\n\nPerform your research and submit your full report now.`);
276
+ promptPromise.catch((err: Error) => logger.debug(`[ResearcherExecutor] Background session prompt rejection: ${err.message}`));
277
+ await Promise.race([
278
+ promptPromise,
279
+ timeoutPromise,
280
+ ...(signal ? [
281
+ new Promise<never>((_, reject) => {
282
+ const onAbort = () => {
283
+ session.abort().catch(err => logger.warn('[ResearcherExecutor] Failed to abort session on signal:', err));
284
+ reject(new Error('Aborted'));
285
+ };
286
+ if (signal.aborted) {
287
+ onAbort();
288
+ } else {
289
+ signal.addEventListener('abort', onAbort, { once: true });
290
+ abortCleanup = () => signal.removeEventListener('abort', onAbort);
291
+ }
292
+ })
293
+ ] : []),
294
+ ]);
295
+ } finally {
296
+ clearTimeout(timeoutId);
297
+ abortCleanup?.();
298
+ }
299
+
300
+ const responseText = ensureAssistantResponse(session, id);
301
+ const researcherDuration = Date.now() - researcherExecutionStartMs;
302
+ metrics.observe('researcher_execution_latency_ms', researcherDuration, { mode: 'deep', complexity: String(complexity), round: String(round) });
303
+ logger.debug(`[ResearcherExecutor] Researcher ${id} Final Response:\n${responseText}`);
304
+
305
+ // Grounding gate (see scrapeEnabled / successfulScrapeCount above). A deep researcher
306
+ // cannot search (workerExclude = ['search']); scrape is its only path to real sources.
307
+ // If scrape was available but produced zero successful fetches AND no knowledge-store
308
+ // summaries were supplied (historicalUrls), the report is not grounded in any source —
309
+ // do NOT land it. Retry first (a transiently blocked scrape may recover); on the final
310
+ // attempt this falls through to `throw lastError`, which runResearchers catches and
311
+ // records as a researcher failure (same contract as retry-exhaustion).
312
+ if (scrapeEnabled && successfulScrapeCount === 0 && nonScrapeGroundingHits === 0 && historicalUrls.length === 0) {
313
+ lastError = new Error(`Researcher ${id} produced an ungrounded report: scrape tool enabled but zero successful scrapes and no other grounding`);
314
+ metrics.increment('researcher_ungrounded_total', 1, { mode: 'deep', complexity: String(complexity), round: String(round) });
315
+ // Do NOT retry an ungrounded report when the run is being torn down (quit/SIGTERM sets
316
+ // container.isDisposing without necessarily aborting the signal). A retry rebuilds the
317
+ // session and relaunches a search/scrape burst into a WorkerPool that dispose() is
318
+ // destroying — the same "Cannot execute a task on destroying pool" storm the catch-block
319
+ // guard prevents. This branch exits via `continue`, so it needs the guard too.
320
+ if (signal?.aborted || container?.isDisposing) {
321
+ logger.debug(`[ResearcherExecutor] Researcher ${id} ungrounded but run aborting/disposing; skipping retries.`);
322
+ break;
323
+ }
324
+ logger.warn(`[ResearcherExecutor] Researcher ${id} attempt ${attempt}/${maxAttempts} ungrounded (scrape enabled, 0 successful scrapes, 0 security/stackexchange grounding hits, no knowledge-store grounding); ${attempt < maxAttempts ? 'retrying' : 'failing'}`);
325
+ continue;
326
+ }
327
+
328
+ const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
329
+ synthesisService.storeReport(researchId, `${round}.${id}`, responseText);
330
+
331
+ observer?.onResearcherComplete?.(id, responseText);
332
+ return;
333
+ } catch (err) {
334
+ const researcherDuration = Date.now() - researcherExecutionStartMs;
335
+ metrics.observe('researcher_execution_latency_ms', researcherDuration, { mode: 'deep', complexity: String(complexity), round: String(round), status: 'error' });
336
+ metrics.increment('researcher_errors_total', 1, { mode: 'deep', complexity: String(complexity), round: String(round) });
337
+ lastError = err;
338
+ const errMsg = err instanceof Error ? err.message : String(err);
339
+
340
+ // Attempt to salvage partial content on timeout or error
341
+ try {
342
+ const partialResponse = ensureAssistantResponse(session, id);
343
+ // Apply the same grounding gate to salvaged partials: a timed-out/errored researcher
344
+ // with zero successful scrapes, zero non-scrape grounding hits, and no knowledge-store
345
+ // grounding has nothing real to land, so skip the salvage store and fall through to the
346
+ // retry/abort handling below.
347
+ const ungrounded = scrapeEnabled && successfulScrapeCount === 0 && nonScrapeGroundingHits === 0 && historicalUrls.length === 0;
348
+ if (partialResponse && partialResponse.trim().length > 50 && !ungrounded) {
349
+ const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
350
+ synthesisService.storeReport(researchId, `${round}.${id}`, partialResponse + '\n\n---\n*WARNING: This report was truncated due to a timeout/error. Content may be incomplete.*');
351
+ logger.log(`[ResearcherExecutor] Researcher ${id} salvaged partial content (${partialResponse.length} chars) after error: ${errMsg}`);
352
+ observer?.onResearcherComplete?.(id, partialResponse);
353
+ return;
354
+ }
355
+ } catch (salvageErr) {
356
+ logger.debug(`[ResearcherExecutor] Researcher ${id} salvage attempt failed:`, salvageErr);
357
+ }
358
+
359
+ // Skip retries when the run is being torn down: an aborted signal, an explicit
360
+ // "Aborted" error, or the service container entering disposal (SIGTERM/quit
361
+ // mid-run, which throws "…during container disposal" from getService). Retrying
362
+ // during disposal is futile — services are being destroyed — and it relaunches
363
+ // search bursts into a WorkerPool that dispose() is simultaneously destroying,
364
+ // producing a storm of "Cannot execute a task on destroying pool" errors.
365
+ if (signal?.aborted || errMsg === 'Aborted' || errMsg.includes('during container disposal') || container?.isDisposing) {
366
+ logger.debug(`[ResearcherExecutor] Researcher ${id} aborted or container disposing, skipping retries.`);
367
+ break; // Break out of the attempt loop
368
+ }
369
+
370
+ if (attempt < maxAttempts) {
371
+ logger.warn(`[ResearcherExecutor] Researcher ${id} attempt ${attempt} failed: ${errMsg}; will retry`);
372
+ } else {
373
+ logger.error(`[ResearcherExecutor] Researcher ${id} failed all ${maxAttempts} attempts: ${errMsg}`);
374
+ metrics.increment('researcher_retries_exhausted_total', 1, { mode: 'deep', complexity: String(complexity) });
375
+ }
376
+ } finally {
377
+ subscription();
378
+ await session.abort().catch((err) => {
379
+ logger.warn(`[ResearcherExecutor] Failed to abort researcher session ${id}:`, err);
380
+ });
381
+ sessionService?.unregisterSession(researchId, id);
382
+
383
+ // Restore the default thinking label now that the researcher is done.
384
+ // Otherwise "Researcher X" persists for all subsequent agent turns.
385
+ // Symmetric with where the label is set (researcher.ts): a no-op in the
386
+ // research TUI, where the label is never applied in the first place.
387
+ if (ctx.mode !== 'tui' && ctx.hasUI && typeof ctx.ui.setHiddenThinkingLabel === 'function') {
388
+ ctx.ui.setHiddenThinkingLabel();
389
+ }
390
+ }
391
+ }
392
+
393
+ throw lastError;
394
+ }
@@ -3,102 +3,175 @@
3
3
  *
4
4
  * Responsible for instantiating individual researcher agent sessions.
5
5
  * Each researcher is an autonomous agent equipped with a suite of specialized tools:
6
- * - Web search via SearXNG
6
+ * - Web search via DuckDuckGo Lite
7
7
  * - Context-aware URL scraping via Playwright
8
8
  * - Security vulnerability database queries (NVD, CISA, OSV)
9
9
  * - Technical Q&A retrieval from Stack Exchange
10
10
  * - Local code search via Ripgrep
11
+ * - Knowledge store search for historical data
11
12
  */
12
13
 
13
- import type { AgentSession, ModelRegistry, SettingsManager, ExtensionContext } from '@mariozechner/pi-coding-agent';
14
- import { createAgentSession, createReadTool, SessionManager } from '@mariozechner/pi-coding-agent';
15
- import type { Model } from '@mariozechner/pi-ai';
14
+ import type { AgentSession, ModelRegistry, ExtensionContext } from '@earendil-works/pi-coding-agent';
15
+ import { createAgentSession, SessionManager, SettingsManager as SettingsManagerClass } from '@earendil-works/pi-coding-agent';
16
+ import type { Model } from '@earendil-works/pi-ai';
16
17
  import { createResearchTools } from '../tools/index.ts';
17
18
  import { makeResourceLoader } from '../utils/make-resource-loader.ts';
18
19
  import { ToolUsageTracker, createDefaultToolLimits } from '../utils/tool-usage-tracker.ts';
19
20
  import type { SystemResearchState } from './deep-research-types.ts';
21
+ import type { Config } from '../config.ts';
22
+ import { logger } from '../logger.ts';
23
+
24
+ import { resolveResearchModel } from '../core/llm/research-model-resolver.ts';
20
25
 
21
26
  export interface CreateResearcherSessionOptions {
22
27
  cwd: string;
23
28
  ctxModel: Model<any> | undefined;
24
29
  modelRegistry: ModelRegistry;
25
- settingsManager: SettingsManager;
26
30
  systemPrompt: string;
27
- searxngUrl: string;
28
31
  extensionCtx: ExtensionContext;
29
32
  // Optional: real closures for global state management
30
33
  getGlobalState?: () => SystemResearchState;
31
34
  updateGlobalLinks?: (links: string[]) => void;
32
- /** Returns tokens consumed by this researcher session so far (for context-aware scrape gating). */
33
- getTokensUsed?: () => number;
34
- /** Model context window size in tokens. */
35
- contextWindowSize?: number;
35
+ /** Optional researcher ID for per-researcher scrape tracking. */
36
+ researcherId?: string;
37
+ /** Callback invoked during search with cumulative link count found so far */
38
+ onSearchProgress?: (links: number) => void;
39
+ /** Fires for each individual URL scrape result (per-URL TUI flash). */
40
+ onUrlScrapeResult?: (url: string, success: boolean) => void;
41
+ /** List of tool names to disable for this researcher. */
42
+ excludeTools?: string[];
43
+ config?: Config;
44
+ }
45
+
46
+ export interface ResolvedResearcherSession {
47
+ session: AgentSession;
48
+ /** The model actually used for this session (after RESEARCH_MODEL resolution). */
49
+ resolvedModel: Model<any>;
36
50
  }
37
51
 
38
- export async function createResearcherSession(options: CreateResearcherSessionOptions): Promise<AgentSession> {
52
+ export async function createResearcherSession(options: CreateResearcherSessionOptions): Promise<ResolvedResearcherSession> {
39
53
  const {
40
54
  cwd,
41
55
  ctxModel,
42
56
  modelRegistry,
43
- settingsManager,
44
57
  systemPrompt,
45
- searxngUrl,
46
58
  extensionCtx,
47
59
  getGlobalState,
48
60
  updateGlobalLinks,
49
- getTokensUsed,
50
- contextWindowSize,
61
+ researcherId,
62
+ onSearchProgress,
63
+ onUrlScrapeResult,
64
+ excludeTools = [],
65
+ config,
51
66
  } = options;
52
67
 
53
- // Validate required parameters
54
- if (!ctxModel) {
55
- throw new Error('No model selected. Please select a model before using the research tool.');
56
- }
57
-
58
68
  if (!systemPrompt || typeof systemPrompt !== 'string') {
59
69
  throw new Error('Invalid system prompt: must be a non-empty string');
60
70
  }
61
71
 
62
- if (!searxngUrl || typeof searxngUrl !== 'string') {
63
- throw new Error('Invalid SearXNG URL: must be a non-empty string');
64
- }
72
+ // Create a mutable reference to the session so the tools can access it
73
+ // before the createAgentSession call returns.
74
+ const sessionRef: { session: AgentSession | null } = { session: null };
65
75
 
66
- // Create tool usage tracker for this researcher
67
- const tracker = new ToolUsageTracker(createDefaultToolLimits());
76
+ // Resolve model using centralized priority logic
77
+ const modelToUse = resolveResearchModel({
78
+ modelRegistry,
79
+ config,
80
+ hostModel: ctxModel,
81
+ cwd,
82
+ });
68
83
 
69
- // Use provided closures or fallback to safe dummies
70
- const globalState = getGlobalState || (() => ({} as any));
71
- const globalLinks = updateGlobalLinks || (() => {});
84
+ logger.info(`[Researcher] Using model for researcher ${researcherId}: ${modelToUse.provider}/${modelToUse.id}`);
72
85
 
73
86
  try {
87
+ const tracker = new ToolUsageTracker(createDefaultToolLimits(config));
88
+
89
+ // Use provided closures or fallback to safe dummies
90
+ const globalLinks = updateGlobalLinks || (() => {});
91
+
92
+ const customTools = createResearchTools({
93
+ cwd,
94
+ ctx: extensionCtx,
95
+ tracker,
96
+ getGlobalState,
97
+ updateGlobalLinks: globalLinks,
98
+ researcherId,
99
+ onSearchProgress: onSearchProgress,
100
+ onUrlScrapeResult: onUrlScrapeResult,
101
+ getTokensUsed: () => {
102
+ if (!sessionRef.session) return 0;
103
+
104
+ // Prefer getContextUsage as it accounts for the full context window history
105
+ if (typeof (sessionRef.session as any).getContextUsage === 'function') {
106
+ const usage = (sessionRef.session as any).getContextUsage();
107
+ if (usage && typeof usage.tokens === 'number') return usage.tokens;
108
+ }
109
+
110
+ // Fallback to cumulative session usage
111
+ if (typeof (sessionRef.session as any).getUsage === 'function') {
112
+ const usage = (sessionRef.session as any).getUsage();
113
+ return (usage.input || 0) + (usage.output || 0);
114
+ }
115
+
116
+ return 0;
117
+ },
118
+ contextWindowSize: modelToUse.contextWindow,
119
+ config,
120
+ });
121
+
122
+ // Explicit allowlist of custom tools minus dangerous built-ins.
123
+ const defaultExclude = ['bash', 'write', 'edit', 'repl', 'git', 'terminal'];
124
+ const mergedExclude = [...new Set([...defaultExclude, ...excludeTools])];
125
+
126
+ const tools = customTools.map(t => t.name).filter(name => !mergedExclude.includes(name));
127
+
128
+ // Build a researcher-scoped settings manager with provider retries restored to 2.
129
+ // v0.76.0 changed the SDK default to 0; transient network errors during long researcher
130
+ // runs would otherwise fail the entire researcher rather than retrying the LLM call.
131
+ const researcherSettings = SettingsManagerClass.inMemory({ retry: { provider: { maxRetries: 2 } } });
132
+
74
133
  const result = await createAgentSession({
75
134
  cwd,
76
- tools: [createReadTool(cwd)],
77
- customTools: createResearchTools({
78
- searxngUrl,
79
- ctx: extensionCtx,
80
- tracker,
81
- getGlobalState: globalState,
82
- updateGlobalLinks: globalLinks,
83
- getTokensUsed,
84
- contextWindowSize,
85
- }),
135
+ customTools,
136
+ tools, // Explicit allowlist (BUG-1 fix)
86
137
  sessionManager: SessionManager.inMemory(), // Each researcher gets its own isolated session
87
- settingsManager,
88
- model: ctxModel,
138
+ settingsManager: researcherSettings,
139
+ model: modelToUse,
89
140
  modelRegistry,
90
141
  resourceLoader: makeResourceLoader(systemPrompt),
142
+ // Thinking/reasoning is disabled for researcher agents: they do retrieval and
143
+ // synthesis from scraped pages, not open-ended reasoning. Keeping it off reduces
144
+ // latency and token cost for at most a marginal quality gain in this use case.
145
+ // Honors the system-wide PI_RESEARCH_LLM_THINKING_LEVEL override (default 'off').
146
+ // pi-coding-agent clamps the level to whatever the chosen model supports.
147
+ thinkingLevel: config?.LLM_THINKING_LEVEL ?? 'off',
91
148
  });
92
149
 
150
+ // Customize the agent thinking label for researchers (e.g. "Researcher 1.1")
151
+ // so non-TUI surfaces can tell concurrent researchers apart. Skip this in the
152
+ // research TUI (mode === 'tui'): the panel already shows every researcher, and
153
+ // the host renders this label as dim/italic thinking text ABOVE the panel —
154
+ // stray output the panel was meant to replace. Use the researcherId passed by
155
+ // the caller (not regex on the prompt, since the ID is never templated in).
156
+ if (extensionCtx.mode !== 'tui' && extensionCtx.hasUI && typeof extensionCtx.ui.setHiddenThinkingLabel === 'function') {
157
+ extensionCtx.ui.setHiddenThinkingLabel(
158
+ researcherId ? `Researcher ${researcherId}` : undefined
159
+ );
160
+ }
161
+
162
+ logger.log(`[Researcher] Created session with model=${modelToUse?.id || 'unknown'}`);
163
+
93
164
  if (!result || !result.session) {
94
165
  throw new Error('Session creation returned invalid result');
95
166
  }
96
167
 
97
- return result.session;
168
+ sessionRef.session = result.session;
169
+
170
+ return { session: result.session, resolvedModel: modelToUse };
98
171
  } catch (error) {
99
172
  const errorMsg = error instanceof Error ? error.message : String(error);
100
173
  throw new Error(`Failed to create researcher session: ${errorMsg}`, {
101
174
  cause: error,
102
175
  });
103
176
  }
104
- }
177
+ }