@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.
- package/LICENSE +1 -1
- package/README.md +55 -138
- package/dist/cli.mjs +23375 -0
- package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
- package/dist/prompts/research-knowledge-search-extractor.md +41 -0
- package/dist/prompts/research-tool-usage.md +92 -0
- package/dist/prompts/researcher.md +89 -0
- package/dist/prompts/system-coordinator.md +75 -0
- package/dist/prompts/system-lead-evaluator.md +118 -0
- package/dist/thread-worker.mjs +2611 -0
- package/dist/webgpu-probe.mjs +80 -0
- package/docs/AGENT-SKILL.md +130 -0
- package/docs/ARCHITECTURE.md +306 -80
- package/docs/CONFIGURATION.md +249 -0
- package/docs/KNOWLEDGE-STORE.md +211 -0
- package/docs/PI-EXTENSION.md +91 -0
- package/docs/SDK.md +122 -0
- package/docs/THIRD-PARTY-NOTICES.md +66 -0
- package/package.json +102 -36
- package/scripts/build.cjs +140 -0
- package/scripts/cleanup.cjs +149 -0
- package/scripts/prepare.cjs +28 -0
- package/scripts/setup.cjs +134 -0
- package/skills/pi-research/README.md +90 -0
- package/skills/pi-research/SKILL.md +102 -0
- package/skills/pi-research/references/configuration.md +77 -0
- package/skills/pi-research/scripts/run.mjs +237 -0
- package/src/cleanup/research-cleanup.ts +140 -0
- package/src/cli.ts +1195 -0
- package/src/config.ts +1032 -155
- package/src/constants.ts +115 -84
- package/src/core/health-check-service.ts +265 -0
- package/src/core/interfaces/health-check-interfaces.ts +50 -0
- package/src/core/interfaces/knowledge-interfaces.ts +190 -0
- package/src/core/interfaces/observer-interfaces.ts +67 -0
- package/src/core/interfaces/orchestration-interfaces.ts +86 -0
- package/src/core/interfaces/planning-interfaces.ts +100 -0
- package/src/core/interfaces/process-interfaces.ts +37 -0
- package/src/core/interfaces/research-plan-types.ts +52 -0
- package/src/core/interfaces/scheduler-interfaces.ts +91 -0
- package/src/core/interfaces/service-names.ts +39 -0
- package/src/core/interfaces/state-manager-interfaces.ts +29 -0
- package/src/core/interfaces/steering-interfaces.ts +31 -0
- package/src/core/llm/agentic-repair.ts +169 -0
- package/src/core/llm/inject-date.ts +52 -0
- package/src/core/llm/llm-timeout.ts +64 -0
- package/src/core/llm/llm-utils.ts +86 -0
- package/src/core/llm/model-registry-factory.ts +287 -0
- package/src/core/llm/pi-ai-completion.ts +29 -0
- package/src/core/llm/prompts.ts +50 -0
- package/src/core/llm/research-model-resolver.ts +91 -0
- package/src/core/planning-constants.ts +13 -0
- package/src/core/planning-service.ts +649 -0
- package/src/core/planning-utils.ts +299 -0
- package/src/core/scheduler-factory.ts +48 -0
- package/src/core/scheduler-service.ts +123 -0
- package/src/core/service-initialization.ts +194 -0
- package/src/core/service-interfaces.ts +48 -0
- package/src/core/service-registry.ts +659 -0
- package/src/healthcheck/index.ts +271 -265
- package/src/healthcheck/registry.ts +130 -0
- package/src/index.ts +639 -50
- package/src/infrastructure/browser/browser-cleanup.ts +309 -0
- package/src/infrastructure/browser/browser-client.ts +199 -0
- package/src/infrastructure/browser/browser-error-utils.ts +160 -0
- package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
- package/src/infrastructure/browser/browser-server.ts +174 -0
- package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
- package/src/infrastructure/browser/cleanup-utils.ts +155 -0
- package/src/infrastructure/browser/client-agent.ts +31 -0
- package/src/infrastructure/browser/config.ts +288 -0
- package/src/infrastructure/browser/ensure-browser.ts +205 -0
- package/src/infrastructure/browser/index.ts +37 -0
- package/src/infrastructure/browser/priority-task-queue.ts +226 -0
- package/src/infrastructure/browser/scheduler-factory.ts +354 -0
- package/src/infrastructure/browser/task-execution-service.ts +209 -0
- package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
- package/src/infrastructure/browser/thread-worker-types.ts +25 -0
- package/src/infrastructure/browser/thread-worker.mjs +2611 -0
- package/src/infrastructure/browser/thread-worker.ts +168 -0
- package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
- package/src/infrastructure/embedding/embedding-client.ts +205 -0
- package/src/infrastructure/embedding/embedding-factory.ts +324 -0
- package/src/infrastructure/embedding/embedding-server.ts +535 -0
- package/src/infrastructure/file-lock-service.ts +592 -0
- package/src/infrastructure/gpu-resource-service.ts +191 -0
- package/src/infrastructure/knowledge-store-service.ts +475 -0
- package/src/infrastructure/metrics-service.ts +129 -0
- package/src/infrastructure/process-lifecycle-service.ts +253 -0
- package/src/infrastructure/scheduler-factory-service.ts +58 -0
- package/src/infrastructure/service-initialization.ts +292 -0
- package/src/infrastructure/state/state-backup-manager.ts +290 -0
- package/src/infrastructure/state/state-browser-api.ts +62 -0
- package/src/infrastructure/state/state-browser-manager.ts +70 -0
- package/src/infrastructure/state/state-manager-service.ts +264 -0
- package/src/infrastructure/state/state-manager.ts +547 -0
- package/src/infrastructure/state/state-metrics.ts +92 -0
- package/src/infrastructure/state/state-migration.ts +103 -0
- package/src/infrastructure/state/state-path-configuration.ts +119 -0
- package/src/infrastructure/state/state-session-api.ts +94 -0
- package/src/infrastructure/state/state-session-manager.ts +127 -0
- package/src/infrastructure/state/state-validator.ts +93 -0
- package/src/infrastructure/types/state-types.ts +85 -0
- package/src/knowledge/chunker.ts +157 -0
- package/src/knowledge/embedder-init.ts +413 -0
- package/src/knowledge/embedder-utils.ts +296 -0
- package/src/knowledge/embedder.ts +592 -0
- package/src/knowledge/index.ts +126 -0
- package/src/knowledge/lancedb-loader.ts +35 -0
- package/src/knowledge/migration.ts +17 -0
- package/src/knowledge/model-config.ts +132 -0
- package/src/knowledge/onnx-env.ts +48 -0
- package/src/knowledge/store-operations.ts +291 -0
- package/src/knowledge/store-schema.ts +79 -0
- package/src/knowledge/store.ts +1251 -0
- package/src/knowledge/transformers-loader.ts +31 -0
- package/src/knowledge/webgpu-probe.mjs +80 -0
- package/src/knowledge/webgpu-viability.ts +255 -0
- package/src/knowledge/writer-queue.ts +252 -0
- package/src/logger.ts +316 -122
- package/src/observers/research-observer-impl.ts +459 -0
- package/src/orchestration/deep-research-orchestrator.ts +524 -773
- package/src/orchestration/deep-research-types.ts +47 -35
- package/src/orchestration/headless-observer.ts +114 -0
- package/src/orchestration/orchestration-types.ts +33 -0
- package/src/orchestration/quick-research-orchestrator.ts +463 -0
- package/src/orchestration/research-orchestration-service.ts +549 -0
- package/src/orchestration/research-session-service.ts +178 -0
- package/src/orchestration/research-synthesis-service.ts +348 -0
- package/src/orchestration/researcher-executor.ts +394 -0
- package/src/orchestration/researcher.ts +115 -42
- package/src/orchestration/service-initialization.ts +109 -0
- package/src/orchestration/session-context.ts +7 -3
- package/src/orchestration/session-state.ts +672 -0
- package/src/prompts/research-knowledge-relevance-triage.md +35 -0
- package/src/prompts/research-knowledge-search-extractor.md +41 -0
- package/src/prompts/research-tool-usage.md +63 -16
- package/src/prompts/researcher.md +59 -136
- package/src/prompts/system-coordinator.md +64 -13
- package/src/prompts/system-lead-evaluator.md +82 -189
- package/src/research-config.ts +872 -0
- package/src/sdk.ts +847 -0
- package/src/security/cisa-kev.ts +72 -9
- package/src/security/cvss.ts +92 -0
- package/src/security/ecosystem.ts +45 -0
- package/src/security/github-advisories.ts +168 -428
- package/src/security/github-advisory-types.ts +282 -0
- package/src/security/index.ts +63 -177
- package/src/security/nvd-client.ts +550 -0
- package/src/security/nvd-types.ts +179 -0
- package/src/security/osv-client.ts +246 -0
- package/src/security/osv-types.ts +285 -0
- package/src/security/types.ts +67 -53
- package/src/skill-install/skill-installer.ts +519 -0
- package/src/stackexchange/index.ts +81 -27
- package/src/stackexchange/output/compact.ts +7 -6
- package/src/stackexchange/output/table.ts +11 -7
- package/src/stackexchange/queries.ts +0 -10
- package/src/stackexchange/rest-client.ts +141 -66
- package/src/stackexchange/types.ts +0 -17
- package/src/tool.ts +15 -423
- package/src/tools/grep.ts +32 -310
- package/src/tools/health-tool-definition.ts +101 -0
- package/src/tools/index.ts +38 -9
- package/src/tools/research-knowledge-search.ts +974 -0
- package/src/tools/research-knowledge-types.ts +77 -0
- package/src/tools/research-tool-definition.ts +494 -0
- package/src/tools/scrape.ts +286 -388
- package/src/tools/search.ts +98 -153
- package/src/tools/security.ts +107 -80
- package/src/tools/stackexchange.ts +58 -35
- package/src/tools/youtube-transcript.ts +163 -0
- package/src/tui/knowledge-search-panel.ts +86 -0
- package/src/tui/pulse.ts +86 -0
- package/src/tui/research-health.ts +124 -0
- package/src/tui/research-panel-color-utils.ts +340 -0
- package/src/tui/research-panel-format.ts +54 -0
- package/src/tui/research-panel-state.ts +404 -0
- package/src/tui/research-panel-wave.ts +280 -0
- package/src/tui/research-panel.ts +339 -435
- package/src/tui/research-tui-manager.ts +233 -0
- package/src/tui/tui-controller.ts +137 -0
- package/src/types/extension-context.ts +18 -49
- package/src/types/index.ts +61 -0
- package/src/types/llm.ts +68 -29
- package/src/types/research-panel-types.ts +61 -0
- package/src/utils/circuit-breaker.ts +146 -0
- package/src/utils/citation-utils.ts +97 -0
- package/src/utils/disk-space-checker.ts +55 -0
- package/src/utils/error-tracker.ts +265 -0
- package/src/utils/host-config.ts +45 -0
- package/src/utils/input-validation.ts +8 -10
- package/src/utils/json-utils.ts +150 -16
- package/src/utils/llm-usage.ts +54 -0
- package/src/utils/log-rotation.ts +125 -0
- package/src/utils/log-utils.ts +159 -0
- package/src/utils/make-resource-loader.ts +3 -1
- package/src/utils/metrics-summary.ts +459 -0
- package/src/utils/metrics.ts +302 -0
- package/src/utils/research-export.ts +28 -12
- package/src/utils/safe-unref.ts +31 -0
- package/src/utils/shared-links.ts +195 -90
- package/src/utils/shutdown-manager.ts +65 -0
- package/src/utils/stdio-capture.ts +407 -0
- package/src/utils/text-utils.ts +258 -11
- package/src/utils/tool-usage-tracker.ts +83 -16
- package/src/utils/url-utils.ts +255 -0
- package/src/utils/user-agent.ts +28 -0
- package/src/web-research/browser-search.ts +179 -0
- package/src/web-research/retry-utils.ts +173 -77
- package/src/web-research/scraper-types.ts +72 -0
- package/src/web-research/scraper-utils.ts +434 -0
- package/src/web-research/search.ts +78 -218
- package/src/web-research/types.ts +9 -24
- package/src/web-research/utils.ts +2 -143
- package/src/web-research/web-scraper.ts +477 -0
- package/src/youtube/index.ts +154 -0
- package/src/youtube/potoken.ts +259 -0
- package/src/youtube/transcript-client.ts +390 -0
- package/src/youtube/video-id.ts +118 -0
- package/config/searxng/default-settings.yml +0 -189
- package/config/searxng/limiter.toml +0 -34
- package/config/tooling/eslint.config.js +0 -73
- package/config/tooling/vitest.config.integration.ts +0 -17
- package/config/tooling/vitest.config.ts +0 -40
- package/config/tooling/vitest.config.unit.ts +0 -12
- package/docs/assets/README-banner.jpg +0 -0
- package/scripts/setup.js +0 -212
- package/src/infrastructure/searxng-lifecycle.ts +0 -644
- package/src/infrastructure/searxng-manager.ts +0 -1183
- package/src/infrastructure/state-manager.ts +0 -918
- package/src/orchestration/deep-research-reducer.ts +0 -129
- package/src/orchestration/id-utils.ts +0 -63
- package/src/security/nvd.ts +0 -532
- package/src/security/osv.ts +0 -487
- package/src/stackexchange/cache.ts +0 -111
- package/src/stackexchange/output/index.ts +0 -7
- package/src/stackexchange/output/json.ts +0 -21
- package/src/tui/searxng-status.ts +0 -80
- package/src/utils/inject-date.ts +0 -29
- package/src/utils/research-error.ts +0 -205
- package/src/utils/searxng-config.ts +0 -94
- package/src/utils/session-state.ts +0 -295
- package/src/web-research/dependencies.ts +0 -20
- package/src/web-research/scrapers.ts +0 -935
- 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
|
+
|