@lincoln504/pi-research 0.1.13 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +55 -138
  3. package/dist/cli.mjs +23375 -0
  4. package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
  5. package/dist/prompts/research-knowledge-search-extractor.md +41 -0
  6. package/dist/prompts/research-tool-usage.md +92 -0
  7. package/dist/prompts/researcher.md +89 -0
  8. package/dist/prompts/system-coordinator.md +75 -0
  9. package/dist/prompts/system-lead-evaluator.md +118 -0
  10. package/dist/thread-worker.mjs +2611 -0
  11. package/dist/webgpu-probe.mjs +80 -0
  12. package/docs/AGENT-SKILL.md +130 -0
  13. package/docs/ARCHITECTURE.md +306 -80
  14. package/docs/CONFIGURATION.md +249 -0
  15. package/docs/KNOWLEDGE-STORE.md +211 -0
  16. package/docs/PI-EXTENSION.md +91 -0
  17. package/docs/SDK.md +122 -0
  18. package/docs/THIRD-PARTY-NOTICES.md +66 -0
  19. package/package.json +102 -36
  20. package/scripts/build.cjs +140 -0
  21. package/scripts/cleanup.cjs +149 -0
  22. package/scripts/prepare.cjs +28 -0
  23. package/scripts/setup.cjs +134 -0
  24. package/skills/pi-research/README.md +90 -0
  25. package/skills/pi-research/SKILL.md +102 -0
  26. package/skills/pi-research/references/configuration.md +77 -0
  27. package/skills/pi-research/scripts/run.mjs +237 -0
  28. package/src/cleanup/research-cleanup.ts +140 -0
  29. package/src/cli.ts +1195 -0
  30. package/src/config.ts +1032 -155
  31. package/src/constants.ts +115 -84
  32. package/src/core/health-check-service.ts +265 -0
  33. package/src/core/interfaces/health-check-interfaces.ts +50 -0
  34. package/src/core/interfaces/knowledge-interfaces.ts +190 -0
  35. package/src/core/interfaces/observer-interfaces.ts +67 -0
  36. package/src/core/interfaces/orchestration-interfaces.ts +86 -0
  37. package/src/core/interfaces/planning-interfaces.ts +100 -0
  38. package/src/core/interfaces/process-interfaces.ts +37 -0
  39. package/src/core/interfaces/research-plan-types.ts +52 -0
  40. package/src/core/interfaces/scheduler-interfaces.ts +91 -0
  41. package/src/core/interfaces/service-names.ts +39 -0
  42. package/src/core/interfaces/state-manager-interfaces.ts +29 -0
  43. package/src/core/interfaces/steering-interfaces.ts +31 -0
  44. package/src/core/llm/agentic-repair.ts +169 -0
  45. package/src/core/llm/inject-date.ts +52 -0
  46. package/src/core/llm/llm-timeout.ts +64 -0
  47. package/src/core/llm/llm-utils.ts +86 -0
  48. package/src/core/llm/model-registry-factory.ts +287 -0
  49. package/src/core/llm/pi-ai-completion.ts +29 -0
  50. package/src/core/llm/prompts.ts +50 -0
  51. package/src/core/llm/research-model-resolver.ts +91 -0
  52. package/src/core/planning-constants.ts +13 -0
  53. package/src/core/planning-service.ts +649 -0
  54. package/src/core/planning-utils.ts +299 -0
  55. package/src/core/scheduler-factory.ts +48 -0
  56. package/src/core/scheduler-service.ts +123 -0
  57. package/src/core/service-initialization.ts +194 -0
  58. package/src/core/service-interfaces.ts +48 -0
  59. package/src/core/service-registry.ts +659 -0
  60. package/src/healthcheck/index.ts +271 -265
  61. package/src/healthcheck/registry.ts +130 -0
  62. package/src/index.ts +639 -50
  63. package/src/infrastructure/browser/browser-cleanup.ts +309 -0
  64. package/src/infrastructure/browser/browser-client.ts +199 -0
  65. package/src/infrastructure/browser/browser-error-utils.ts +160 -0
  66. package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
  67. package/src/infrastructure/browser/browser-server.ts +174 -0
  68. package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
  69. package/src/infrastructure/browser/cleanup-utils.ts +155 -0
  70. package/src/infrastructure/browser/client-agent.ts +31 -0
  71. package/src/infrastructure/browser/config.ts +288 -0
  72. package/src/infrastructure/browser/ensure-browser.ts +205 -0
  73. package/src/infrastructure/browser/index.ts +37 -0
  74. package/src/infrastructure/browser/priority-task-queue.ts +226 -0
  75. package/src/infrastructure/browser/scheduler-factory.ts +354 -0
  76. package/src/infrastructure/browser/task-execution-service.ts +209 -0
  77. package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
  78. package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
  79. package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
  80. package/src/infrastructure/browser/thread-worker-types.ts +25 -0
  81. package/src/infrastructure/browser/thread-worker.mjs +2611 -0
  82. package/src/infrastructure/browser/thread-worker.ts +168 -0
  83. package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
  84. package/src/infrastructure/embedding/embedding-client.ts +205 -0
  85. package/src/infrastructure/embedding/embedding-factory.ts +324 -0
  86. package/src/infrastructure/embedding/embedding-server.ts +535 -0
  87. package/src/infrastructure/file-lock-service.ts +592 -0
  88. package/src/infrastructure/gpu-resource-service.ts +191 -0
  89. package/src/infrastructure/knowledge-store-service.ts +475 -0
  90. package/src/infrastructure/metrics-service.ts +129 -0
  91. package/src/infrastructure/process-lifecycle-service.ts +253 -0
  92. package/src/infrastructure/scheduler-factory-service.ts +58 -0
  93. package/src/infrastructure/service-initialization.ts +292 -0
  94. package/src/infrastructure/state/state-backup-manager.ts +290 -0
  95. package/src/infrastructure/state/state-browser-api.ts +62 -0
  96. package/src/infrastructure/state/state-browser-manager.ts +70 -0
  97. package/src/infrastructure/state/state-manager-service.ts +264 -0
  98. package/src/infrastructure/state/state-manager.ts +547 -0
  99. package/src/infrastructure/state/state-metrics.ts +92 -0
  100. package/src/infrastructure/state/state-migration.ts +103 -0
  101. package/src/infrastructure/state/state-path-configuration.ts +119 -0
  102. package/src/infrastructure/state/state-session-api.ts +94 -0
  103. package/src/infrastructure/state/state-session-manager.ts +127 -0
  104. package/src/infrastructure/state/state-validator.ts +93 -0
  105. package/src/infrastructure/types/state-types.ts +85 -0
  106. package/src/knowledge/chunker.ts +157 -0
  107. package/src/knowledge/embedder-init.ts +413 -0
  108. package/src/knowledge/embedder-utils.ts +296 -0
  109. package/src/knowledge/embedder.ts +592 -0
  110. package/src/knowledge/index.ts +126 -0
  111. package/src/knowledge/lancedb-loader.ts +35 -0
  112. package/src/knowledge/migration.ts +17 -0
  113. package/src/knowledge/model-config.ts +132 -0
  114. package/src/knowledge/onnx-env.ts +48 -0
  115. package/src/knowledge/store-operations.ts +291 -0
  116. package/src/knowledge/store-schema.ts +79 -0
  117. package/src/knowledge/store.ts +1251 -0
  118. package/src/knowledge/transformers-loader.ts +31 -0
  119. package/src/knowledge/webgpu-probe.mjs +80 -0
  120. package/src/knowledge/webgpu-viability.ts +255 -0
  121. package/src/knowledge/writer-queue.ts +252 -0
  122. package/src/logger.ts +316 -122
  123. package/src/observers/research-observer-impl.ts +459 -0
  124. package/src/orchestration/deep-research-orchestrator.ts +524 -773
  125. package/src/orchestration/deep-research-types.ts +47 -35
  126. package/src/orchestration/headless-observer.ts +114 -0
  127. package/src/orchestration/orchestration-types.ts +33 -0
  128. package/src/orchestration/quick-research-orchestrator.ts +463 -0
  129. package/src/orchestration/research-orchestration-service.ts +549 -0
  130. package/src/orchestration/research-session-service.ts +178 -0
  131. package/src/orchestration/research-synthesis-service.ts +348 -0
  132. package/src/orchestration/researcher-executor.ts +394 -0
  133. package/src/orchestration/researcher.ts +115 -42
  134. package/src/orchestration/service-initialization.ts +109 -0
  135. package/src/orchestration/session-context.ts +7 -3
  136. package/src/orchestration/session-state.ts +672 -0
  137. package/src/prompts/research-knowledge-relevance-triage.md +35 -0
  138. package/src/prompts/research-knowledge-search-extractor.md +41 -0
  139. package/src/prompts/research-tool-usage.md +63 -16
  140. package/src/prompts/researcher.md +59 -136
  141. package/src/prompts/system-coordinator.md +64 -13
  142. package/src/prompts/system-lead-evaluator.md +82 -189
  143. package/src/research-config.ts +872 -0
  144. package/src/sdk.ts +847 -0
  145. package/src/security/cisa-kev.ts +72 -9
  146. package/src/security/cvss.ts +92 -0
  147. package/src/security/ecosystem.ts +45 -0
  148. package/src/security/github-advisories.ts +168 -428
  149. package/src/security/github-advisory-types.ts +282 -0
  150. package/src/security/index.ts +63 -177
  151. package/src/security/nvd-client.ts +550 -0
  152. package/src/security/nvd-types.ts +179 -0
  153. package/src/security/osv-client.ts +246 -0
  154. package/src/security/osv-types.ts +285 -0
  155. package/src/security/types.ts +67 -53
  156. package/src/skill-install/skill-installer.ts +519 -0
  157. package/src/stackexchange/index.ts +81 -27
  158. package/src/stackexchange/output/compact.ts +7 -6
  159. package/src/stackexchange/output/table.ts +11 -7
  160. package/src/stackexchange/queries.ts +0 -10
  161. package/src/stackexchange/rest-client.ts +141 -66
  162. package/src/stackexchange/types.ts +0 -17
  163. package/src/tool.ts +15 -423
  164. package/src/tools/grep.ts +32 -310
  165. package/src/tools/health-tool-definition.ts +101 -0
  166. package/src/tools/index.ts +38 -9
  167. package/src/tools/research-knowledge-search.ts +974 -0
  168. package/src/tools/research-knowledge-types.ts +77 -0
  169. package/src/tools/research-tool-definition.ts +494 -0
  170. package/src/tools/scrape.ts +286 -388
  171. package/src/tools/search.ts +98 -153
  172. package/src/tools/security.ts +107 -80
  173. package/src/tools/stackexchange.ts +58 -35
  174. package/src/tools/youtube-transcript.ts +163 -0
  175. package/src/tui/knowledge-search-panel.ts +86 -0
  176. package/src/tui/pulse.ts +86 -0
  177. package/src/tui/research-health.ts +124 -0
  178. package/src/tui/research-panel-color-utils.ts +340 -0
  179. package/src/tui/research-panel-format.ts +54 -0
  180. package/src/tui/research-panel-state.ts +404 -0
  181. package/src/tui/research-panel-wave.ts +280 -0
  182. package/src/tui/research-panel.ts +339 -435
  183. package/src/tui/research-tui-manager.ts +233 -0
  184. package/src/tui/tui-controller.ts +137 -0
  185. package/src/types/extension-context.ts +18 -49
  186. package/src/types/index.ts +61 -0
  187. package/src/types/llm.ts +68 -29
  188. package/src/types/research-panel-types.ts +61 -0
  189. package/src/utils/circuit-breaker.ts +146 -0
  190. package/src/utils/citation-utils.ts +97 -0
  191. package/src/utils/disk-space-checker.ts +55 -0
  192. package/src/utils/error-tracker.ts +265 -0
  193. package/src/utils/host-config.ts +45 -0
  194. package/src/utils/input-validation.ts +8 -10
  195. package/src/utils/json-utils.ts +150 -16
  196. package/src/utils/llm-usage.ts +54 -0
  197. package/src/utils/log-rotation.ts +125 -0
  198. package/src/utils/log-utils.ts +159 -0
  199. package/src/utils/make-resource-loader.ts +3 -1
  200. package/src/utils/metrics-summary.ts +459 -0
  201. package/src/utils/metrics.ts +302 -0
  202. package/src/utils/research-export.ts +28 -12
  203. package/src/utils/safe-unref.ts +31 -0
  204. package/src/utils/shared-links.ts +195 -90
  205. package/src/utils/shutdown-manager.ts +65 -0
  206. package/src/utils/stdio-capture.ts +407 -0
  207. package/src/utils/text-utils.ts +258 -11
  208. package/src/utils/tool-usage-tracker.ts +83 -16
  209. package/src/utils/url-utils.ts +255 -0
  210. package/src/utils/user-agent.ts +28 -0
  211. package/src/web-research/browser-search.ts +179 -0
  212. package/src/web-research/retry-utils.ts +173 -77
  213. package/src/web-research/scraper-types.ts +72 -0
  214. package/src/web-research/scraper-utils.ts +434 -0
  215. package/src/web-research/search.ts +78 -218
  216. package/src/web-research/types.ts +9 -24
  217. package/src/web-research/utils.ts +2 -143
  218. package/src/web-research/web-scraper.ts +477 -0
  219. package/src/youtube/index.ts +154 -0
  220. package/src/youtube/potoken.ts +259 -0
  221. package/src/youtube/transcript-client.ts +390 -0
  222. package/src/youtube/video-id.ts +118 -0
  223. package/config/searxng/default-settings.yml +0 -189
  224. package/config/searxng/limiter.toml +0 -34
  225. package/config/tooling/eslint.config.js +0 -73
  226. package/config/tooling/vitest.config.integration.ts +0 -17
  227. package/config/tooling/vitest.config.ts +0 -40
  228. package/config/tooling/vitest.config.unit.ts +0 -12
  229. package/docs/assets/README-banner.jpg +0 -0
  230. package/scripts/setup.js +0 -212
  231. package/src/infrastructure/searxng-lifecycle.ts +0 -644
  232. package/src/infrastructure/searxng-manager.ts +0 -1183
  233. package/src/infrastructure/state-manager.ts +0 -918
  234. package/src/orchestration/deep-research-reducer.ts +0 -129
  235. package/src/orchestration/id-utils.ts +0 -63
  236. package/src/security/nvd.ts +0 -532
  237. package/src/security/osv.ts +0 -487
  238. package/src/stackexchange/cache.ts +0 -111
  239. package/src/stackexchange/output/index.ts +0 -7
  240. package/src/stackexchange/output/json.ts +0 -21
  241. package/src/tui/searxng-status.ts +0 -80
  242. package/src/utils/inject-date.ts +0 -29
  243. package/src/utils/research-error.ts +0 -205
  244. package/src/utils/searxng-config.ts +0 -94
  245. package/src/utils/session-state.ts +0 -295
  246. package/src/web-research/dependencies.ts +0 -20
  247. package/src/web-research/scrapers.ts +0 -935
  248. package/tsconfig.json +0 -40
