@lincoln504/pi-research 1.0.6 → 1.0.9

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/src/sdk.ts CHANGED
@@ -22,11 +22,12 @@ import { getService, resetServiceContainer, getServiceContainer } from './core/s
22
22
  import type { ServiceContainer } from './core/service-registry.ts';
23
23
  import { ServiceNames } from './core/service-interfaces.ts';
24
24
  import type { IKnowledgeStoreService } from './core/interfaces/knowledge-interfaces.ts';
25
- import type {
26
- IResearchOrchestration,
25
+ import type {
26
+ IResearchOrchestration,
27
27
  IResearchSynthesisService,
28
- ResearchOptions
28
+ ResearchOptions
29
29
  } from './core/interfaces/orchestration-interfaces.ts';
30
+ import type { IPlanningService } from './core/interfaces/planning-interfaces.ts';
30
31
  import type { Model } from '@earendil-works/pi-ai';
31
32
  import { type ExtensionContext, ModelRegistry } from '@earendil-works/pi-coding-agent';
32
33
  import { logger, createLogger, setLogger } from './logger.ts';
@@ -78,6 +79,22 @@ let _lastRunSummary: RunSummary | null = null;
78
79
  // run end when errors occurred, and consumers can read the full report after the run.
79
80
  let _lastErrorReport: ErrorReport | null = null;
80
81
 
82
+ /**
83
+ * Researcher-outcome summary for the most recent runDeepResearch call: how many
84
+ * researchers were planned/launched vs. how many ultimately failed (never landed
85
+ * a report) vs. succeeded, plus the first recorded failure reason per failed
86
+ * researcher. Lets a caller distinguish "thin report, little material out there"
87
+ * from "thin report because most researchers died" without opening the log file.
88
+ */
89
+ export interface ResearcherOutcome {
90
+ planned: number;
91
+ launched: number;
92
+ succeeded: number;
93
+ failed: number;
94
+ failureReasons: Record<string, string>;
95
+ }
96
+ let _lastResearcherOutcome: ResearcherOutcome | null = null;
97
+
81
98
  // Signal handler state — registered once, removed on clean shutdown.
82
99
  let _onSignal: ((signal: string) => void) | null = null;
83
100
  let _sigintHandler: (() => void) | null = null;
@@ -269,7 +286,7 @@ async function _doInit(options: ResearchSDKOptions = {}): Promise<void> {
269
286
  }
270
287
 
271
288
  // Build and cache the registry (one instance for the lifetime of this init cycle).
272
- globalRegistry = sharedBuildModelRegistry(globalApiKey, parsedProvider);
289
+ globalRegistry = await sharedBuildModelRegistry(globalApiKey, parsedProvider);
273
290
 
274
291
  try {
275
292
  // Resolve the model. Precedence: explicit option (a Model object is used
@@ -512,6 +529,7 @@ export async function runDeepResearch(
512
529
  metrics.recordRunSummary(_lastRunSummary);
513
530
  _lastErrorReport = runTracker.getReport();
514
531
  logRunErrorSummary(_lastErrorReport, depthLabel, 'success');
532
+ _lastResearcherOutcome = await captureResearcherOutcome(sessionId, researchId, _lastRunSummary.snapshot);
515
533
  return result;
516
534
  } catch (err) {
517
535
  const completedAt = Date.now();
@@ -524,6 +542,7 @@ export async function runDeepResearch(
524
542
  metrics.recordRunSummary(_lastRunSummary);
525
543
  _lastErrorReport = runTracker.getReport();
526
544
  logRunErrorSummary(_lastErrorReport, depthLabel, 'error');
545
+ _lastResearcherOutcome = await captureResearcherOutcome(sessionId, researchId, _lastRunSummary.snapshot);
527
546
  throw err;
528
547
  } finally {
529
548
  // Free the per-run PiSessionState that shouldStopResearch()/getFailedResearchers()
@@ -564,6 +583,14 @@ export function getLastErrorReport(): ErrorReport | null {
564
583
  return _lastErrorReport;
565
584
  }
566
585
 
586
+ /**
587
+ * Researcher-outcome summary (planned/launched/succeeded/failed + failure reasons)
588
+ * for the most recent run. Null until the first run completes; cleared on shutdown.
589
+ */
590
+ export function getLastResearcherOutcome(): ResearcherOutcome | null {
591
+ return _lastResearcherOutcome;
592
+ }
593
+
567
594
  /**
568
595
  * Emit a single compact line summarizing a run's tracked errors, so they are visible in
569
596
  * the operator's log instead of silently accumulating. No-op when the run was clean.
@@ -576,6 +603,31 @@ function logRunErrorSummary(report: ErrorReport | null, depthLabel: string, stat
576
603
  logger.warn(`[SDK] run ${status} with ${report.totalErrors} tracked error(s) across ${report.uniquePatterns} pattern(s) [${depthLabel}]${top ? ` — top: ${top}` : ''}`);
577
604
  }
578
605
 
606
+ /**
607
+ * Capture how many researchers were planned/launched/failed for this run, BEFORE
608
+ * the caller's `finally` block frees the PiSessionState (endResearchSession) that
609
+ * getFailedResearchers()/getResearcherFailureReasons() read from. Best-effort:
610
+ * this is instrumentation only and must never affect the run's own result.
611
+ */
612
+ async function captureResearcherOutcome(sessionId: string, researchId: string, snapshot: IMetricsSnapshot): Promise<ResearcherOutcome | null> {
613
+ try {
614
+ const planningService = await getService<IPlanningService>(ServiceNames.PLANNING, undefined, globalContainer!);
615
+ const planned = planningService.getTotalResearchersPlanned(researchId);
616
+ const launched = extractRunStats(snapshot)?.researchersLaunched ?? 0;
617
+ const failed = getFailedResearchers(sessionId, researchId);
618
+ return {
619
+ planned,
620
+ launched,
621
+ succeeded: Math.max(0, launched - failed.length),
622
+ failed: failed.length,
623
+ failureReasons: getResearcherFailureReasons(sessionId, researchId),
624
+ };
625
+ } catch (err) {
626
+ logger.debug('[SDK] Failed to capture researcher outcome (non-fatal):', err);
627
+ return null;
628
+ }
629
+ }
630
+
579
631
  /**
580
632
  * Distilled, human-relevant stats from the most recent run (researchers launched,
581
633
  * searches, URLs analyzed/failed, tokens, cost, per-tool usage). Null if no run
@@ -744,7 +796,7 @@ export async function searchKnowledge(
744
796
  };
745
797
  }
746
798
 
747
- import { clearAllSessionState, endResearchSession } from './orchestration/session-state.ts';
799
+ import { clearAllSessionState, endResearchSession, getFailedResearchers, getResearcherFailureReasons } from './orchestration/session-state.ts';
748
800
  import { shutdownManager } from './utils/shutdown-manager.ts';
749
801
 
750
802
  /**
@@ -833,6 +885,7 @@ async function _doShutdown(): Promise<void> {
833
885
  _lastResearchId = null;
834
886
  _lastRunSummary = null;
835
887
  _lastErrorReport = null;
888
+ _lastResearcherOutcome = null;
836
889
  // The module-level config cache survives the globals above. Without this, a
837
890
  // shutdown → env/config-file change → re-init cycle in one process silently
838
891
  // reuses the first init's config. (The CLI/skill are unaffected — they pass
@@ -5,31 +5,21 @@
5
5
  * Both files use identical ResourceLoader implementations.
6
6
  */
7
7
 
8
- import type { ResourceLoader, ExtensionRuntime } from '@earendil-works/pi-coding-agent';
8
+ import { createExtensionRuntime, type ResourceLoader } from '@earendil-works/pi-coding-agent';
9
9
 
10
10
  export function makeResourceLoader(systemPromptText: string): ResourceLoader {
11
- const mockRuntime: ExtensionRuntime = {
12
- flagValues: new Map(),
13
- pendingProviderRegistrations: [],
14
- registerProvider: () => {},
15
- unregisterProvider: () => {},
16
- sendMessage: async () => {},
17
- sendUserMessage: async () => {},
18
- appendEntry: async () => {},
19
- setSessionName: () => undefined,
20
- getSessionName: () => undefined,
21
- setLabel: async () => {},
22
- getActiveTools: () => [],
23
- getAllTools: () => [],
24
- setActiveTools: () => {},
25
- refreshTools: () => {},
26
- getCommands: () => [],
27
- setModel: async () => true,
28
- getThinkingLevel: () => 'off',
29
- setThinkingLevel: () => {},
30
- assertActive: () => {},
31
- invalidate: () => {},
32
- };
11
+ // Use pi's own createExtensionRuntime() instead of hand-maintaining a mock.
12
+ //
13
+ // A literal mock object drifted out of sync with the ExtensionRuntime contract:
14
+ // pi 0.81.0 ("Full provider extensions") added `pendingNativeProviderRegistrations`
15
+ // + `registerNativeProvider`, which this mock omitted. Under pi >= 0.81.0 the
16
+ // host's runner.bindCore() / createAgentSessionServices() iterate that field —
17
+ // `for (... of runtime.pendingNativeProviderRegistrations)` — and an `undefined`
18
+ // field threw `this.runtime.pendingNativeProviderRegistrations is not iterable`,
19
+ // aborting EVERY researcher session ("Research produced no report — no source
20
+ // material was collected"). createExtensionRuntime() always returns a runtime
21
+ // that satisfies the current host's full contract, so this can't drift again.
22
+ const mockRuntime = createExtensionRuntime();
33
23
 
34
24
  return {
35
25
  getExtensions: () => ({ extensions: [], errors: [], runtime: mockRuntime }),
@@ -61,10 +61,22 @@ export async function performSearch(
61
61
  if (onProgress) onProgress(0);
62
62
 
63
63
  // Hard cap per query so a single Cloudflare block or hung browser worker
64
- // cannot stall the entire Promise.all burst. Derived from the configured
65
- // search timeout (default 45s) so raising SEARCH_TIMEOUT_MS actually takes
66
- // effect here instead of being silently overridden by a lower literal.
67
- const QUERY_TIMEOUT_MS = config?.SEARCH_TIMEOUT_MS ?? 45_000;
64
+ // cannot stall the entire Promise.all burst.
65
+ //
66
+ // The budget is the worker's nav budget (SEARCH_TIMEOUT_MS) PLUS the
67
+ // queue-wait/overhead margin (BROWSER_TASK_TIMEOUT_MS), mirroring the
68
+ // scheduler's own task-timeout ceiling in BrowserTaskScheduler.runSearch
69
+ // (= SEARCH_TIMEOUT_MS + BROWSER_TASK_TIMEOUT_MS). This clock starts at
70
+ // ENQUEUE (before runWorkerSearch queues onto the shared browser pool), so
71
+ // a budget of only SEARCH_TIMEOUT_MS would let a query that sat in the
72
+ // saturated worker queue burn its ENTIRE nav budget waiting for a slot and
73
+ // abort with zero actual search work — exactly the "Query timed out after
74
+ // 45000ms (likely blocked or slow startup)" cascade observed under
75
+ // concurrent load. Including the margin lets a queued query still use its
76
+ // full nav budget once a worker picks it up, and keeps this layer's hard
77
+ // cap coherent with the scheduler's so it never preempts the worker's own
78
+ // (post-dequeue) deadline.
79
+ const QUERY_TIMEOUT_MS = (config?.SEARCH_TIMEOUT_MS ?? 45_000) + (config?.BROWSER_TASK_TIMEOUT_MS ?? 10_000);
68
80
 
69
81
  const filteredQueries = queries.filter(q => q.trim());
70
82
  let timeoutCount = 0;
@@ -133,7 +145,7 @@ export async function performSearch(
133
145
  metrics.increment('browser_search_queries_total', 1, { status });
134
146
 
135
147
  if (isTimeout) {
136
- logger.warn(`[Search] Query timed out after ${QUERY_TIMEOUT_MS}ms (likely blocked or slow startup): "${query}"`);
148
+ logger.warn(`[Search] Query timed out after ${QUERY_TIMEOUT_MS}ms — likely starved waiting for a free browser worker under concurrent load, or a slow/blocked search provider: "${query}"`);
137
149
  } else {
138
150
  const msg = error instanceof Error ? error.message : String(error);
139
151
  if (msg !== 'Aborted') {
@@ -126,10 +126,11 @@ function formatResults(results: VideoTranscript[], rejected: string[]): string {
126
126
  // citation pipeline (parseCitations extracts URLs), so the transcript's
127
127
  // provenance would silently drop out of the final report's CITED LINKS.
128
128
  // The title/channel follows so the report names the real video, not a
129
- // paraphrase (the watch URL alone is not human-identifiable).
130
- if (r.title) {
131
- md += `**Cite as:** ${r.url} — '${r.title}'${r.author ? ` by ${r.author}` : ''}\n`;
132
- }
129
+ // paraphrase (the watch URL alone is not human-identifiable). Emit this line
130
+ // unconditionally — an untitled video still needs a parseable "Cite as" the
131
+ // model can copy verbatim; omitting the line entirely for untitled videos
132
+ // left them without any prompt-visible citation string to reuse.
133
+ md += `**Cite as:** ${r.url}${r.title ? ` — '${r.title}'${r.author ? ` by ${r.author}` : ''}` : ''}\n`;
133
134
  md += `\n${r.text}\n\n---\n\n`;
134
135
  }
135
136