@lincoln504/pi-research 1.0.4 → 1.0.6

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/cli.ts CHANGED
@@ -259,7 +259,7 @@ export interface CredentialDetection {
259
259
  apiKeyConfigured: boolean;
260
260
  provider?: string;
261
261
  model?: string;
262
- modelFrom: 'PI_RESEARCH_MODEL' | 'config.env' | 'unset';
262
+ modelFrom: '--model' | 'PI_RESEARCH_MODEL' | 'config.env' | 'unset';
263
263
  piAuthPresent: boolean;
264
264
  piModelsPresent: boolean;
265
265
  /** A short, human-readable problem string, or undefined when ready. */
@@ -269,8 +269,17 @@ export interface CredentialDetection {
269
269
  /**
270
270
  * Detect how the SDK will authenticate WITHOUT initializing it. Used both for
271
271
  * the `status` command and for the fast-fail pre-flight before every run.
272
+ *
273
+ * `explicitModel` is a per-run `--model` flag. It must participate in the
274
+ * pre-flight: it outranks PI_RESEARCH_MODEL/config for the run, so (a) it
275
+ * satisfies the model-required check on its own, and (b) when no explicit
276
+ * PI_RESEARCH_PROVIDER is set, provider-aware key detection derives the
277
+ * provider from the FLAG's model — otherwise a configured model on provider X
278
+ * greenlights a `--model` run on keyless provider Y (or vice versa wrongly
279
+ * blocks a valid `--model` run). An explicit PI_RESEARCH_PROVIDER still wins:
280
+ * it names the provider the configured key belongs to.
272
281
  */
273
- function detectCredentials(): CredentialDetection {
282
+ function detectCredentials(explicitModel?: string): CredentialDetection {
274
283
  const paths = resolvedConfigPaths();
275
284
  const piAuthPresent = fileExists(paths.piAuth);
276
285
  const piModelsPresent = fileExists(paths.piModels);
@@ -280,8 +289,8 @@ function detectCredentials(): CredentialDetection {
280
289
  const modelEnv = process.env['PI_RESEARCH_MODEL'];
281
290
  const modelConfig = getConfig(process.cwd(), 'cli').RESEARCH_MODEL;
282
291
 
283
- const model = modelEnv ?? modelConfig;
284
- const modelFrom = modelEnv ? 'PI_RESEARCH_MODEL' : modelConfig ? 'config.env' : 'unset';
292
+ const model = explicitModel ?? modelEnv ?? modelConfig;
293
+ const modelFrom = explicitModel ? '--model' : modelEnv ? 'PI_RESEARCH_MODEL' : modelConfig ? 'config.env' : 'unset';
285
294
  // Provider is either explicit, or inferred from a "provider/id" model string.
286
295
  const provider =
287
296
  providerEnv ?? (model && model.includes('/') ? model.slice(0, model.indexOf('/')) : undefined);
@@ -316,7 +325,7 @@ function detectCredentials(): CredentialDetection {
316
325
  piModelsPresent,
317
326
  problem:
318
327
  'PI_RESEARCH_API_KEY is set but no model is configured. Set PI_RESEARCH_MODEL to a ' +
319
- '"provider/model-id" (e.g. openai/gpt-4o).',
328
+ '"provider/model-id" (e.g. openai/gpt-4o), or pass --model <provider/id> for a single run.',
320
329
  };
321
330
  }
322
331
  return {
@@ -350,7 +359,8 @@ function detectCredentials(): CredentialDetection {
350
359
  'pi credentials were found (in pi\'s configuration), but no research model is configured. ' +
351
360
  'The standalone CLI / agent skill run only on an explicitly configured model — they do not ' +
352
361
  'follow the model selected inside the pi extension. Set PI_RESEARCH_MODEL to a ' +
353
- '"provider/model-id" (in the environment, or in config.env / cli.env below).',
362
+ '"provider/model-id" (in the environment, or in config.env / cli.env below), or pass ' +
363
+ '--model <provider/id> for a single run.',
354
364
  };
355
365
  }
356
366
  return {
@@ -401,8 +411,8 @@ function configBlock(det: CredentialDetection, extraNote?: string): string {
401
411
  lines.push(` • api key configured: ${det.apiKeyConfigured}`);
402
412
  lines.push(` • provider: ${det.provider ?? '(unset — inferred from a provider/model-id model)'}`);
403
413
  lines.push(` • model: ${det.model ? `${det.model} [from: ${det.modelFrom}]` : '(not set — set PI_RESEARCH_MODEL, required)'}`);
404
- lines.push(` • pi auth.json: ${det.piAuthPresent ? 'present' : 'absent'}`);
405
- lines.push(` • pi models.json: ${det.piModelsPresent ? 'present' : 'absent'}`);
414
+ lines.push(` • pi config (auth.json): ${det.piAuthPresent ? 'present' : 'absent'}`);
415
+ lines.push(` • pi config (models.json): ${det.piModelsPresent ? 'present' : 'absent'}`);
406
416
  if (extraNote) lines.push(` • note: ${extraNote}`);
407
417
  return lines.join('\n');
408
418
  }
@@ -425,7 +435,9 @@ interface ResearchArgs {
425
435
  * markdown report to stdout, and always shuts down (even on failure).
426
436
  */
427
437
  async function cmdResearch(args: ResearchArgs): Promise<number> {
428
- const det = detectCredentials();
438
+ // Pre-flight with the per-run --model flag folded in: it satisfies the
439
+ // model-required check and steers the provider-aware key detection.
440
+ const det = detectCredentials(args.model);
429
441
  if (det.problem) {
430
442
  if (args.json) toStdout(pretty({ ok: false, error: det.problem, exitCode: EXIT.CONFIG }));
431
443
  else toStderr(`\nError: ${det.problem}\n\n${configBlock(det)}\n`);
@@ -447,8 +459,8 @@ async function cmdResearch(args: ResearchArgs): Promise<number> {
447
459
 
448
460
  const depth: 0 | 1 | 2 | 3 =
449
461
  (args.depth ?? getConfig(process.cwd(), 'cli').DEFAULT_RESEARCH_DEPTH) as 0 | 1 | 2 | 3;
450
- // An explicit --model wins over configured/PI_RESEARCH_MODEL for this run.
451
- const runModel = args.model ?? det.model;
462
+ // det.model already reflects --model-first precedence (detectCredentials above).
463
+ const runModel = det.model;
452
464
  toStderr(`[pi-research] starting research (depth ${depth})${runModel ? ` with ${runModel}` : ''}…\n`);
453
465
 
454
466
  // Stream concise lifecycle progress to stderr so a long run is never silent.
@@ -877,7 +889,7 @@ USAGE
877
889
  COMMANDS
878
890
  research "<query>" Run multi-agent web research (default depth: 1).
879
891
  --depth <0-3> 0 = quick, 1 = normal (default), 2 = deep, 3 = ultra.
880
- --model <provider/id> Override the model for this run.
892
+ --model <provider/id> Model for this run (outranks PI_RESEARCH_MODEL; satisfies the required-model check).
881
893
  --exclude-tools <a,b> Disable internal tools (e.g. security_search,stackexchange).
882
894
  --initial-links <url ...> Seed URLs to investigate first; requires a query (-- ends options).
883
895
  --config <path> Use this config file instead of the base config.env.
@@ -914,9 +926,9 @@ CONFIGURE
914
926
  pi config (keys): ${p.piAuth}
915
927
  pi config (models): ${p.piModels}
916
928
 
917
- A model is REQUIRED: set PI_RESEARCH_MODEL to a "provider/model-id". This CLI and
918
- the agent skill run only on that configured model — they do not follow the model
919
- selected inside the pi extension.
929
+ A model is REQUIRED: set PI_RESEARCH_MODEL to a "provider/model-id" (or pass
930
+ --model per run). This CLI and the agent skill run only on that configured
931
+ model — they do not follow the model selected inside the pi extension.
920
932
 
921
933
  The pi extension has its own optional overlay file:
922
934
  pi extension: ${p.piIfaceEnv}
@@ -267,10 +267,12 @@ export class KnowledgeStore implements IKnowledgeStore {
267
267
  if (strategy !== 'drop' && strategy !== 'backup') {
268
268
  logger.warn('[store] Falling back to backup strategy after migration failure');
269
269
  await this.handleModelChange(storedModel || 'unknown', this.options.modelName, 'backup');
270
- } else if (strategy === 'backup') {
271
- logger.warn('[store] Falling back to drop strategy after backup failure');
272
- await this.handleModelChange(storedModel || 'unknown', this.options.modelName, 'drop');
273
270
  } else {
271
+ // 'backup' failure must ABORT, never escalate to 'drop': backup is the
272
+ // data-preserving strategy, and silently destroying every row after a
273
+ // (possibly transient) failure is the opposite of what the user asked
274
+ // for. The store stays on the old model; the next open() retries.
275
+ // 'drop' failure has nowhere further to fall.
274
276
  throw new Error(errorMsg, { cause: err });
275
277
  }
276
278
  }
@@ -332,8 +334,16 @@ export class KnowledgeStore implements IKnowledgeStore {
332
334
  throw new Error('Table not connected');
333
335
  }
334
336
 
335
- const count = await this.table.countRows();
336
-
337
+ // Best-effort: the count is only used for logging and the result summary, so a
338
+ // transient countRows() failure must not abort the backup (previously it threw
339
+ // while the fallback ladder could still escalate to a data-destroying drop).
340
+ let count = -1;
341
+ try {
342
+ count = await this.table.countRows();
343
+ } catch (err) {
344
+ logger.warn(`[store] countRows failed before backup (continuing): ${err instanceof Error ? err.message : String(err)}`);
345
+ }
346
+
337
347
  // Close current table handle
338
348
  this.table = null;
339
349
 
@@ -355,9 +365,9 @@ export class KnowledgeStore implements IKnowledgeStore {
355
365
  this.table = await this.createTable();
356
366
  // Table name unchanged (still canonical 'knowledge'), but persist to be safe.
357
367
  await this.saveManifest();
358
- logger.info(`[store] Migration complete: ${count} documents backed up, fresh table created with model ${newModel}`);
368
+ logger.info(`[store] Migration complete: ${count >= 0 ? count : 'unknown count of'} documents backed up, fresh table created with model ${newModel}`);
359
369
 
360
- return { strategy: 'backup', success: true, documentsProcessed: count };
370
+ return { strategy: 'backup', success: true, documentsProcessed: Math.max(0, count) };
361
371
  }
362
372
 
363
373
  private async migrationDrop(_oldModel: string, newModel: string): Promise<MigrationResult> {
@@ -17,7 +17,7 @@ import {
17
17
  import type {
18
18
  ResearchObserver,
19
19
  } from '../core/interfaces/observer-interfaces.ts';
20
- import { HeadlessObserver, type HeadlessObserverOptions } from './headless-observer.ts';
20
+ import { HeadlessObserver, makeSafeObserver, type HeadlessObserverOptions } from './headless-observer.ts';
21
21
  import {
22
22
  ServiceNames,
23
23
  type IResearchOrchestration,
@@ -61,11 +61,12 @@ export class DeepResearchOrchestrator {
61
61
  constructor(private options: DeepResearchOrchestratorOptions) {
62
62
  this.config = options.config || getConfig(options.ctx.cwd);
63
63
 
64
- // Resolve observer: if options were provided instead of an instance, create the instance
64
+ // Resolve observer: if options were provided instead of an instance, create the instance.
65
+ // Either way, wrap it so a throwing observer callback can never fail the run.
65
66
  if (options.observer && typeof (options.observer as any).onProgress === 'function' && !(options.observer instanceof HeadlessObserver)) {
66
- this.observer = new HeadlessObserver(options.observer as HeadlessObserverOptions);
67
+ this.observer = makeSafeObserver(new HeadlessObserver(options.observer as HeadlessObserverOptions));
67
68
  } else {
68
- this.observer = options.observer as ResearchObserver | undefined;
69
+ this.observer = options.observer ? makeSafeObserver(options.observer as ResearchObserver) : undefined;
69
70
  }
70
71
 
71
72
  if (options.orchestrationService) {
@@ -478,30 +479,40 @@ export class DeepResearchOrchestrator {
478
479
  }
479
480
 
480
481
  let result = finalReport.content || '';
481
-
482
- // If content is empty but we have reports, build a fallback synthesis
482
+
483
+ // ZERO reports collected AND researchers actually failed — the run attempted
484
+ // research and produced nothing groundable. This gate is deliberately
485
+ // independent of the synthesis text: with the default single-researcher plan
486
+ // the fast-stop threshold (MAX_FAILED_RESEARCHERS = 2) never trips, so a lone
487
+ // researcher that exhausts its retries (e.g. a provider rate limit) reaches
488
+ // synthesis with no source material. Whether the LLM then returns an empty
489
+ // string OR chatty refusal prose ("no information was found"), that is a
490
+ // FAILED run: throw, and surface the recorded root causes so the user sees
491
+ // the 429/model error, not a shrug (and never a source-free report shipped
492
+ // as success). The failed-researcher condition keeps the designed
493
+ // immediate-synthesis path intact: a coordinator that chose to answer
494
+ // directly (no delegation, no failures) is not a failed run.
495
+ const synthesisCheckService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
496
+ const hasReports = synthesisCheckService.hasReports(researchId);
497
+ const failedIds = getFailedResearchers(this.options.sessionId, researchId);
498
+ if (!hasReports && failedIds.length > 0) {
499
+ const reasons = getResearcherFailureReasons(this.options.sessionId, researchId);
500
+ const causes = failedIds
501
+ .map((id) => `researcher ${id}: ${reasons[id] ?? 'no usable report'}`)
502
+ .join('; ');
503
+ throw new Error(
504
+ `Research produced no report — no source material was collected. Causes: ${causes}`
505
+ );
506
+ }
507
+
483
508
  if (!result.trim()) {
484
- const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
485
- if (synthesisService.hasReports(researchId)) {
486
- logger.warn(`[DeepOrchestrator] LLM returned empty synthesis for ${researchId}, building fallback from ${synthesisService.getReportCount(researchId)} reports`);
487
- result = synthesisService.buildFallbackSynthesis(researchId, this.currentRound);
509
+ if (hasReports) {
510
+ // Content is empty but reports exist — build a fallback synthesis.
511
+ logger.warn(`[DeepOrchestrator] LLM returned empty synthesis for ${researchId}, building fallback from ${synthesisCheckService.getReportCount(researchId)} reports`);
512
+ result = synthesisCheckService.buildFallbackSynthesis(researchId, this.currentRound);
488
513
  } else {
489
- // ZERO reports were collected — the run produced nothing groundable.
490
- // With the default single-researcher plan the fast-stop threshold
491
- // (MAX_FAILED_RESEARCHERS = 2) never trips, so a lone researcher that
492
- // exhausts its retries (e.g. a provider rate limit) used to land here
493
- // and return a hollow "no summary was generated" string on the SUCCESS
494
- // path — exit 0. That is a FAILED run: throw, and surface the recorded
495
- // root causes so the user sees the 429/model error, not a shrug.
496
- const failedIds = getFailedResearchers(this.options.sessionId, researchId);
497
- const reasons = getResearcherFailureReasons(this.options.sessionId, researchId);
498
- const causes = failedIds
499
- .map((id) => `researcher ${id}: ${reasons[id] ?? 'no usable report'}`)
500
- .join('; ');
501
- throw new Error(
502
- 'Research produced no report — no source material was collected' +
503
- (causes ? `. Causes: ${causes}` : '.')
504
- );
514
+ // No reports, no recorded failures, and nothing to ship either.
515
+ throw new Error('Research produced no report — no source material was collected.');
505
516
  }
506
517
  }
507
518
 
@@ -12,6 +12,69 @@ import type { ResearchPlan } from '../core/interfaces/research-plan-types.ts';
12
12
 
13
13
  export type { HeadlessObserverOptions };
14
14
 
15
+ /**
16
+ * Wrap an observer so a throwing (or rejecting) callback can never corrupt a
17
+ * research run. Observer methods are fire-and-forget notifications, but the
18
+ * orchestrators invoke them inline at load-bearing points (researcher
19
+ * completion, evaluation decisions, synthesis start) — an exception from
20
+ * user-supplied SDK/TUI observer code would otherwise surface as a *research*
21
+ * failure. HeadlessObserver already isolates its own onProgress callback; this
22
+ * extends the same isolation to raw ResearchObserver implementations. Sync
23
+ * throws are logged and swallowed; returned promises get a .catch so
24
+ * rejections never become unhandled.
25
+ */
26
+ export function makeSafeObserver<T extends object>(observer: T): T {
27
+ // Proxy a FRESH empty object and close over the real observer instead of
28
+ // proxying the observer directly: a `get` trap on the observer itself may
29
+ // not substitute a wrapper for a non-writable non-configurable own data
30
+ // property (Proxy invariant), so a frozen observer — a common defensive
31
+ // pattern for SDK callback bags — would make the engine throw TypeError at
32
+ // the property ACCESS, outside every try/catch. An empty target has no own
33
+ // properties, so no invariant can ever bind the trap.
34
+ return new Proxy({} as T, {
35
+ get(_target, prop) {
36
+ // Guard the ACCESS too, and resolve getters against the observer (not
37
+ // the proxy): an observer exposing a method via an accessor that touches
38
+ // a private field would otherwise throw at `observer?.onX?.(...)`
39
+ // property access — outside the call-site try/catch below — which is
40
+ // exactly the failure this wrapper exists to absorb.
41
+ let value: unknown;
42
+ try {
43
+ value = Reflect.get(observer, prop, observer);
44
+ } catch (err) {
45
+ logger.warn(`[observer] Ignored error reading observer.${String(prop)}:`, err);
46
+ return undefined;
47
+ }
48
+ if (typeof value !== 'function') return value;
49
+ return (...args: unknown[]) => {
50
+ try {
51
+ const out = value.apply(observer, args);
52
+ if (out && typeof (out as Promise<unknown>).then === 'function') {
53
+ return (out as Promise<unknown>).catch((err: unknown) => {
54
+ logger.warn(`[observer] Ignored rejection from observer.${String(prop)}:`, err);
55
+ });
56
+ }
57
+ return out;
58
+ } catch (err) {
59
+ logger.warn(`[observer] Ignored error from observer.${String(prop)}:`, err);
60
+ return undefined;
61
+ }
62
+ };
63
+ },
64
+ has(_target, prop) {
65
+ try {
66
+ return Reflect.has(observer, prop);
67
+ } catch {
68
+ return false;
69
+ }
70
+ },
71
+ // Keep `instanceof` transparent (legal on an extensible target).
72
+ getPrototypeOf() {
73
+ return Reflect.getPrototypeOf(observer);
74
+ },
75
+ }) as T;
76
+ }
77
+
15
78
  export class HeadlessObserver implements ResearchObserver {
16
79
  constructor(private options: HeadlessObserverOptions = {}) {}
17
80
 
@@ -20,7 +20,7 @@ import { createResearcherSession } from './researcher.ts';
20
20
  import { ensureAssistantResponse, parseCitations } from '../utils/text-utils.ts';
21
21
  import { getMaxScrapeBatches } from '../constants.ts';
22
22
  import type { ResearchObserver } from '../core/interfaces/observer-interfaces.ts';
23
- import { HeadlessObserver, type HeadlessObserverOptions } from './headless-observer.ts';
23
+ import { HeadlessObserver, makeSafeObserver, type HeadlessObserverOptions } from './headless-observer.ts';
24
24
  import { getService, tryGetServiceContainerFromCtx } from '../core/service-registry.ts';
25
25
  import { ServiceNames, type IWriterQueue, type IKnowledgeStoreService, type IResearchSynthesisService, type IResearchOrchestration } from '../core/service-interfaces.ts';
26
26
  import type { ResearchSessionService } from './research-session-service.ts';
@@ -51,11 +51,12 @@ export class QuickResearchOrchestrator {
51
51
  constructor(private options: QuickResearchOrchestratorOptions) {
52
52
  this.config = options.config || getConfig(options.ctx.cwd);
53
53
 
54
- // Resolve observer: if options were provided instead of an instance, create the instance
54
+ // Resolve observer: if options were provided instead of an instance, create the instance.
55
+ // Either way, wrap it so a throwing observer callback can never fail the run.
55
56
  if (options.observer && typeof (options.observer as any).onProgress === 'function' && !(options.observer instanceof HeadlessObserver)) {
56
- this.observer = new HeadlessObserver(options.observer as HeadlessObserverOptions);
57
+ this.observer = makeSafeObserver(new HeadlessObserver(options.observer as HeadlessObserverOptions));
57
58
  } else {
58
- this.observer = options.observer as ResearchObserver | undefined;
59
+ this.observer = options.observer ? makeSafeObserver(options.observer as ResearchObserver) : undefined;
59
60
  }
60
61
  }
61
62
 
@@ -12,7 +12,7 @@
12
12
  import { parseCitations } from '../utils/text-utils.ts';
13
13
  import { normalizeCitations, formatCitedLinks, type GlobalCitation } from '../utils/citation-utils.ts';
14
14
  import { lastCitedLinksHeaderIndex } from '../utils/text-utils.ts';
15
- import { getScrapedLinks, normalizeUrl } from '../utils/shared-links.ts';
15
+ import { getScrapedLinks, isTranscribedLink, normalizeUrl } from '../utils/shared-links.ts';
16
16
  import { logger } from '../logger.ts';
17
17
  import { ServiceLifecycle, type IService } from '../core/service-registry.ts';
18
18
  import { ServiceNames } from '../core/interfaces/service-names.ts';
@@ -188,9 +188,17 @@ export class ResearchSynthesisService implements IService {
188
188
  if (globalCitations.length === 0) {
189
189
  const scraped = getScrapedLinks(sessionId);
190
190
  if (scraped.length > 0) {
191
- globalCitations = scraped.map((url, i) => ({ id: i + 1, url, description: '', source: 'Scrape' }));
191
+ // Label per actual provenance: the pool also contains transcript watch
192
+ // URLs the youtube_transcript tool registered — calling those "Scrape"
193
+ // misattributes the report's real source material.
194
+ globalCitations = scraped.map((url, i) => ({
195
+ id: i + 1,
196
+ url,
197
+ description: '',
198
+ source: isTranscribedLink(sessionId, url) ? 'YouTube Transcript' : 'Scrape',
199
+ }));
192
200
  logger.warn(
193
- `[ResearchSynthesisService] No citations parsed from reports; rebuilt ${scraped.length} source(s) from scrape provenance.`,
201
+ `[ResearchSynthesisService] No citations parsed from reports; rebuilt ${scraped.length} source(s) from scrape/transcript provenance.`,
194
202
  );
195
203
  }
196
204
  }
@@ -58,7 +58,7 @@ Write your report immediately after scraping is complete, or as soon as a tool r
58
58
  ## Guidelines
59
59
 
60
60
  - **Available Tools**:
61
- - `scrape`: Fetch and read web pages (primary tool). Focus your energy here.
61
+ - `scrape`: Fetch and read web pages (primary tool). Focus your energy here. Do NOT scrape YouTube video links (watch / youtu.be / shorts) — a watch page returns YouTube's app shell, not the video's content; read videos with `youtube_transcript`. Scraping a YouTube channel or playlist page to DISCOVER video links is fine.
62
62
  - `youtube_transcript`: Read the captions/transcript of YouTube videos from your source list — it is the `scrape` for video. ONE call only, batching up to a few of the most relevant YouTube links. Cite these as `Source: YouTube Transcript`.
63
63
  - `stackexchange`: Use ONLY for Stack Overflow or Stack Exchange URLs.
64
64
  - `security_search`: Query NVD, CVE, OSV, CISA databases.
package/src/sdk.ts CHANGED
@@ -30,7 +30,7 @@ import type {
30
30
  import type { Model } from '@earendil-works/pi-ai';
31
31
  import { type ExtensionContext, ModelRegistry } from '@earendil-works/pi-coding-agent';
32
32
  import { logger, createLogger, setLogger } from './logger.ts';
33
- import { getConfig, createConfig, validateConfig, type Config } from './config.ts';
33
+ import { getConfig, createConfig, validateConfig, resetConfig, type Config } from './config.ts';
34
34
  import { metrics, MetricsRegistry, runWithRunRegistry } from './utils/metrics.ts';
35
35
  import type { IMetricsSnapshot, RunSummary } from './utils/metrics.ts';
36
36
  import { extractRunStats, type ResearchStats } from './utils/metrics-summary.ts';
@@ -833,6 +833,12 @@ async function _doShutdown(): Promise<void> {
833
833
  _lastResearchId = null;
834
834
  _lastRunSummary = null;
835
835
  _lastErrorReport = null;
836
+ // The module-level config cache survives the globals above. Without this, a
837
+ // shutdown → env/config-file change → re-init cycle in one process silently
838
+ // reuses the first init's config. (The CLI/skill are unaffected — they pass
839
+ // ignoreGlobalConfig and their own config — but the SDK contract is that a
840
+ // fresh init reads fresh config.)
841
+ resetConfig();
836
842
 
837
843
  // Remove signal handlers — caller is shutting down cooperatively.
838
844
  _removeSignalHandlers();
@@ -122,6 +122,9 @@ async function fetchWithRetryImpl(url: string, options: RequestInit, signal?: Ab
122
122
  async () => {
123
123
  const response = await fetch(url, options);
124
124
  if (!response.ok) {
125
+ // Drain before throwing: an unconsumed error body pins its socket until
126
+ // GC, and this path runs on every retry of a failed sweep.
127
+ void response.body?.cancel()?.catch(() => { /* best-effort */ });
125
128
  const error = new Error(`HTTP ${response.status}: ${response.statusText}`) as Error & { status?: number };
126
129
  error.status = response.status;
127
130
  metrics.increment('cisa_kev_errors_total', 1, { endpoint, status_code: response.status.toString() });
@@ -116,6 +116,9 @@ export async function searchGitHubAdvisories(
116
116
  });
117
117
 
118
118
  if (!resp.ok) {
119
+ // Drain before throwing: an unconsumed error body pins its socket until
120
+ // GC, and the 403/5xx branches below are retried with backoff.
121
+ void resp.body?.cancel()?.catch(() => { /* best-effort */ });
119
122
  if (resp.status === 404) {
120
123
  throw new Error(`Repository "${owner}/${name}" not found or no access to security advisories.`);
121
124
  }
@@ -206,6 +209,8 @@ export async function searchGitHubAdvisories(
206
209
  });
207
210
 
208
211
  if (!resp.ok) {
212
+ // Same drain-before-throw as the repo-advisories fetch above.
213
+ void resp.body?.cancel()?.catch(() => { /* best-effort */ });
209
214
  if (resp.status === 404) {
210
215
  throw new Error('Advisory not found (HTTP 404)');
211
216
  }
@@ -327,6 +327,9 @@ function handleFetchError(error: unknown): never {
327
327
 
328
328
  function handleResponseStatus(response: Response): void {
329
329
  if (!response.ok) {
330
+ // Drain before throwing: an unconsumed error body pins its socket until GC,
331
+ // and this path runs on every retry of a rate-limited/5xx sweep.
332
+ void response.body?.cancel()?.catch(() => { /* best-effort */ });
330
333
  if (response.status === 429) {
331
334
  throw new Error('NVD API rate limit exceeded (HTTP 429). Retrying with backoff...');
332
335
  }
@@ -44,6 +44,9 @@ async function fetchWithRetry(
44
44
  async () => {
45
45
  const response = await fetch(url, options);
46
46
  if (!response.ok) {
47
+ // Drain before throwing: an unconsumed error body pins its socket until
48
+ // GC, and this path runs on every retry of a rate-limited/5xx sweep.
49
+ void response.body?.cancel()?.catch(() => { /* best-effort */ });
47
50
  const error = new Error(`HTTP ${response.status}: ${response.statusText}`) as Error & { status?: number };
48
51
  error.status = response.status;
49
52
  metrics.increment('osv_errors_total', 1, { endpoint, status_code: response.status.toString() });
@@ -20,7 +20,7 @@ import { Value } from 'typebox/value';
20
20
  import { youtubeTranscriptCommand } from '../youtube/index.ts';
21
21
  import type { ToolUsageTracker } from '../utils/tool-usage-tracker.ts';
22
22
  import type { SystemResearchState } from '../orchestration/deep-research-types.ts';
23
- import { cacheScrapedContent, registerScrapedLinks, registerResearcherScrapes } from '../utils/shared-links.ts';
23
+ import { cacheScrapedContent, registerScrapedLinks, registerTranscribedLinks, registerResearcherScrapes } from '../utils/shared-links.ts';
24
24
  import { type Config, getConfig } from '../config.ts';
25
25
  import { logger } from '../logger.ts';
26
26
  import { metrics } from '../utils/metrics.ts';
@@ -64,7 +64,7 @@ export function createYoutubeTranscriptTool(options: {
64
64
  'You may call this tool only ONCE, so choose the videos carefully before calling.',
65
65
  'Pass full watch URLs, youtu.be links, or shorts/embed/live links. Non-YouTube links are ignored.',
66
66
  'Videos without captions, or that are private/age-restricted, are reported as unavailable — the rest still succeed.',
67
- "When you cite a YouTube source in CITED LINKS, use the video's ACTUAL title and channel (both shown in this tool's output) as the description — e.g. \"'<exact title>' by <channel>\". Do NOT paraphrase or invent a title; the watch URL alone is not human-identifiable.",
67
+ "When you cite a YouTube source in CITED LINKS, the entry's link MUST be the video's watch URL (shown in this tool's output) — a title-only entry loses the source from the final report. Put the video's ACTUAL title and channel in the Description — e.g. \"'<exact title>' by <channel>\". Do NOT paraphrase or invent a title.",
68
68
  ],
69
69
  parameters: YoutubeParamsSchema,
70
70
  executionMode: 'parallel',
@@ -139,6 +139,10 @@ export function createYoutubeTranscriptTool(options: {
139
139
 
140
140
  if (researchId && cachedUrls.length > 0) {
141
141
  registerScrapedLinks(researchId, cachedUrls);
142
+ // Record the provenance subset: if the researcher's CITED LINKS block is
143
+ // unparseable, the synthesis fallback rebuilds sources from the link pool
144
+ // and must label these "YouTube Transcript", not "Scrape".
145
+ registerTranscribedLinks(researchId, cachedUrls);
142
146
  options.updateGlobalLinks?.(cachedUrls);
143
147
  if (options.researcherId) {
144
148
  registerResearcherScrapes(researchId, options.researcherId, cachedUrls);
@@ -17,6 +17,11 @@ const sessionLinks = new Map<string, Set<string>>();
17
17
  const sessionScrapedContent = new Map<string, Map<string, string>>();
18
18
  // Per-researcher scrape tracking: researchId → researcherId → Set<normalizedUrl>
19
19
  const researcherScrapes = new Map<string, Map<string, Set<string>>>();
20
+ // URLs whose content came from a YouTube TRANSCRIPT (not a page scrape). The
21
+ // transcript tool registers its watch URLs in the same sessionLinks pool so the
22
+ // post-round writer and citation fallback see them; this subset lets the
23
+ // fallback label them "YouTube Transcript" instead of "Scrape".
24
+ const transcribedUrls = new Map<string, Set<string>>();
20
25
 
21
26
  // FIX (Issue 12): Track creation timestamps for orphaned-entry cleanup.
22
27
  const sessionTimestamps = new Map<string, number>();
@@ -38,6 +43,7 @@ function startCleanupTimer(): void {
38
43
  sessionScrapedContent.delete(researchId);
39
44
  sessionTimestamps.delete(researchId);
40
45
  researcherScrapes.delete(researchId);
46
+ transcribedUrls.delete(researchId);
41
47
  cleaned++;
42
48
  }
43
49
  }
@@ -111,6 +117,24 @@ export function getScrapedLinks(researchId: string): string[] {
111
117
  return Array.from(sessionLinks.get(researchId) || []);
112
118
  }
113
119
 
120
+ /**
121
+ * Mark links as sourced from a YouTube transcript (call IN ADDITION to
122
+ * registerScrapedLinks — this only records the provenance subset).
123
+ */
124
+ export function registerTranscribedLinks(researchId: string, links: string[]) {
125
+ if (!transcribedUrls.has(researchId)) {
126
+ transcribedUrls.set(researchId, new Set());
127
+ }
128
+ sessionTimestamps.set(researchId, Date.now()); // last-activity TTL refresh (parity with registerScrapedLinks)
129
+ const pool = transcribedUrls.get(researchId)!;
130
+ links.forEach(l => pool.add(normalizeUrl(l)));
131
+ }
132
+
133
+ /** Whether a URL's content came from a YouTube transcript in this session. */
134
+ export function isTranscribedLink(researchId: string, url: string): boolean {
135
+ return transcribedUrls.get(researchId)?.has(normalizeUrl(url)) ?? false;
136
+ }
137
+
114
138
  /**
115
139
  * Deduplicate a list of candidate URLs against already scraped links.
116
140
  */
@@ -140,6 +164,7 @@ export function cleanupSharedLinks(researchId: string) {
140
164
  sessionScrapedContent.delete(researchId);
141
165
  sessionTimestamps.delete(researchId);
142
166
  researcherScrapes.delete(researchId);
167
+ transcribedUrls.delete(researchId);
143
168
  logger.debug(`[Shared Links] Cleaned up session: ${researchId}`);
144
169
  }
145
170
 
@@ -156,6 +181,7 @@ export function clearAllSharedLinks(): void {
156
181
  sessionScrapedContent.clear();
157
182
  sessionTimestamps.clear();
158
183
  researcherScrapes.clear();
184
+ transcribedUrls.clear();
159
185
  logger.debug('[Shared Links] All global state cleared');
160
186
  }
161
187
 
@@ -150,7 +150,10 @@ export function validateUrlForSSRFSync(url: string): boolean {
150
150
  *
151
151
  * FIX (#8): In addition to hostname pattern checks, resolves the hostname
152
152
  * via DNS and rejects any address that resolves to a private/reserved IP.
153
- * This defends against DNS rebinding and decimal/octal IP representations.
153
+ * This defends against DNS rebinding and AAAA-only private hosts. (Decimal/
154
+ * octal/hex IP encodings are handled earlier: WHATWG URL parsing normalizes
155
+ * them to dotted-quad, so the sync IP-literal gate blocks them — see the
156
+ * comment on the DNS pass below.)
154
157
  */
155
158
  export async function validateUrlForSSRF(url: string): Promise<void> {
156
159
  // Synchronous protocol/localhost/internal-pattern/IP-literal screen first.
@@ -161,8 +164,12 @@ export async function validateUrlForSSRF(url: string): Promise<void> {
161
164
  const hostname = new URL(url).hostname.toLowerCase().replace(/^\[|\]$/g, '');
162
165
 
163
166
  // Resolve hostname via DNS (both IPv4 and IPv6) and check all resulting addresses.
164
- // This catches DNS rebinding, decimal IP representations (2130706433),
165
- // AAAA-only hosts (::1, fc00::/7) and other hostname-based bypasses.
167
+ // This catches DNS rebinding, AAAA-only hosts (::1, fc00::/7) and other
168
+ // hostname-based bypasses. (It does NOT catch numeric-encoding forms like
169
+ // decimal 2130706433 — dns.resolve4/6 reject numeric hosts with EBADNAME — but
170
+ // it doesn't need to: WHATWG URL parsing normalizes decimal/octal/hex IPv4
171
+ // forms to dotted-quad before this function ever sees them, so the IP-literal
172
+ // gate in validateUrlForSSRFSync blocks them. Pinned by a regression test.)
166
173
  const [v4Results, v6Results] = await Promise.allSettled([
167
174
  dns.resolve4(hostname),
168
175
  dns.resolve6(hostname),