@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,77 @@
1
+ /**
2
+ * Research Knowledge Search Types
3
+ *
4
+ * Strict contracts for the research_knowledge_search tool's internal data flow.
5
+ * Phase 1 of the architecture: rigid schema definitions that the
6
+ * background LLM MUST conform to, enforced via TypeBox validation
7
+ * and agentic repair.
8
+ */
9
+
10
+ import { Type, type Static, type TSchema } from 'typebox';
11
+
12
+ /**
13
+ * Tri-state answer confidence.
14
+ *
15
+ * - `"yes"` — The documents contain a substantive, directly useful answer.
16
+ * - `"maybe"` — The documents contain partial or tangential information
17
+ * that *might* be helpful but is NOT sufficient for a complete answer.
18
+ * The host agent should still consider live research to fill gaps.
19
+ * - `"no"` — No relevant information found in the knowledge store.
20
+ */
21
+ export const AnswerStatusEnum = Type.Union([
22
+ Type.Literal('yes'),
23
+ Type.Literal('maybe'),
24
+ Type.Literal('no'),
25
+ ]);
26
+
27
+ /**
28
+ * The internal schema the background LLM MUST output.
29
+ *
30
+ * - `answer_status`: Tri-state enum indicating answer confidence.
31
+ * - `"yes"`: Documents contain a substantive answer.
32
+ * - `"maybe"`: Documents contain partial/tangential info — useful but
33
+ * insufficient. The synthesis (if present) summarizes what's available.
34
+ * - `"no"`: No relevant information found.
35
+ * - `synthesis`: Optional synthesized answer with inline citation markers
36
+ * [1], [2], etc. Present when answer_status is "yes" or "maybe".
37
+ * - `citations`: Array of source URLs used to construct the answer.
38
+ */
39
+ export const ResearchKnowledgeSynthesisResponseSchema = Type.Object({
40
+ answer_status: AnswerStatusEnum,
41
+ synthesis: Type.Optional(Type.String({
42
+ description: 'Synthesized answer with inline citation markers [1], [2], etc.',
43
+ })),
44
+ citations: Type.Array(Type.String(), {
45
+ description: 'Source URLs used to construct the answer.',
46
+ }),
47
+ });
48
+
49
+ export type ResearchKnowledgeSynthesisResponse = Static<typeof ResearchKnowledgeSynthesisResponseSchema>;
50
+
51
+ /**
52
+ * The schema cast as TSchema for use with agentic-repair.
53
+ */
54
+ export const ResearchKnowledgeSynthesisResponseSchemaAsTSchema =
55
+ ResearchKnowledgeSynthesisResponseSchema as unknown as TSchema;
56
+
57
+ /**
58
+ * Cheap relevance-triage response.
59
+ *
60
+ * The triage LLM reads only the SHORT per-candidate descriptions (a few hundred
61
+ * chars each) — not the full rebuilt documents — and returns the 0-based indices
62
+ * of the candidates that are genuinely relevant to the user's question. An empty
63
+ * array means "nothing in the store is relevant" → the tool reports an instant
64
+ * miss WITHOUT rebuilding documents or running the full synthesis LLM.
65
+ *
66
+ * This replaces the earlier idea of an embedding-similarity threshold, which was
67
+ * shown to be neither model- nor store-size-robust (a large anisotropic vector
68
+ * space gives every query a spuriously-close nearest neighbour). Letting the LLM
69
+ * judge the descriptions is model-agnostic and adapts to any embedding model.
70
+ */
71
+ export const KnowledgeRelevanceTriageSchema = Type.Object({
72
+ relevant_indices: Type.Array(Type.Integer(), {
73
+ description: '0-based indices of the candidate sources that are relevant to the question. Empty if none.',
74
+ }),
75
+ });
76
+
77
+ export type KnowledgeRelevanceTriage = Static<typeof KnowledgeRelevanceTriageSchema>;
@@ -0,0 +1,494 @@
1
+ /**
2
+ * Research Tool Definition
3
+ *
4
+ * Defines the research tool that orchestrates web/internet research.
5
+ * Depth 1-3: AI-orchestrated multi-session research (coordinator → researchers → evaluator).
6
+ *
7
+ * Note: Depth 0 (quick mode) is only available via the SDK / CLI (`--depth 0`,
8
+ * which the agent skill can pass). The pi extension tool has minimum: 1.
9
+ */
10
+
11
+ import type {
12
+ ToolDefinition,
13
+ AgentToolResult,
14
+ ExtensionContext,
15
+ AgentToolUpdateCallback,
16
+ } from '@earendil-works/pi-coding-agent';
17
+ import type { ExtendedExtensionContext } from '../types/extension-context.ts';
18
+ import type { ResearchDepth, CleanupContext } from '../types/index.ts';
19
+ import { Type, type Static } from 'typebox';
20
+ import { validateConfig, getConfig, type ConfigInterface } from '../config.ts';
21
+ import { tryGetServiceContainerFromCtx, getService } from '../core/service-registry.ts';
22
+
23
+ import { ServiceNames } from '../core/service-interfaces.ts';
24
+ import type { IResearchOrchestration, IResearchSynthesisService } from '../core/service-interfaces.ts';
25
+ import { metrics, MetricsRegistry, runWithRunRegistry } from '../utils/metrics.ts';
26
+ import { createResearchRunId, logger, createLogger, isVerboseFromEnv, runWithLogger } from '../logger.ts';
27
+ import { exportResearchReport, appendExportMessage } from '../utils/research-export.ts';
28
+ import { validateAndSanitizeQuery } from '../utils/input-validation.ts';
29
+ import { validateInitialLinks, MAX_INITIAL_LINKS, MAX_INITIAL_LINK_CHARS } from '../utils/url-utils.ts';
30
+ import { startResearchSession, registerSessionAbort, clearSteeringMessages, getPiActivePanels } from '../orchestration/session-state.ts';
31
+ import { createResearchTuiManager, hideWorkingIndicator } from '../tui/research-tui-manager.ts';
32
+ import { createCleanupFunction } from '../cleanup/research-cleanup.ts';
33
+ import { createResearchObserver, createObserverState, stopObserverWaveAnimation } from '../observers/research-observer-impl.ts';
34
+ import { HeadlessObserver } from '../orchestration/headless-observer.ts';
35
+
36
+ import { ensureFunctionalHealth, createHealthMonitor } from '../tui/research-health.ts';
37
+ import { ErrorTracker, runWithTracker, type ErrorReport } from '../utils/error-tracker.ts';
38
+ import { extractRunStats, buildResearchSummary } from '../utils/metrics-summary.ts';
39
+
40
+ /**
41
+ * Append a unified research summary to the result.
42
+ * Replaces the old separate error + scrape summaries.
43
+ *
44
+ * Design: No tables, no percentages — just counts.
45
+ * Emphasizes volume of work done. Errors are a concise footnote when present.
46
+ */
47
+ function appendResearchSummary(
48
+ result: string,
49
+ metricsSnapshot: { counters?: Record<string, number>; histograms?: Record<string, any> },
50
+ errorReport: ErrorReport,
51
+ ): string {
52
+ const stats = extractRunStats(metricsSnapshot as any);
53
+
54
+ if (!stats) {
55
+ // The run produced no meaningful metrics (hard failure before any research ran).
56
+ // The error tracker is the only remaining signal, so surface its count here. This
57
+ // path involves no scrape fallback chains, so it is not subject to the per-URL
58
+ // over-counting that made the tracker unsuitable as the normal error count.
59
+ if (errorReport.totalErrors > 0) {
60
+ return result + `\n\n---\n\n*${errorReport.totalErrors} error${errorReport.totalErrors > 1 ? 's' : ''} encountered during research.*`;
61
+ }
62
+ return result;
63
+ }
64
+
65
+ // NOTE: stats.errors is intentionally left as extractRunStats computed it (genuine
66
+ // engine faults only). It is NOT overridden with errorReport.totalErrors: the
67
+ // tracker records ~5-6 entries per failed URL along the fetch→browser fallback
68
+ // chain, so using it here reported blocked/unavailable sources (already shown as
69
+ // "not scraped") as a large, inflated error count. Keeping the metric-derived count
70
+ // also makes the report footnote consistent with the SDK (getLastRunStats) and the
71
+ // session-metrics TUI, which both read extractRunStats().errors.
72
+
73
+ const summary = buildResearchSummary(stats);
74
+ if (!summary) {
75
+ if (errorReport.totalErrors > 0) {
76
+ return result + `\n\n---\n\n*${errorReport.totalErrors} error${errorReport.totalErrors > 1 ? 's' : ''} encountered during research.*`;
77
+ }
78
+ return result;
79
+ }
80
+
81
+ return result + `\n\n---\n\n${summary}`;
82
+ }
83
+
84
+ /**
85
+ * Create the research tool definition
86
+ */
87
+ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
88
+ const parameters = Type.Object({
89
+ query: Type.String({
90
+ description: 'The research topic or query to investigate.',
91
+ }),
92
+ depth: Type.Optional(Type.Integer({
93
+ minimum: 1,
94
+ maximum: 3,
95
+ description: [
96
+ 'Research depth (1-3).',
97
+ '1: Normal (coordinated, thorough).',
98
+ '2: Deep (multi-round, exhaustive).',
99
+ '3: Ultra (maximum depth, extreme rigor).',
100
+ ].join('\n'),
101
+ default: 1,
102
+ })),
103
+ model: Type.Optional(Type.String({
104
+ description: 'Optional model ID override for coordination.',
105
+ })),
106
+ excludeTools: Type.Optional(Type.Array(Type.String(), {
107
+ description: 'List of internal tools to disable (e.g., search, scrape, security_search).',
108
+ })),
109
+ // Bounded exactly like the CLI's --initial-links flag (validated again in
110
+ // execute via the shared validateInitialLinks — the schema alone is not
111
+ // enforced on every caller path).
112
+ initialLinks: Type.Optional(Type.Array(Type.String({ maxLength: MAX_INITIAL_LINK_CHARS }), {
113
+ maxItems: MAX_INITIAL_LINKS,
114
+ description: `Optional seed URLs to investigate before (or instead of) web search. http(s) only; at most ${MAX_INITIAL_LINKS} URLs of up to ${MAX_INITIAL_LINK_CHARS} characters each.`,
115
+ })),
116
+ });
117
+
118
+ type ResearchParams = Static<typeof parameters>;
119
+
120
+ return {
121
+ name: 'research',
122
+ label: 'Research',
123
+ description:
124
+ 'Perform multi-source web research using search, scraping, and specialized databases.',
125
+ promptSnippet: 'Execute comprehensive web research',
126
+ parameters,
127
+ renderShell: 'self',
128
+ executionMode: 'parallel',
129
+ prepareArguments: (args: unknown) => {
130
+ const rawArgs = args as Record<string, unknown>;
131
+ const normalized: Record<string, unknown> = {
132
+ query: rawArgs['query'] ?? '',
133
+ model: rawArgs['model'],
134
+ excludeTools: rawArgs['excludeTools'],
135
+ initialLinks: rawArgs['initialLinks'],
136
+ };
137
+
138
+ const rawDepth = rawArgs['depth'];
139
+ if (rawDepth !== undefined && rawDepth !== null) {
140
+ if (typeof rawDepth === 'string') {
141
+ const parsed = parseInt(rawDepth, 10);
142
+ normalized['depth'] = isNaN(parsed) ? 1 : Math.max(1, Math.min(3, parsed));
143
+ } else if (typeof rawDepth === 'number') {
144
+ normalized['depth'] = Math.max(1, Math.min(3, rawDepth));
145
+ } else {
146
+ normalized['depth'] = 1;
147
+ }
148
+ }
149
+
150
+ return normalized;
151
+ },
152
+ async execute(
153
+ _toolCallId: string,
154
+ params: unknown,
155
+ aborted: AbortSignal | undefined,
156
+ onUpdate: AgentToolUpdateCallback<any>,
157
+ ctx: ExtensionContext,
158
+ ): Promise<AgentToolResult<unknown>> {
159
+ // 1. System Readiness Check
160
+ const container = tryGetServiceContainerFromCtx(ctx);
161
+ if (!container.isReady && process.env['PI_RESEARCH_FORCE_READY'] !== 'true') {
162
+ return {
163
+ content: [{
164
+ type: 'text',
165
+ text: '**Research system is not ready.**\n\nOne or more critical services failed to initialize during startup. Please check the logs for error details or try restarting the extension.\n\nYou can also run `/research-config health` to diagnose the issue.'
166
+ }],
167
+ details: { error: 'system_not_ready' }
168
+ };
169
+ }
170
+
171
+ const { query, depth: rawDepth, model: modelId, excludeTools: paramExcludeTools, initialLinks } = params as ResearchParams;
172
+
173
+ // Enforce the same bounds the CLI applies to --initial-links: these links are
174
+ // templated verbatim into the researcher prompt as trusted seed evidence, so
175
+ // they must be http(s)-only, length-bounded, and count-capped on this path too
176
+ // (the TypeBox schema declares the bounds, but is not enforced by every caller).
177
+ if (initialLinks && initialLinks.length > 0) {
178
+ const linkError = validateInitialLinks(initialLinks);
179
+ if (linkError) {
180
+ return {
181
+ content: [{ type: 'text', text: `Error: invalid initialLinks — ${linkError}.` }],
182
+ details: { error: 'invalid_initial_links' },
183
+ };
184
+ }
185
+ }
186
+
187
+ const depth = rawDepth ?? Math.max(1, getConfig(ctx.cwd, iface).DEFAULT_RESEARCH_DEPTH) as 1 | 2 | 3;
188
+ const eCtx = ctx as ExtendedExtensionContext;
189
+ const parentExcludeTools = eCtx.excludeTools || [];
190
+ const excludeTools = [...new Set([...(paramExcludeTools || []), ...parentExcludeTools])];
191
+
192
+ const researchId = createResearchRunId();
193
+ const piSessionId = eCtx.sessionId || eCtx.sessionManager?.getSessionId() || 'default';
194
+ logger.debug(`[research] Initializing session IDs: piSessionId=${piSessionId}, researchId=${researchId}`);
195
+
196
+ const internalAbort = new AbortController();
197
+ let tuiManager: ReturnType<typeof createResearchTuiManager> | null = null;
198
+ let healthMonitorInstance: ReturnType<typeof createHealthMonitor> | null = null;
199
+ let observer: any;
200
+ let observerState: any;
201
+ let panelState: any;
202
+ // Assigned only after the TUI panel and observers are wired up. A failure
203
+ // before that point (e.g. the pre-run health check) leaves it undefined,
204
+ // so teardownUi() guards before calling it.
205
+ let cleanup: (() => Promise<void>) | undefined;
206
+ // Removes the listener attached to the parent abort signal (below), so a
207
+ // normally-completing run does not leave a handler (closing over
208
+ // internalAbort) attached to a parent signal that may outlive this call.
209
+ let detachAbortListener: (() => void) | undefined;
210
+
211
+ // Single idempotent UI teardown. Runs on every exit path — including a
212
+ // failure that happens before the inner research loop begins (model
213
+ // resolution, health check, observer setup) — so the on-screen research
214
+ // panel is never orphaned empty. The inner finally and the outer finally
215
+ // both call this; the guard makes the second call a no-op.
216
+ let uiTornDown = false;
217
+ const teardownUi = async (): Promise<void> => {
218
+ if (uiTornDown) return;
219
+ uiTornDown = true;
220
+
221
+ if (healthMonitorInstance) {
222
+ try { healthMonitorInstance.stop(); } catch (e) { logger.error('[research] Health monitor stop failed:', e); }
223
+ }
224
+ if (observerState && panelState) {
225
+ try { stopObserverWaveAnimation(observerState, panelState); } catch (e) { logger.error('[research] Stop wave animation failed:', e); }
226
+ }
227
+ // Steering messages are shared per pi-session. Only reset them when THIS is
228
+ // the last active run — otherwise a finishing run wipes a concurrent sibling
229
+ // run's queued/active steering mid-flight. This run's panel is still
230
+ // registered here (endResearchSession runs later in cleanup()), so a count
231
+ // of <= 1 means no other run is active.
232
+ try {
233
+ if (getPiActivePanels(piSessionId).length <= 1) clearSteeringMessages(piSessionId);
234
+ } catch (e) { logger.error('[research] Clear steering messages failed:', e); }
235
+ if (detachAbortListener) {
236
+ try { detachAbortListener(); } catch { /* best-effort */ } finally { detachAbortListener = undefined; }
237
+ }
238
+ if (cleanup) {
239
+ try { await cleanup(); } catch (cleanupError) { logger.error('[research] Cleanup failed:', cleanupError); }
240
+ }
241
+ // Disposes the master TUI widget — this is what removes the panel from
242
+ // the screen. Must always run or the empty panel leaks.
243
+ if (tuiManager) {
244
+ try { tuiManager.dispose(); } catch (disposeError) { logger.error('[research] TUI dispose failed:', disposeError); }
245
+ }
246
+ };
247
+
248
+ // A query is always required — initialLinks seed URLs for a query but cannot
249
+ // replace it (validateAndSanitizeQuery below rejects an empty query anyway;
250
+ // failing here keeps the message clear and matches the CLI's up-front check).
251
+ if (!query) {
252
+ return { content: [{ type: 'text', text: 'Error: research requires a query; initialLinks seed URLs for a query but cannot replace it.' }], details: {} };
253
+ }
254
+
255
+ // Each run gets its own isolated registry; session-level counter is incremented
256
+ // here, outside the run context, so it lands in the session registry.
257
+ const runRegistry = new MetricsRegistry();
258
+ const runStartedAt = Date.now();
259
+ metrics.increment('session_runs_started_total');
260
+
261
+ try {
262
+ const researchRunResult = await runWithRunRegistry<{ result: string; tokens: number; researchId: string }>(runRegistry, () =>
263
+ logger.runCapturingStderr(async () => {
264
+ const config = getConfig(ctx.cwd, iface);
265
+ validateConfig(config);
266
+
267
+ const sanitizedQuery = validateAndSanitizeQuery(query);
268
+
269
+ // Get orchestration service
270
+ const orch = await getService<IResearchOrchestration>(ServiceNames.RESEARCH_ORCHESTRATION, ctx, container);
271
+
272
+ // Resolve model using centralized service logic
273
+ const selectedModel = await orch.resolveResearchModel({
274
+ ctx,
275
+ query: sanitizedQuery,
276
+ model: modelId ? { id: modelId } as any : undefined,
277
+ config,
278
+ sessionId: piSessionId,
279
+ researchId,
280
+ });
281
+
282
+ logger.info(`[research] Resolved research model: ${selectedModel.provider}/${selectedModel.id}`);
283
+
284
+ // Setup TUI or Headless Observer based on context
285
+ if (ctx.mode === 'tui' && ctx.hasUI) {
286
+ tuiManager = createResearchTuiManager({
287
+ piSessionId,
288
+ researchId,
289
+ query: sanitizedQuery,
290
+ modelId: selectedModel.id,
291
+ }, { ctx });
292
+
293
+ tuiManager.initializePanel();
294
+ panelState = tuiManager.panelState;
295
+
296
+ // Setup cleanup BEFORE the functional health check. The panel is
297
+ // already registered in session state (createResearchTuiManager →
298
+ // registerSessionPanel above), so if the health check throws — e.g.
299
+ // "the browser could not load a page in time" on a flaky connection —
300
+ // teardownUi() must have a cleanup function to call. Otherwise the
301
+ // panel is never removed from session state and leaks as a permanent
302
+ // "ghost" panel that stacks higher with every retry. (Root cause of
303
+ // observed multi-panel ghost stacking on repeated health-check failures.)
304
+ const cleanupCtx: CleanupContext = {
305
+ researchId: startResearchSession(piSessionId, researchId),
306
+ piSessionId,
307
+ masterWidgetId: tuiManager.masterWidgetId,
308
+ panelState,
309
+ waveTimer: null,
310
+ unsubOrder: null,
311
+ };
312
+ registerSessionAbort(piSessionId, cleanupCtx.researchId, internalAbort);
313
+
314
+ cleanup = createCleanupFunction(cleanupCtx, { ctx });
315
+ const { updateUnsubOrder } = await import('../cleanup/research-cleanup.ts');
316
+ updateUnsubOrder(cleanupCtx, tuiManager.unsubOrder);
317
+
318
+ // Perform health check — cleanup is now wired, so a failure here is
319
+ // torn down cleanly (panel removed) instead of leaking a ghost panel.
320
+ await ensureFunctionalHealth({
321
+ panelState,
322
+ onUpdate: () => tuiManager?.debouncedRefresh(),
323
+ });
324
+
325
+ // Start health monitor for periodic checks
326
+ healthMonitorInstance = createHealthMonitor();
327
+ healthMonitorInstance.start();
328
+
329
+ // Setup TUI observer
330
+ observerState = createObserverState();
331
+ observer = createResearchObserver(
332
+ { panelState, debouncedRefresh: () => tuiManager?.debouncedRefresh(), renderImmediate: () => tuiManager?.renderImmediate(), researchComplexity: depth ?? 1 },
333
+ observerState
334
+ );
335
+ } else {
336
+ // Headless mode (CLI / SDK)
337
+ observer = new HeadlessObserver({ enableLogging: true });
338
+
339
+ const sessionResearchId = startResearchSession(piSessionId, researchId);
340
+ registerSessionAbort(piSessionId, sessionResearchId, internalAbort);
341
+
342
+ cleanup = async () => {
343
+ clearSteeringMessages(piSessionId);
344
+ };
345
+
346
+ // Minimal stub — only stopObserverWaveAnimation touches panelState in the
347
+ // headless path (it clears wave fields). Run totals come from the metrics
348
+ // registry, so no token/cost state is needed here.
349
+ panelState = {};
350
+ observerState = {};
351
+ }
352
+
353
+ // Handle abort signal. Track the handler so teardownUi() can detach it
354
+ // on normal completion (the parent signal may outlive this tool call).
355
+ if (aborted) {
356
+ const onParentAbort = () => internalAbort.abort();
357
+ aborted.addEventListener('abort', onParentAbort, { once: true });
358
+ detachAbortListener = () => aborted.removeEventListener('abort', onParentAbort);
359
+ }
360
+
361
+ // Setup scoped logging — use AsyncLocalStorage so concurrent runs don't bleed
362
+ const researchLogger = createLogger({ researchRunId: researchId, verbose: isVerboseFromEnv() });
363
+
364
+ // Hide working indicator
365
+ hideWorkingIndicator(ctx);
366
+
367
+ // Use runWithTracker to ensure all errors tracked within this async branch
368
+ // are isolated to this research run's tracker.
369
+ const sessionTracker = new ErrorTracker();
370
+ const researchRunResult = await runWithTracker(sessionTracker, () => runWithLogger(researchLogger, async () => {
371
+ try {
372
+ // Run research via orchestration service
373
+ const result = await orch.runResearch({
374
+ ctx,
375
+ query: sanitizedQuery || (initialLinks?.[0] ?? 'Initial Links Research'),
376
+ depth: (depth ?? 1) as ResearchDepth,
377
+ model: selectedModel,
378
+ observer,
379
+ onUpdate,
380
+ sessionId: piSessionId,
381
+ researchId,
382
+ excludeTools,
383
+ initialLinks,
384
+ // Pass the interface-resolved config (getConfig(cwd, iface) at the top
385
+ // of this handler) so the RUN honors the same `pi.env` overlay already
386
+ // applied to model/depth/export. Without this the orchestrator falls
387
+ // back to getConfig(cwd) with no interface, silently ignoring a
388
+ // pi.env DISABLED_TOOLS / TIMEOUT_MS / concurrency override for the run.
389
+ config,
390
+ }, internalAbort.signal);
391
+
392
+ // Stop wave animation
393
+ if (observerState && panelState) stopObserverWaveAnimation(observerState, panelState);
394
+
395
+ // Append unified research summary (stats + errors). Tokens/cost are read
396
+ // from the run-scoped metrics registry — the single source of truth that
397
+ // is populated identically in TUI and headless mode (the old panelState
398
+ // tally was TUI-only and stayed 0 for every headless/SDK/print-mode run,
399
+ // and even in TUI omitted coordinator+evaluator spend). extractRunStats
400
+ // returns null only when nothing meaningful ran → 0.
401
+ const runSnapshot = runRegistry.getSnapshot();
402
+ const runStats = extractRunStats(runSnapshot);
403
+ const runTokens = runStats?.tokens ?? 0;
404
+ const runCost = runStats?.cost ?? 0;
405
+ const errorReport = sessionTracker.getReport();
406
+ const resultWithSummaries = appendResearchSummary(result, runSnapshot, errorReport);
407
+
408
+ let finalResult = resultWithSummaries;
409
+ const exportCfg = getConfig(ctx.cwd, iface);
410
+ if (exportCfg.RESEARCH_REPORT_EXPORT_ENABLED) {
411
+ const exportPath = await exportResearchReport(sanitizedQuery, resultWithSummaries, (depth ?? 1) <= 1 ? 'quick' : 'deep', ctx.cwd, exportCfg.RESEARCH_REPORT_EXPORT_DIR);
412
+ if (exportPath) {
413
+ finalResult = appendExportMessage(resultWithSummaries, exportPath, runCost);
414
+ }
415
+ }
416
+
417
+ // Append research metadata (model used) at the very end, after metrics/summaries
418
+ const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
419
+ finalResult = synthesisService.appendMetadata(finalResult, selectedModel!.id);
420
+
421
+ return { result: finalResult, tokens: runTokens, researchId };
422
+ } catch (error) {
423
+ if (aborted?.aborted || internalAbort.signal.aborted) {
424
+ return { result: 'Research cancelled.', tokens: 0, researchId };
425
+ }
426
+ throw error;
427
+ } finally {
428
+ await teardownUi();
429
+ }
430
+ }));
431
+ return researchRunResult;
432
+ })); // end runCapturingStderr / runWithRunRegistry
433
+
434
+ // Snapshot the run registry
435
+ metrics.recordRunSummary({
436
+ runId: researchId,
437
+ startedAt: runStartedAt,
438
+ completedAt: Date.now(),
439
+ durationMs: Date.now() - runStartedAt,
440
+ status: 'success',
441
+ snapshot: runRegistry.getSnapshot(),
442
+ });
443
+
444
+ return { content: [{ type: 'text', text: researchRunResult.result }], details: { totalTokens: researchRunResult.tokens, researchId: researchRunResult.researchId } };
445
+ } catch (error) {
446
+ if (aborted?.aborted || internalAbort.signal.aborted) {
447
+ metrics.recordRunSummary({
448
+ runId: researchId,
449
+ startedAt: runStartedAt,
450
+ completedAt: Date.now(),
451
+ durationMs: Date.now() - runStartedAt,
452
+ status: 'cancelled',
453
+ snapshot: runRegistry.getSnapshot(),
454
+ });
455
+ return { content: [{ type: 'text', text: 'Research cancelled.' }], details: {} };
456
+ }
457
+
458
+ metrics.recordRunSummary({
459
+ runId: researchId,
460
+ startedAt: runStartedAt,
461
+ completedAt: Date.now(),
462
+ durationMs: Date.now() - runStartedAt,
463
+ status: 'error',
464
+ snapshot: runRegistry.getSnapshot(),
465
+ });
466
+
467
+ const errMsg = String(error).toLowerCase();
468
+
469
+ // Handle rate limits gracefully
470
+ if (errMsg.includes('429') || errMsg.includes('rate limit') || errMsg.includes('too many requests') || errMsg.includes('quota')) {
471
+ logger.warn('[research] Run halted gracefully due to rate limit:', error);
472
+ if (ctx.hasUI) {
473
+ ctx.ui.notify('Research halted: API rate limit reached', 'warning');
474
+ }
475
+ return {
476
+ content: [{
477
+ type: 'text',
478
+ text: `[SYSTEM MESSAGE]: The research operation was halted gracefully because an API rate limit (HTTP 429) was reached. Please inform the user that the operation was stopped due to provider rate limits and they should wait a moment before trying again.\n\nDetails: ${String(error)}`
479
+ }],
480
+ details: {}
481
+ };
482
+ }
483
+
484
+ logger.error('[research] run failed', error);
485
+ return { content: [{ type: 'text', text: `Research failed: ${String(error)}` }], details: {} };
486
+ } finally {
487
+ // Guarantees panel teardown even when the run fails before the inner
488
+ // research loop is reached (e.g. health-check failure), where the inner
489
+ // finally never executes. Idempotent: a no-op if teardown already ran.
490
+ await teardownUi();
491
+ }
492
+ },
493
+ };
494
+ }