package/src/sdk.ts ADDED
@@ -0,0 +1,847 @@
1
+ /**
2
+ * Pi Research Programmatic SDK
3
+ *
4
+ * Provides a clean API for running research without the Pi CLI / TUI.
5
+ * Ideal for scripts, CI/CD, and integration into other tools.
6
+ */
7
+
8
+ import {
9
+ registerCoreServices,
10
+ initializeCoreServices,
11
+ disposeCoreServices
12
+ } from './core/service-initialization.ts';
13
+ import {
14
+ registerInfrastructureServices,
15
+ initializeInfrastructureServices
16
+ } from './infrastructure/service-initialization.ts';
17
+ import {
18
+ registerOrchestrationServices,
19
+ initializeOrchestrationServices
20
+ } from './orchestration/service-initialization.ts';
21
+ import { getService, resetServiceContainer, getServiceContainer } from './core/service-registry.ts';
22
+ import type { ServiceContainer } from './core/service-registry.ts';
23
+ import { ServiceNames } from './core/service-interfaces.ts';
24
+ import type { IKnowledgeStoreService } from './core/interfaces/knowledge-interfaces.ts';
25
+ import type {
26
+ IResearchOrchestration,
27
+ IResearchSynthesisService,
28
+ ResearchOptions
29
+ } from './core/interfaces/orchestration-interfaces.ts';
30
+ import type { Model } from '@earendil-works/pi-ai';
31
+ import { type ExtensionContext, ModelRegistry } from '@earendil-works/pi-coding-agent';
32
+ import { logger, createLogger, setLogger } from './logger.ts';
33
+ import { getConfig, createConfig, validateConfig, type Config } from './config.ts';
34
+ import { metrics, MetricsRegistry, runWithRunRegistry } from './utils/metrics.ts';
35
+ import type { IMetricsSnapshot, RunSummary } from './utils/metrics.ts';
36
+ import { extractRunStats, type ResearchStats } from './utils/metrics-summary.ts';
37
+ import { ErrorTracker, runWithTracker, type ErrorReport } from './utils/error-tracker.ts';
38
+ import { runHealthCheck } from './healthcheck/index.ts';
39
+ import { buildModelRegistry as sharedBuildModelRegistry, resolveModel } from './core/llm/model-registry-factory.ts';
40
+ import { scrapeSingle } from './web-research/web-scraper.ts';
41
+ import { validateInitialLinks } from './utils/url-utils.ts';
42
+ import { validateUrlForSSRF } from './web-research/scraper-utils.ts';
43
+ import type { ScrapeResult } from './core/interfaces/scheduler-interfaces.ts';
44
+ import { randomUUID } from 'node:crypto';
45
+ import type { ResearchDepth } from './types/index.ts';
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Global SDK State
49
+ // ---------------------------------------------------------------------------
50
+
51
+ let isInitialized = false;
52
+ let _initPromise: Promise<void> | null = null;
53
+ let globalRegistry: ModelRegistry | null = null;
54
+ let globalModel: Model<any> | null = null;
55
+ let globalCwd: string = process.cwd();
56
+ let globalApiKey: string | undefined = undefined;
57
+ let globalContainer: ServiceContainer | null = null;
58
+ let globalConfig: Config | null = null;
59
+ let _lastSessionId: string | null = null;
60
+ // Guards against overlapping runDeepResearch calls on the one SDK instance. The
61
+ // `_last*` accessors below are module singletons with last-writer-wins semantics, so a
62
+ // second concurrent run would silently corrupt the first run's reported sessionId /
63
+ // metrics / reports. Concurrent runs are documented-unsupported; this converts that
64
+ // silent mis-attribution into an explicit, contract-matching error.
65
+ let _isRunning = false;
66
+ // The researchId of the most recent runDeepResearch call. Per-researcher reports are
67
+ // stored keyed by researchId (not sessionId), so getResearchReports() must look them up
68
+ // by this value — sessionId is a distinct random UUID that was never a report key.
69
+ let _lastResearchId: string | null = null;
70
+ // Per-run metrics summary captured by the most recent runDeepResearch call, so
71
+ // consumers (e.g. audit harnesses) can read pi-research's internal telemetry —
72
+ // latency, scrape/search counts, tokens, cost, tool usage — after each run.
73
+ let _lastRunSummary: RunSummary | null = null;
74
+ // Per-run error report captured by the most recent runDeepResearch call. Without
75
+ // this, the SDK path never bound an ErrorTracker, so every tracked error (healthcheck
76
+ // timeouts, provider failures, scrape errors) was written to the global tracker and
77
+ // discarded unread. Now each run gets its own tracker, a concise summary is logged at
78
+ // run end when errors occurred, and consumers can read the full report after the run.
79
+ let _lastErrorReport: ErrorReport | null = null;
80
+
81
+ // Signal handler state — registered once, removed on clean shutdown.
82
+ let _onSignal: ((signal: string) => void) | null = null;
83
+ let _sigintHandler: (() => void) | null = null;
84
+ let _sigtermHandler: (() => void) | null = null;
85
+ let _sighupHandler: (() => void) | null = null;
86
+ let _sigbreakHandler: (() => void) | null = null;
87
+ let _shuttingDown = false;
88
+ // In-flight shutdown guard. A cooperative shutdownResearchSDK() can race a
89
+ // signal-triggered one (or two callers in a finally + signal): both pass the
90
+ // early-return while isInitialized is still true and then double-dispose the
91
+ // container and double-wipe globals. Coalesce them onto one promise.
92
+ let _shutdownPromise: Promise<void> | null = null;
93
+
94
+ function _registerSignalHandlers(): void {
95
+ if (_onSignal) return; // already registered
96
+
97
+ _onSignal = (signal: string) => {
98
+ if (_shuttingDown) return;
99
+ _shuttingDown = true;
100
+
101
+ // Mark that the process is going down so native addons know to skip risky teardowns
102
+ process.env['PI_PROCESS_EXITING'] = '1';
103
+
104
+ logger.warn(`[SDK] Received ${signal} — shutting down gracefully...`);
105
+ // Fire-and-forget shutdown; force-exit after a hard deadline. The timer is
106
+ // cleared once shutdown settles so an embedded host that keeps its event
107
+ // loop alive past our teardown isn't hard-killed 15s later.
108
+ const forceExitTimer = setTimeout(() => {
109
+ logger.error(`[SDK] Forced exit after ${signal} (shutdown timed out)`);
110
+ process.exit(1);
111
+ }, 15000);
112
+ forceExitTimer.unref();
113
+ shutdownResearchSDK()
114
+ .catch(err => logger.error('[SDK] Signal shutdown error:', err))
115
+ .finally(() => clearTimeout(forceExitTimer));
116
+ };
117
+
118
+ // Store named refs so we can remove only our handlers without nuking the host's.
119
+ _sigintHandler = () => _onSignal?.('SIGINT');
120
+ _sigtermHandler = () => _onSignal?.('SIGTERM');
121
+ _sighupHandler = () => _onSignal?.('SIGHUP');
122
+ process.on('SIGINT', _sigintHandler);
123
+ process.on('SIGTERM', _sigtermHandler);
124
+ process.on('SIGHUP', _sighupHandler);
125
+ if (process.platform === 'win32') {
126
+ _sigbreakHandler = () => _onSignal?.('SIGBREAK');
127
+ process.on('SIGBREAK', _sigbreakHandler);
128
+ }
129
+ }
130
+
131
+ function _removeSignalHandlers(): void {
132
+ if (_sigintHandler) { process.removeListener('SIGINT', _sigintHandler); _sigintHandler = null; }
133
+ if (_sigtermHandler) { process.removeListener('SIGTERM', _sigtermHandler); _sigtermHandler = null; }
134
+ if (_sighupHandler) { process.removeListener('SIGHUP', _sighupHandler); _sighupHandler = null; }
135
+ if (_sigbreakHandler) { process.removeListener('SIGBREAK', _sigbreakHandler); _sigbreakHandler = null; }
136
+ _onSignal = null;
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Initialization
141
+ // ---------------------------------------------------------------------------
142
+
143
+ /**
144
+ * Options for initializing the Research SDK
145
+ */
146
+ export interface ResearchSDKOptions {
147
+ /**
148
+ * The LLM model to use for coordination, planning, and evaluation.
149
+ * If a string, it must match "provider/id" (e.g. "openai/gpt-4o").
150
+ * If omitted, the first available model in pi's configuration will be used.
151
+ */
152
+ model?: string | Model<any>;
153
+
154
+ /**
155
+ * Optional explicit API key for the model.
156
+ * If provided, `provider` must also be set.
157
+ * If omitted, pi's global auth storage (~/.pi/agent/auth.json) is used.
158
+ */
159
+ apiKey?: string;
160
+
161
+ /**
162
+ * The provider name for the explicit API key (e.g. "openai", "anthropic").
163
+ * Required if `apiKey` is provided.
164
+ */
165
+ provider?: string;
166
+
167
+ /**
168
+ * Override configuration values directly in code. These win over any values
169
+ * read from the global config file, so the SDK can be driven entirely from
170
+ * code (see `ignoreGlobalConfig` for fully hermetic usage).
171
+ */
172
+ config?: Partial<Config>;
173
+
174
+ /**
175
+ * When true, the SDK does NOT read the global `~/.pi/research/config.env` file
176
+ * at all — configuration comes only from built-in defaults, `process.env`, and
177
+ * `options.config`. Use this to run the SDK as a self-contained library with no
178
+ * dependency on a machine's global pi-research configuration.
179
+ */
180
+ ignoreGlobalConfig?: boolean;
181
+
182
+ /**
183
+ * Working directory for research logs and database.
184
+ * Defaults to current process directory.
185
+ */
186
+ cwd?: string;
187
+
188
+ /**
189
+ * Whether to enable verbose logging to the console.
190
+ */
191
+ verbose?: boolean;
192
+ }
193
+
194
+ /**
195
+ * Get the internal service container used by the SDK.
196
+ * @internal For testing only.
197
+ */
198
+ export function getSDKContainer(): ServiceContainer | null {
199
+ return globalContainer;
200
+ }
201
+
202
+ /**
203
+ * Internal initializer — registers services and makes the SDK ready. Only ever reached when the
204
+ * SDK is NOT already initialized (initResearchSDK guards and no-ops on repeat calls). To
205
+ * re-initialize with a new cwd/config/model, call shutdownResearchSDK() first — as the public
206
+ * initResearchSDK warning instructs. (A previous in-function re-init branch here was dead code:
207
+ * unreachable through the public API, and it contradicted that idempotent contract.)
208
+ */
209
+ async function _doInit(options: ResearchSDKOptions = {}): Promise<void> {
210
+ const newCwd = options.cwd ? options.cwd : process.cwd();
211
+ globalCwd = newCwd;
212
+
213
+ // Verbose logging setup
214
+ if (options.verbose) {
215
+ setLogger(createLogger({ verbose: true }));
216
+ }
217
+
218
+ // Seed configuration. The SDK is a library: it reads the base global config
219
+ // file for convenience, but NEVER a per-interface overlay (those belong to the
220
+ // pi/cli front-ends). `ignoreGlobalConfig` drops the file entirely so
221
+ // the SDK runs purely from defaults + process.env + options.config — fully
222
+ // self-contained and reproducible from code.
223
+ const baseConfig = options.ignoreGlobalConfig
224
+ ? createConfig({}, process.env)
225
+ : getConfig(globalCwd);
226
+ globalConfig = { ...baseConfig };
227
+ if (options.config) {
228
+ globalConfig = { ...globalConfig, ...options.config };
229
+ validateConfig(globalConfig);
230
+ }
231
+
232
+ globalApiKey = options.apiKey || process.env['PI_RESEARCH_API_KEY'];
233
+ let parsedProvider = options.provider || process.env['PI_RESEARCH_PROVIDER'];
234
+
235
+ // Infer provider from model if not explicitly provided
236
+ if (!parsedProvider && options.model) {
237
+ if (typeof options.model === 'string') {
238
+ const slashIdx = options.model.indexOf('/');
239
+ if (slashIdx > 0) {
240
+ parsedProvider = options.model.slice(0, slashIdx);
241
+ }
242
+ } else if ((options.model as any).provider) {
243
+ parsedProvider = (options.model as any).provider;
244
+ }
245
+ }
246
+
247
+ if (globalApiKey && !parsedProvider) {
248
+ throw new Error('Provider must be specified when using an explicit API key (set provider option or PI_RESEARCH_PROVIDER).');
249
+ }
250
+
251
+ // Build and cache the registry (one instance for the lifetime of this init cycle).
252
+ globalRegistry = sharedBuildModelRegistry(globalApiKey, parsedProvider);
253
+
254
+ try {
255
+ // Resolve model using unified logic.
256
+ globalModel = resolveModel(globalRegistry, typeof options.model === 'string' ? options.model : undefined, parsedProvider, globalApiKey);
257
+
258
+ // Use the global container to ensure internal services can resolve dependencies
259
+ globalContainer = getServiceContainer();
260
+
261
+ // Register and initialize services
262
+ registerCoreServices(globalContainer);
263
+ registerInfrastructureServices(globalContainer);
264
+ registerOrchestrationServices(globalContainer);
265
+
266
+ const mockCtx = createMockContext('init-session');
267
+ const initResult = await Promise.all([
268
+ initializeCoreServices(mockCtx, globalContainer),
269
+ initializeInfrastructureServices(mockCtx, globalContainer),
270
+ initializeOrchestrationServices(mockCtx, globalContainer)
271
+ ]);
272
+
273
+ const allSucceeded = initResult.every(r => r && r.success);
274
+ if (!allSucceeded) {
275
+ const failed = initResult.flatMap(r => r?.failed || []);
276
+ throw new Error(`Failed to initialize SDK services: ${failed.join(', ')}`);
277
+ }
278
+
279
+ isInitialized = true;
280
+ logger.log('[SDK] Research SDK initialized successfully');
281
+
282
+ // Register graceful-shutdown signal handlers (SIGINT/SIGTERM).
283
+ // These are a safety net for scripts and long-running embedders —
284
+ // the cooperative shutdownResearchSDK() API is still the primary path.
285
+ _registerSignalHandlers();
286
+ } catch (err) {
287
+ logger.error('[SDK] Initialization failed:', err);
288
+ // Cleanup on failure. If the cleanup ITSELF throws (AggregateError from
289
+ // dispose), don't let it mask the root-cause init error the caller needs —
290
+ // log it and re-throw the original.
291
+ try {
292
+ // Call the raw teardown directly, NOT the public shutdownResearchSDK(): we are
293
+ // inside _doInit and _initPromise still points at this very (rejecting) promise, so
294
+ // the public path's await-init guard would deadlock awaiting itself.
295
+ await _doShutdown();
296
+ } catch (cleanupErr) {
297
+ logger.error('[SDK] Cleanup after failed init also errored:', cleanupErr);
298
+ }
299
+ throw err;
300
+ } finally {
301
+ _initPromise = null;
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Public initialization. Guarantees the SDK is ready for use.
307
+ */
308
+ export async function initResearchSDK(options: ResearchSDKOptions = {}): Promise<void> {
309
+ if (isInitialized) {
310
+ logger.warn('[SDK] Research SDK already initialized. Call shutdownResearchSDK() first if you want to re-initialize with new options.');
311
+ return;
312
+ }
313
+ if (_initPromise) return _initPromise;
314
+ _initPromise = _doInit(options);
315
+ return _initPromise;
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Research API
320
+ // ---------------------------------------------------------------------------
321
+
322
+ import { runBrowserTask } from './infrastructure/browser/task-execution-service.ts';
323
+ import { repairJson as piAiRepairJson } from '@earendil-works/pi-ai';
324
+
325
+ /**
326
+ * Repairs malformed JSON string literals by escaping control characters
327
+ * and fixing common LLM formatting errors.
328
+ */
329
+ export function repairJson(json: string): string {
330
+ return piAiRepairJson(json);
331
+ }
332
+
333
+ /**
334
+ * Export the current knowledge store contents for use in a web frontend or backup.
335
+ *
336
+ * @param outputPath - Path where the JSON export should be saved.
337
+ */
338
+ export async function exportKnowledge(outputPath: string): Promise<void> {
339
+ if (!isInitialized || !globalContainer) throw new Error('SDK not initialized. Call initResearchSDK() first.');
340
+
341
+ const ks = await getService<IKnowledgeStoreService>(ServiceNames.KNOWLEDGE_STORE, undefined, globalContainer);
342
+ if (!ks) throw new Error('Knowledge store service not available');
343
+
344
+ await ks.exportForWeb(outputPath);
345
+ }
346
+
347
+ /**
348
+ * Verify if a URL exists and is reachable using the stealth browser.
349
+ */
350
+ export async function verifyUrl(url: string, signal?: AbortSignal): Promise<boolean> {
351
+ if (!isInitialized || !globalContainer) throw new Error('SDK not initialized. Call initResearchSDK() first.');
352
+
353
+ try {
354
+ // SSRF gate: runBrowserTask navigates the initial URL directly (the worker
355
+ // only re-validates redirect hops), so unlike scrapeSingle this path has no
356
+ // entry-point validation of its own. Block private/loopback/metadata targets
357
+ // before any browser navigation.
358
+ await validateUrlForSSRF(url);
359
+
360
+ const result = await runBrowserTask<{ content: string; success: boolean }>(
361
+ url,
362
+ 'scrape',
363
+ globalConfig!,
364
+ signal,
365
+ 1,
366
+ globalContainer
367
+ );
368
+ return !!(result && result.success);
369
+ } catch (err) {
370
+ logger.debug(`[SDK] URL verification failed for ${url}:`, err);
371
+ return false;
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Scrape a single URL with the SAME two-layer scraper the research pipeline uses:
377
+ * a fast HTTP fetch first, then a stealth-browser (playwright/camoufox) fallback for
378
+ * JS-heavy or bot-protected pages, with SSRF validation up front. Requires
379
+ * initResearchSDK() to have been called. Never throws on a failed scrape — inspect
380
+ * `result.success`; the page content is returned as `result.markdown`.
381
+ */
382
+ export async function scrapeUrl(url: string, signal?: AbortSignal): Promise<ScrapeResult> {
383
+ if (!isInitialized || !globalContainer) throw new Error('SDK not initialized. Call initResearchSDK() first.');
384
+ return scrapeSingle(url, signal, globalConfig ?? undefined, undefined, globalContainer);
385
+ }
386
+
387
+ /**
388
+ * Run a "Deep" research task (multi-round, multi-agent).
389
+ *
390
+ * @param query - The research objective
391
+ * @param options - Research configuration (depth, complexity, observer)
392
+ * @param signal - Optional abort signal
393
+ * @returns The final synthesized research report (Markdown). The session used is
394
+ * tracked internally; call getResearchReports() (no args) afterwards to
395
+ * retrieve the per-researcher reports from this run.
396
+ *
397
+ * Note: `_lastSessionId` and `_lastRunSummary` are module-level singletons. Because
398
+ * overlapping concurrent calls would corrupt each other's session/metrics/report state,
399
+ * a concurrent call on the same SDK instance throws immediately (run sequentially, or use
400
+ * a separate process/instance). Within a run, `runResearchDetailed()` captures the
401
+ * session/metrics atomically.
402
+ */
403
+ export async function runDeepResearch(
404
+ query: string,
405
+ options: Omit<ResearchOptions, 'ctx' | 'query' | 'model' | 'sessionId' | 'researchId'> = {},
406
+ signal?: AbortSignal
407
+ ): Promise<string> {
408
+ if (!isInitialized || !globalContainer) throw new Error('SDK not initialized. Call initResearchSDK() first.');
409
+
410
+ if (options.initialLinks?.length) {
411
+ const linkError = validateInitialLinks(options.initialLinks);
412
+ if (linkError) throw new Error(`Invalid initialLinks: ${linkError}`);
413
+ }
414
+
415
+ if (_isRunning) {
416
+ throw new Error('A research run is already in progress on this SDK instance; concurrent runs are not supported. Await the current run, or use a separate process/instance.');
417
+ }
418
+ _isRunning = true;
419
+
420
+ const researchId = `sdk-${Date.now()}`;
421
+ const sessionId = randomUUID();
422
+ _lastSessionId = sessionId;
423
+ _lastResearchId = researchId;
424
+
425
+ const researchStart = Date.now();
426
+ const depth = options.depth ?? 1;
427
+ const depthLabel = depth === 0 ? 'quick' : `deep-${depth}`;
428
+ const complexity = Math.max(1, Math.min(3, options.complexity ?? 1));
429
+
430
+ // Wrap the run in its own metrics registry so all counters/timings emitted
431
+ // during this call are isolated to a per-run snapshot (the SDK path does not
432
+ // go through the research tool's run-registry, so without this every run's
433
+ // metrics would only accumulate into the cumulative session registry).
434
+ const runRegistry = new MetricsRegistry();
435
+ // Per-run error tracker bound to this call's async context, so every errorTracker
436
+ // .trackError() emitted by the browser pool / orchestrator during the run is captured
437
+ // here instead of vanishing into the never-read global tracker.
438
+ const runTracker = new ErrorTracker();
439
+
440
+ try {
441
+ // Resolve the orchestrator INSIDE the try: getService() can throw (container
442
+ // disposal race with shutdown, or an unregistered service), and if that throw
443
+ // escaped before the finally below, _isRunning would latch true forever — every
444
+ // later run would then wrongly report "already in progress" even across a full
445
+ // shutdown+init cycle. Keeping it in the try guarantees the finally always clears it.
446
+ const orchestrator = await getService<IResearchOrchestration>(ServiceNames.RESEARCH_ORCHESTRATION, undefined, globalContainer);
447
+ let result = await runWithRunRegistry(runRegistry, () => runWithTracker(runTracker, () => orchestrator.runResearch({
448
+ ...options,
449
+ ctx: createMockContext(sessionId),
450
+ query,
451
+ model: globalModel!,
452
+ sessionId,
453
+ researchId,
454
+ depth: depth as ResearchDepth,
455
+ complexity: complexity as 1 | 2 | 3,
456
+ initialLinks: options.initialLinks,
457
+ // Forward the SDK's resolved config so initResearchSDK({ config }) overrides
458
+ // actually reach the orchestrator and every downstream service. A per-call
459
+ // options.config (if provided) takes precedence over the SDK-global config.
460
+ config: options.config ?? globalConfig ?? undefined,
461
+ }, signal ?? options.signal)));
462
+
463
+ // Append research metadata (model used) at the very end
464
+ const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, undefined, globalContainer);
465
+ result = synthesisService.appendMetadata(result, globalModel!.id);
466
+
467
+ const completedAt = Date.now();
468
+ // Emit into the per-run registry directly: this runs AFTER runWithRunRegistry's ALS scope
469
+ // has closed, so metrics.observe() would fall back to session scope and accumulate
470
+ // unbounded across calls (a long-lived SDK/benchmark process). runRegistry.observe() lands
471
+ // it in this run's snapshot, matching the tool path's run-scoped emission.
472
+ runRegistry.observe('research_manager_latency_ms', completedAt - researchStart, { depth: depthLabel, status: 'success', source: 'sdk' });
473
+ _lastRunSummary = {
474
+ runId: researchId, startedAt: researchStart, completedAt,
475
+ durationMs: completedAt - researchStart, status: 'success', snapshot: runRegistry.getSnapshot(),
476
+ };
477
+ metrics.recordRunSummary(_lastRunSummary);
478
+ _lastErrorReport = runTracker.getReport();
479
+ logRunErrorSummary(_lastErrorReport, depthLabel, 'success');
480
+ return result;
481
+ } catch (err) {
482
+ const completedAt = Date.now();
483
+ // Per-run scope (see the success path above) — avoids unbounded session-scope accumulation.
484
+ runRegistry.observe('research_manager_latency_ms', completedAt - researchStart, { depth: depthLabel, status: 'error', source: 'sdk' });
485
+ _lastRunSummary = {
486
+ runId: researchId, startedAt: researchStart, completedAt,
487
+ durationMs: completedAt - researchStart, status: 'error', snapshot: runRegistry.getSnapshot(),
488
+ };
489
+ metrics.recordRunSummary(_lastRunSummary);
490
+ _lastErrorReport = runTracker.getReport();
491
+ logRunErrorSummary(_lastErrorReport, depthLabel, 'error');
492
+ throw err;
493
+ } finally {
494
+ // Free the per-run PiSessionState that shouldStopResearch()/getFailedResearchers()
495
+ // lazily created (keyed by this run's random sessionId). A long-lived SDK/benchmark
496
+ // process doing thousands of runs would otherwise accumulate one never-freed entry
497
+ // (failures/aborts/panels) per run — only full shutdown reclaimed them before.
498
+ try { endResearchSession(sessionId, researchId); } catch { /* best-effort */ }
499
+ _isRunning = false;
500
+ }
501
+ }
502
+
503
+ // ---------------------------------------------------------------------------
504
+ // Audit / telemetry accessors
505
+ // ---------------------------------------------------------------------------
506
+
507
+ /**
508
+ * Raw metrics snapshot (counters/gauges/histograms) captured during the most
509
+ * recent runDeepResearch/runQuickResearch call. Null if no run has completed.
510
+ * Read this BEFORE calling shutdownResearchSDK() (shutdown clears all metrics).
511
+ */
512
+ export function getLastRunMetrics(): IMetricsSnapshot | null {
513
+ return _lastRunSummary?.snapshot ?? null;
514
+ }
515
+
516
+ /**
517
+ * Full RunSummary (runId, status, durationMs, snapshot) for the most recent run.
518
+ */
519
+ export function getLastRunSummary(): RunSummary | null {
520
+ return _lastRunSummary;
521
+ }
522
+
523
+ /**
524
+ * Aggregated error report (counts, patterns, by-domain, by-type) for the most recent
525
+ * run. Lets an unattended operator read exactly which errors occurred without scraping
526
+ * logs. Null until the first run completes; cleared on shutdown.
527
+ */
528
+ export function getLastErrorReport(): ErrorReport | null {
529
+ return _lastErrorReport;
530
+ }
531
+
532
+ /**
533
+ * Emit a single compact line summarizing a run's tracked errors, so they are visible in
534
+ * the operator's log instead of silently accumulating. No-op when the run was clean.
535
+ */
536
+ function logRunErrorSummary(report: ErrorReport | null, depthLabel: string, status: 'success' | 'error'): void {
537
+ if (!report || report.totalErrors === 0) return;
538
+ const top = report.patterns.slice(0, 3)
539
+ .map(p => `"${String(p.message ?? p.signature ?? 'error').slice(0, 48)}" ×${p.count}`)
540
+ .join(', ');
541
+ logger.warn(`[SDK] run ${status} with ${report.totalErrors} tracked error(s) across ${report.uniquePatterns} pattern(s) [${depthLabel}]${top ? ` — top: ${top}` : ''}`);
542
+ }
543
+
544
+ /**
545
+ * Distilled, human-relevant stats from the most recent run (researchers launched,
546
+ * searches, URLs analyzed/failed, tokens, cost, per-tool usage). Null if no run
547
+ * has completed or the snapshot has no recognizable research counters.
548
+ */
549
+ export function getLastRunStats(): ResearchStats | null {
550
+ return _lastRunSummary ? extractRunStats(_lastRunSummary.snapshot) : null;
551
+ }
552
+
553
+ /**
554
+ * Cumulative session-level metrics snapshot across every run since init (or the
555
+ * last shutdown). Use getLastRunMetrics() for per-run deltas instead.
556
+ */
557
+ export function getSessionMetrics(): IMetricsSnapshot {
558
+ return metrics.getSessionSnapshot();
559
+ }
560
+
561
+ /**
562
+ * Run the full system health check (browser pool, knowledge store, state manager,
563
+ * GPU lock). Useful for an audit harness reporting tool health before/after a run.
564
+ */
565
+ export async function getResearchHealth(opts?: { force?: boolean }) {
566
+ if (!isInitialized || !globalContainer) throw new Error('SDK not initialized. Call initResearchSDK() first.');
567
+ return runHealthCheck({ force: opts?.force, ctx: { container: globalContainer } });
568
+ }
569
+
570
+ /**
571
+ * Run a "Quick" research task (single-pass, single-agent).
572
+ * Equivalent to calling runDeepResearch with depth: 0.
573
+ */
574
+ export async function runQuickResearch(
575
+ query: string,
576
+ options: Omit<ResearchOptions, 'ctx' | 'query' | 'model' | 'sessionId' | 'researchId' | 'depth'> = {},
577
+ signal?: AbortSignal
578
+ ): Promise<string> {
579
+ return runDeepResearch(query, { ...options, depth: 0 } as any, signal);
580
+ }
581
+
582
+ /**
583
+ * Retrieve all researcher reports gathered during the most recent runDeepResearch() call.
584
+ * Reports are keyed by researcher ID (e.g. "1.researcher-0"). Pass the runId returned from
585
+ * runResearchDetailed() to target a specific run; omit to use the last run.
586
+ *
587
+ * Reports are looked up by researchId — the same key they were stored under — not by the
588
+ * sessionId (a distinct random UUID). Read them before the next runDeepResearch() call:
589
+ * retention is bounded by the synthesis service's LRU, so a much later read may find a run
590
+ * evicted.
591
+ */
592
+ export async function getResearchReports(researchId?: string): Promise<Map<string, string>> {
593
+ if (!globalContainer) throw new Error('SDK not initialized');
594
+ const id = researchId ?? _lastResearchId;
595
+ if (!id) return new Map();
596
+ const synthesis = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, undefined, globalContainer);
597
+ return synthesis.getAllReports(id);
598
+ }
599
+
600
+ /**
601
+ * The full result of a research run: the report plus everything an audit harness
602
+ * needs in one call — the session id, the per-run metrics snapshot and distilled
603
+ * stats, and the per-researcher reports.
604
+ */
605
+ export interface ResearchRunResult {
606
+ report: string;
607
+ sessionId: string;
608
+ runId: string;
609
+ metrics: IMetricsSnapshot | null;
610
+ stats: ResearchStats | null;
611
+ reports: Map<string, string>;
612
+ }
613
+
614
+ /**
615
+ * Like runDeepResearch but returns a rich result object (report + sessionId +
616
+ * per-run metrics/stats + per-researcher reports) instead of just the report
617
+ * string. Ideal for programmatic/audit consumers.
618
+ */
619
+ export async function runResearchDetailed(
620
+ query: string,
621
+ options: Omit<ResearchOptions, 'ctx' | 'query' | 'model' | 'sessionId' | 'researchId'> = {},
622
+ signal?: AbortSignal
623
+ ): Promise<ResearchRunResult> {
624
+ const report = await runDeepResearch(query, options, signal);
625
+ return {
626
+ report,
627
+ sessionId: _lastSessionId ?? '',
628
+ runId: _lastRunSummary?.runId ?? '',
629
+ metrics: _lastRunSummary?.snapshot ?? null,
630
+ stats: getLastRunStats(),
631
+ reports: await getResearchReports(),
632
+ };
633
+ }
634
+
635
+ // ---------------------------------------------------------------------------
636
+ // Knowledge Search
637
+ // ---------------------------------------------------------------------------
638
+
639
+ /**
640
+ * Result of a knowledge-store search: a human-readable synthesis/report string
641
+ * plus a coarse status the caller can branch on (mirrors the pi tool's tri-state).
642
+ *
643
+ * - `found: 'yes'` — the knowledge store had a complete, cited answer.
644
+ * - `found: 'maybe'` — partial answer; the caller should also do live research.
645
+ * - `found: 'no'` — nothing useful; the caller should do live research. `text`
646
+ * explains why (store empty, disabled, initializing, no match, …).
647
+ */
648
+ export interface KnowledgeSearchResult {
649
+ text: string;
650
+ found: 'yes' | 'maybe' | 'no';
651
+ documentsSearched: number;
652
+ citations: string[];
653
+ }
654
+
655
+ /**
656
+ * Search the research knowledge store for previously investigated information.
657
+ *
658
+ * This is the SDK equivalent of the `research_knowledge_search` pi tool. It runs
659
+ * the same pipeline (vector search → document rebuild → background-LLM synthesis)
660
+ * against the initialized knowledge store and returns a tri-state result that a
661
+ * caller (e.g. a CLI, an MCP server, or another agent host) can use to decide
662
+ * whether live research is still needed.
663
+ *
664
+ * Requires `initResearchSDK()` first, and a knowledge store mode other than
665
+ * `none` (set `PI_RESEARCH_KNOWLEDGE_STORE_MODE=project|global`). When the store
666
+ * is disabled/empty/unavailable this resolves to a `found: 'no'` result instead
667
+ * of throwing, so callers can treat it uniformly.
668
+ */
669
+ export async function searchKnowledge(
670
+ queries: string[],
671
+ signal?: AbortSignal,
672
+ ): Promise<KnowledgeSearchResult> {
673
+ if (!isInitialized || !globalContainer) {
674
+ throw new Error('SDK not initialized. Call initResearchSDK() first.');
675
+ }
676
+ if (!Array.isArray(queries) || queries.length === 0) {
677
+ throw new Error('searchKnowledge requires a non-empty array of queries.');
678
+ }
679
+
680
+ // Lazily import the tool factory so the SDK bundle stays lean when unused.
681
+ const { createResearchKnowledgeSearchTool } = await import('./tools/research-knowledge-search.ts');
682
+ // No per-interface overlay for the SDK — base config only (options.config has
683
+ // already been merged into globalConfig used by the run path).
684
+ const tool = createResearchKnowledgeSearchTool();
685
+
686
+ const result = await tool.execute(
687
+ randomUUID(),
688
+ { queries: queries.slice(0, 5) },
689
+ signal,
690
+ undefined,
691
+ createMockContext(`knowledge-${randomUUID()}`),
692
+ );
693
+
694
+ const textBlock = result.content?.find((c): c is { type: 'text'; text: string } => c.type === 'text');
695
+ const details = (result.details ?? {}) as {
696
+ found?: boolean;
697
+ answerStatus?: 'yes' | 'maybe' | 'no';
698
+ citations?: string[];
699
+ documentsSearched?: number;
700
+ reason?: string;
701
+ };
702
+
703
+ const status: 'yes' | 'maybe' | 'no' = details.answerStatus ?? (details.found ? 'yes' : 'no');
704
+ return {
705
+ text: textBlock?.text ?? 'No results found.',
706
+ found: status,
707
+ documentsSearched: details.documentsSearched ?? 0,
708
+ citations: details.citations ?? [],
709
+ };
710
+ }
711
+
712
+ import { clearAllSessionState, endResearchSession } from './orchestration/session-state.ts';
713
+ import { shutdownManager } from './utils/shutdown-manager.ts';
714
+
715
+ /**
716
+ * Shutdown the SDK and cleanup all background processes and resources.
717
+ */
718
+ export async function shutdownResearchSDK(): Promise<void> {
719
+ if (!isInitialized && !_initPromise && !globalContainer) {
720
+ return;
721
+ }
722
+ // If an initialization is still in flight, let it settle before tearing down. The
723
+ // early-return above only fires when nothing exists yet; once _initPromise is set,
724
+ // isInitialized is still false, so without this a shutdown racing an un-awaited init
725
+ // would dispose/reset the very container initializeCoreServices is still populating.
726
+ // (_doInit's own failure cleanup calls _doShutdown() directly, so it never re-enters
727
+ // here and cannot deadlock awaiting its own promise.)
728
+ if (_initPromise) {
729
+ await _initPromise.catch(() => { /* init failed; fall through to clean up partial state */ });
730
+ }
731
+ // Coalesce concurrent/overlapping shutdowns onto a single teardown so the
732
+ // container is disposed and globals wiped exactly once.
733
+ if (_shutdownPromise) return _shutdownPromise;
734
+ _shutdownPromise = _doShutdown().finally(() => { _shutdownPromise = null; });
735
+ return _shutdownPromise;
736
+ }
737
+
738
+ async function _doShutdown(): Promise<void> {
739
+ logger.log('[SDK] Shutting down Research SDK...');
740
+
741
+ const errors: Error[] = [];
742
+
743
+ try {
744
+ await shutdownManager.runCleanup('sdk_shutdown');
745
+ } catch (err) {
746
+ logger.error('[SDK] Error during shutdown cleanup:', err);
747
+ errors.push(err instanceof Error ? err : new Error(String(err)));
748
+ }
749
+
750
+ try {
751
+ clearAllSessionState();
752
+ metrics.clearSession();
753
+ } catch (err) {
754
+ logger.error('[SDK] Error clearing session state:', err);
755
+ errors.push(err instanceof Error ? err : new Error(String(err)));
756
+ }
757
+
758
+ if (globalContainer) {
759
+ // Dispose all services in one DAG-ordered teardown. disposeCoreServices first
760
+ // clears the embedding-server state while the StateManager is still alive, then
761
+ // disposes EVERY registered service (infra → core → orchestration) in dependency
762
+ // order. (shutdownInfrastructureServices and disposeCoreServices both delegate to
763
+ // the same global disposeAllServices, so calling infra first would dispose the
764
+ // StateManager prematurely and force it to be resurrected just to clear the
765
+ // embedding server — wasteful churn that re-creates state during teardown.)
766
+ try {
767
+ await disposeCoreServices(globalContainer);
768
+ } catch (err) {
769
+ logger.error('[SDK] Error disposing services:', err);
770
+ errors.push(err instanceof Error ? err : new Error(String(err)));
771
+ }
772
+
773
+ // Reset the container (clears registrations; the internal disposeAll is a no-op
774
+ // here because every service was already disposed above).
775
+ try {
776
+ await resetServiceContainer(globalContainer);
777
+ } catch (err) {
778
+ logger.error('[SDK] Error resetting service container:', err);
779
+ errors.push(err instanceof Error ? err : new Error(String(err)));
780
+ }
781
+ }
782
+
783
+ // Always reset globals so the SDK can be re-initialized
784
+ isInitialized = false;
785
+ _shuttingDown = false;
786
+ _initPromise = null;
787
+ // Belt-and-suspenders: an orphaned run whose finally() never settled (e.g. a
788
+ // native call that outlives disposal) would otherwise leave _isRunning latched
789
+ // true, so every runDeepResearch() after a shutdown+re-init in the same process
790
+ // throws "already in progress". The run's own finally remains the primary clear.
791
+ _isRunning = false;
792
+ globalRegistry = null;
793
+ globalModel = null;
794
+ globalCwd = process.cwd();
795
+ globalContainer = null;
796
+ globalConfig = null;
797
+ _lastSessionId = null;
798
+ _lastResearchId = null;
799
+ _lastRunSummary = null;
800
+ _lastErrorReport = null;
801
+
802
+ // Remove signal handlers — caller is shutting down cooperatively.
803
+ _removeSignalHandlers();
804
+
805
+ if (errors.length > 0) {
806
+ throw new AggregateError(errors, `${errors.length} error(s) during SDK shutdown`);
807
+ }
808
+ }
809
+
810
+ // ---------------------------------------------------------------------------
811
+ // Mock context (mirrors pi-coding-agent ExtensionContext)
812
+ // ---------------------------------------------------------------------------
813
+
814
+ /**
815
+ * Create an ExtensionContext for internal services.
816
+ * Uses the cached globalRegistry (built once at init time).
817
+ */
818
+ function createMockContext(sessionId: string): ExtensionContext {
819
+ return {
820
+ cwd: globalCwd,
821
+ config: globalConfig!,
822
+ container: globalContainer!,
823
+ mode: 'print' as const, // SDK defaults to print mode
824
+ hasUI: false, // SDK is headless
825
+ model: globalModel!,
826
+ modelRegistry: globalRegistry!,
827
+ ui: {
828
+ log: (msg: string) => logger.log(`[PI-UI] ${msg}`),
829
+ error: (msg: string) => logger.error(`[PI-UI] ${msg}`),
830
+ notify: (msg: string) => logger.log(`[PI-UI-NOTIFY] ${msg}`),
831
+ showStatus: () => () => {},
832
+ progress: () => () => {},
833
+ // No-op widget host — kept field-compatible with the host-injected ctx so any
834
+ // future (hasUI-gated) ctx.ui.setWidget call behaves identically on
835
+ // headless hosts instead of throwing on the SDK path.
836
+ setWidget: () => {},
837
+ custom: async () => ({ type: 'cancel' }),
838
+ confirm: async () => false,
839
+ onTerminalInput: () => () => { return () => {}; },
840
+ },
841
+ sessionManager: {
842
+ getSessionId: () => sessionId,
843
+ getBranch: () => [],
844
+ },
845
+ } as any;
846
+ }
847
+