@lincoln504/pi-research 1.0.3 → 1.0.5

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/dist/cli.mjs CHANGED
@@ -3252,7 +3252,7 @@ function buildModelRegistry(apiKey, provider) {
3252
3252
  fs5.existsSync(modelsJsonPath) ? modelsJsonPath : void 0
3253
3253
  );
3254
3254
  }
3255
- if (fs5.existsSync(authPath)) {
3255
+ if (fs5.existsSync(authPath) || fs5.existsSync(modelsJsonPath)) {
3256
3256
  const authStorage = AuthStorage.create(authPath);
3257
3257
  return ModelRegistry.create(
3258
3258
  authStorage,
@@ -3266,7 +3266,17 @@ function constructMinimalModel(provider, modelId, _apiKey) {
3266
3266
  provider,
3267
3267
  id: modelId,
3268
3268
  name: modelId,
3269
- api: provider === "openai" ? "openai-completions" : provider + "-conversations",
3269
+ // Map to a REAL pi-ai api id — "<provider>-conversations" is not one, and a
3270
+ // keyed no-registry setup would pass pre-flight then die on the first call.
3271
+ // Unknown providers default to the OpenAI-compatible protocol, which is what
3272
+ // virtually every third-party endpoint (openrouter, groq, cerebras, …) speaks.
3273
+ api: {
3274
+ openai: "openai-completions",
3275
+ anthropic: "anthropic-messages",
3276
+ google: "google-generative-ai",
3277
+ gemini: "google-generative-ai",
3278
+ mistral: "mistral-conversations"
3279
+ }[provider] ?? "openai-completions",
3270
3280
  baseUrl: "",
3271
3281
  // Provider-specific base URLs are handled by pi-ai internal registry
3272
3282
  reasoning: false,
@@ -4702,9 +4712,6 @@ var init_store = __esm({
4702
4712
  if (strategy !== "drop" && strategy !== "backup") {
4703
4713
  logger.warn("[store] Falling back to backup strategy after migration failure");
4704
4714
  await this.handleModelChange(storedModel || "unknown", this.options.modelName, "backup");
4705
- } else if (strategy === "backup") {
4706
- logger.warn("[store] Falling back to drop strategy after backup failure");
4707
- await this.handleModelChange(storedModel || "unknown", this.options.modelName, "drop");
4708
4715
  } else {
4709
4716
  throw new Error(errorMsg, { cause: err });
4710
4717
  }
@@ -4753,7 +4760,12 @@ var init_store = __esm({
4753
4760
  if (!this.table || !this.db) {
4754
4761
  throw new Error("Table not connected");
4755
4762
  }
4756
- const count = await this.table.countRows();
4763
+ let count = -1;
4764
+ try {
4765
+ count = await this.table.countRows();
4766
+ } catch (err) {
4767
+ logger.warn(`[store] countRows failed before backup (continuing): ${err instanceof Error ? err.message : String(err)}`);
4768
+ }
4757
4769
  this.table = null;
4758
4770
  const oldDir = path13.join(this.options.dbDir, `${this.tableName}.lance`);
4759
4771
  const backupDir = path13.join(this.options.dbDir, `${backupTableName}.lance`);
@@ -4768,8 +4780,8 @@ var init_store = __esm({
4768
4780
  }
4769
4781
  this.table = await this.createTable();
4770
4782
  await this.saveManifest();
4771
- logger.info(`[store] Migration complete: ${count} documents backed up, fresh table created with model ${newModel}`);
4772
- return { strategy: "backup", success: true, documentsProcessed: count };
4783
+ logger.info(`[store] Migration complete: ${count >= 0 ? count : "unknown count of"} documents backed up, fresh table created with model ${newModel}`);
4784
+ return { strategy: "backup", success: true, documentsProcessed: Math.max(0, count) };
4773
4785
  }
4774
4786
  async migrationDrop(_oldModel, newModel) {
4775
4787
  logger.warn(`[store] Dropping table and recreating with model ${newModel} (data will be lost)`);
@@ -9358,6 +9370,7 @@ init_constants();
9358
9370
  var sessionLinks = /* @__PURE__ */ new Map();
9359
9371
  var sessionScrapedContent = /* @__PURE__ */ new Map();
9360
9372
  var researcherScrapes = /* @__PURE__ */ new Map();
9373
+ var transcribedUrls = /* @__PURE__ */ new Map();
9361
9374
  var sessionTimestamps = /* @__PURE__ */ new Map();
9362
9375
  var SESSION_MAX_AGE_MS = 2 * 60 * 60 * 1e3;
9363
9376
  var MAX_CACHED_CONTENT_PER_SESSION = 500;
@@ -9373,6 +9386,7 @@ function startCleanupTimer() {
9373
9386
  sessionScrapedContent.delete(researchId);
9374
9387
  sessionTimestamps.delete(researchId);
9375
9388
  researcherScrapes.delete(researchId);
9389
+ transcribedUrls.delete(researchId);
9376
9390
  cleaned++;
9377
9391
  }
9378
9392
  }
@@ -9410,6 +9424,17 @@ function registerScrapedLinks(researchId, links) {
9410
9424
  function getScrapedLinks(researchId) {
9411
9425
  return Array.from(sessionLinks.get(researchId) || []);
9412
9426
  }
9427
+ function registerTranscribedLinks(researchId, links) {
9428
+ if (!transcribedUrls.has(researchId)) {
9429
+ transcribedUrls.set(researchId, /* @__PURE__ */ new Set());
9430
+ }
9431
+ sessionTimestamps.set(researchId, Date.now());
9432
+ const pool = transcribedUrls.get(researchId);
9433
+ links.forEach((l) => pool.add(normalizeUrl(l)));
9434
+ }
9435
+ function isTranscribedLink(researchId, url) {
9436
+ return transcribedUrls.get(researchId)?.has(normalizeUrl(url)) ?? false;
9437
+ }
9413
9438
  function deduplicateUrls(urls, researchId) {
9414
9439
  const pool = sessionLinks.get(researchId) || /* @__PURE__ */ new Set();
9415
9440
  const kept = [];
@@ -9429,6 +9454,7 @@ function cleanupSharedLinks(researchId) {
9429
9454
  sessionScrapedContent.delete(researchId);
9430
9455
  sessionTimestamps.delete(researchId);
9431
9456
  researcherScrapes.delete(researchId);
9457
+ transcribedUrls.delete(researchId);
9432
9458
  logger.debug(`[Shared Links] Cleaned up session: ${researchId}`);
9433
9459
  }
9434
9460
  function clearAllSharedLinks() {
@@ -9440,6 +9466,7 @@ function clearAllSharedLinks() {
9440
9466
  sessionScrapedContent.clear();
9441
9467
  sessionTimestamps.clear();
9442
9468
  researcherScrapes.clear();
9469
+ transcribedUrls.clear();
9443
9470
  logger.debug("[Shared Links] All global state cleared");
9444
9471
  }
9445
9472
  function registerResearcherScrapes(researchId, researcherId, urls) {
@@ -15699,6 +15726,48 @@ function decodeHtmlBody(bytes, contentTypeHeader) {
15699
15726
  return new TextDecoder("utf-8").decode(bytes);
15700
15727
  }
15701
15728
  }
15729
+ var BodyTooLargeError = class extends Error {
15730
+ constructor(bytesRead, maxBytes) {
15731
+ super(`Response body exceeded ${maxBytes} byte cap`);
15732
+ this.bytesRead = bytesRead;
15733
+ this.maxBytes = maxBytes;
15734
+ this.name = "BodyTooLargeError";
15735
+ }
15736
+ bytesRead;
15737
+ maxBytes;
15738
+ };
15739
+ async function readBodyCapped(response, maxBytes) {
15740
+ const body = response.body;
15741
+ if (!body || typeof body.getReader !== "function") {
15742
+ return new Uint8Array(await response.arrayBuffer());
15743
+ }
15744
+ const reader = body.getReader();
15745
+ const chunks = [];
15746
+ let total = 0;
15747
+ try {
15748
+ for (; ; ) {
15749
+ const { done, value } = await reader.read();
15750
+ if (done) break;
15751
+ if (value) {
15752
+ total += value.byteLength;
15753
+ if (total > maxBytes) throw new BodyTooLargeError(total, maxBytes);
15754
+ chunks.push(value);
15755
+ }
15756
+ }
15757
+ } finally {
15758
+ try {
15759
+ await reader.cancel();
15760
+ } catch {
15761
+ }
15762
+ }
15763
+ const out = new Uint8Array(total);
15764
+ let offset = 0;
15765
+ for (const c of chunks) {
15766
+ out.set(c, offset);
15767
+ offset += c.byteLength;
15768
+ }
15769
+ return out;
15770
+ }
15702
15771
  async function scrapeWithFetch(url, signal) {
15703
15772
  await validateUrlForSSRF(url);
15704
15773
  const dispatcher = await getSsrfSafeDispatcher();
@@ -15734,6 +15803,10 @@ async function scrapeWithFetch(url, signal) {
15734
15803
  if (!location) break;
15735
15804
  const resolved = new URL(location, currentUrl).href;
15736
15805
  await validateUrlForSSRF(resolved);
15806
+ try {
15807
+ await response.body?.cancel();
15808
+ } catch {
15809
+ }
15737
15810
  currentUrl = resolved;
15738
15811
  continue;
15739
15812
  }
@@ -15741,6 +15814,10 @@ async function scrapeWithFetch(url, signal) {
15741
15814
  }
15742
15815
  if (!response.ok) {
15743
15816
  metrics.increment("scrape_errors_total", 1, { error_type: "http_error", status_code: String(response.status) });
15817
+ try {
15818
+ await response.body?.cancel();
15819
+ } catch {
15820
+ }
15744
15821
  throw new Error(`HTTP ${response.status}`);
15745
15822
  }
15746
15823
  const contentType = response.headers.get("content-type") ?? "";
@@ -15753,24 +15830,62 @@ async function scrapeWithFetch(url, signal) {
15753
15830
  const sizeMB = Math.round(size / 1024 / 1024);
15754
15831
  logger.warn(`[Scrapers] PDF too large (Content-Length: ${sizeMB}MB, max 100MB), skipping`);
15755
15832
  metrics.increment("scrape_pdf_errors_total", 1, { error_type: "size_exceeded" });
15833
+ try {
15834
+ await response.body?.cancel();
15835
+ } catch {
15836
+ }
15756
15837
  throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
15757
15838
  }
15758
15839
  } else if (size > MAX_HTML_SIZE) {
15759
15840
  const sizeMB = Math.round(size / 1024 / 1024);
15760
15841
  logger.warn(`[Scrapers] HTML response too large (Content-Length: ${sizeMB}MB, max 25MB), skipping`);
15761
15842
  metrics.increment("scrape_errors_total", 1, { error_type: "size_exceeded", content_type: contentType.split(";")[0] || "unknown" });
15843
+ try {
15844
+ await response.body?.cancel();
15845
+ } catch {
15846
+ }
15762
15847
  throw new Error(`HTML response too large (${sizeMB}MB, max 25MB)`);
15763
15848
  }
15764
15849
  }
15765
15850
  if (contentType.includes("application/pdf") || url.toLowerCase().endsWith(".pdf")) {
15766
- const buffer = await response.arrayBuffer();
15767
- const markdown2 = await extractPdfToMarkdown(new Uint8Array(buffer));
15851
+ let pdfBytes;
15852
+ try {
15853
+ pdfBytes = await readBodyCapped(response, MAX_PDF_SIZE);
15854
+ } catch (e) {
15855
+ if (e instanceof BodyTooLargeError) {
15856
+ const sizeMB = Math.round(e.bytesRead / 1024 / 1024);
15857
+ logger.warn(`[Scrapers] PDF too large (streamed ${sizeMB}MB, max 100MB), skipping`);
15858
+ metrics.increment("scrape_pdf_errors_total", 1, { error_type: "size_exceeded" });
15859
+ throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`, { cause: e });
15860
+ }
15861
+ throw e;
15862
+ }
15863
+ const markdown2 = await extractPdfToMarkdown(pdfBytes);
15768
15864
  validateContent("", markdown2, url);
15769
15865
  metrics.increment("scrape_operations_total", 1, { layer: "fetch", content_type: "pdf", status: "success" });
15770
15866
  metrics.observe("scrape_latency_ms", fetchDuration, { layer: "fetch", content_type: "pdf", status: "success" });
15771
15867
  return { source: "fetch", layer: "fetch", markdown: markdown2 };
15772
15868
  }
15773
- const bodyBytes = new Uint8Array(await response.arrayBuffer());
15869
+ let bodyBytes;
15870
+ try {
15871
+ bodyBytes = await readBodyCapped(response, MAX_HTML_SIZE);
15872
+ } catch (e) {
15873
+ if (e instanceof BodyTooLargeError) {
15874
+ const sizeMB = Math.round(e.bytesRead / 1024 / 1024);
15875
+ logger.warn(`[Scrapers] HTML response too large (streamed ${sizeMB}MB, max 25MB), skipping`);
15876
+ metrics.increment("scrape_errors_total", 1, { error_type: "size_exceeded", content_type: "html" });
15877
+ errorTracker.trackError(new Error(`HTML response too large (${sizeMB}MB, max 25MB)`), {
15878
+ component: "scrapers",
15879
+ operation: "fetch",
15880
+ url,
15881
+ domain: extractDomain(url),
15882
+ layer: "fetch",
15883
+ contentType: "html"
15884
+ });
15885
+ throw new Error(`HTML response too large (${sizeMB}MB, max 25MB)`, { cause: e });
15886
+ }
15887
+ throw e;
15888
+ }
15774
15889
  if (bodyBytes.length > MAX_HTML_SIZE) {
15775
15890
  const sizeMB = Math.round(bodyBytes.length / 1024 / 1024);
15776
15891
  logger.warn(`[Scrapers] HTML response too large (actual: ${sizeMB}MB, max 25MB), skipping`);
@@ -16468,6 +16583,8 @@ function handleFetchError(error) {
16468
16583
  }
16469
16584
  function handleResponseStatus(response) {
16470
16585
  if (!response.ok) {
16586
+ void response.body?.cancel()?.catch(() => {
16587
+ });
16471
16588
  if (response.status === 429) {
16472
16589
  throw new Error("NVD API rate limit exceeded (HTTP 429). Retrying with backoff...");
16473
16590
  }
@@ -16662,6 +16779,8 @@ async function fetchWithRetryImpl(url, options, signal) {
16662
16779
  async () => {
16663
16780
  const response = await fetch(url, options);
16664
16781
  if (!response.ok) {
16782
+ void response.body?.cancel()?.catch(() => {
16783
+ });
16665
16784
  const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
16666
16785
  error.status = response.status;
16667
16786
  metrics.increment("cisa_kev_errors_total", 1, { endpoint, status_code: response.status.toString() });
@@ -17018,6 +17137,8 @@ async function searchGitHubAdvisories(terms, options) {
17018
17137
  signal: createTimeoutSignal(1e4, options?.signal)
17019
17138
  });
17020
17139
  if (!resp.ok) {
17140
+ void resp.body?.cancel()?.catch(() => {
17141
+ });
17021
17142
  if (resp.status === 404) {
17022
17143
  throw new Error(`Repository "${owner}/${name}" not found or no access to security advisories.`);
17023
17144
  }
@@ -17085,6 +17206,8 @@ async function searchGitHubAdvisories(terms, options) {
17085
17206
  signal: createTimeoutSignal(1e4, options?.signal)
17086
17207
  });
17087
17208
  if (!resp.ok) {
17209
+ void resp.body?.cancel()?.catch(() => {
17210
+ });
17088
17211
  if (resp.status === 404) {
17089
17212
  throw new Error("Advisory not found (HTTP 404)");
17090
17213
  }
@@ -17405,6 +17528,8 @@ async function fetchWithRetry2(url, options, signal) {
17405
17528
  async () => {
17406
17529
  const response = await fetch(url, options);
17407
17530
  if (!response.ok) {
17531
+ void response.body?.cancel()?.catch(() => {
17532
+ });
17408
17533
  const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
17409
17534
  error.status = response.status;
17410
17535
  metrics.increment("osv_errors_total", 1, { endpoint, status_code: response.status.toString() });
@@ -19143,6 +19268,8 @@ async function fetchOne(session, videoId, contentToken, lang, timeoutMs, signal,
19143
19268
  headers: { "User-Agent": "Mozilla/5.0", "Accept-Language": lang }
19144
19269
  });
19145
19270
  if (!response.ok) {
19271
+ void response.body?.cancel()?.catch(() => {
19272
+ });
19146
19273
  throw new Error(`HTTP ${response.status} from timedtext endpoint`);
19147
19274
  }
19148
19275
  const body = await response.text();
@@ -19319,7 +19446,7 @@ function formatResults(results, rejected) {
19319
19446
  if (meta.length) md += `**${meta.join(" \xB7 ")}**
19320
19447
  `;
19321
19448
  if (r.title) {
19322
- md += `**Cite as:** '${r.title}'${r.author ? ` by ${r.author}` : ""}
19449
+ md += `**Cite as:** ${r.url} \u2014 '${r.title}'${r.author ? ` by ${r.author}` : ""}
19323
19450
  `;
19324
19451
  }
19325
19452
  md += `
@@ -19378,7 +19505,7 @@ function createYoutubeTranscriptTool(options) {
19378
19505
  "You may call this tool only ONCE, so choose the videos carefully before calling.",
19379
19506
  "Pass full watch URLs, youtu.be links, or shorts/embed/live links. Non-YouTube links are ignored.",
19380
19507
  "Videos without captions, or that are private/age-restricted, are reported as unavailable \u2014 the rest still succeed.",
19381
- `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 \u2014 e.g. "'<exact title>' by <channel>". Do NOT paraphrase or invent a title; the watch URL alone is not human-identifiable.`
19508
+ `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) \u2014 a title-only entry loses the source from the final report. Put the video's ACTUAL title and channel in the Description \u2014 e.g. "'<exact title>' by <channel>". Do NOT paraphrase or invent a title.`
19382
19509
  ],
19383
19510
  parameters: YoutubeParamsSchema,
19384
19511
  executionMode: "parallel",
@@ -19429,6 +19556,7 @@ function createYoutubeTranscriptTool(options) {
19429
19556
  });
19430
19557
  if (researchId && cachedUrls.length > 0) {
19431
19558
  registerScrapedLinks(researchId, cachedUrls);
19559
+ registerTranscribedLinks(researchId, cachedUrls);
19432
19560
  options.updateGlobalLinks?.(cachedUrls);
19433
19561
  if (options.researcherId) {
19434
19562
  registerResearcherScrapes(researchId, options.researcherId, cachedUrls);
@@ -19812,6 +19940,7 @@ function getPiState(piSessionId) {
19812
19940
  if (!state) {
19813
19941
  state = {
19814
19942
  failures: /* @__PURE__ */ new Map(),
19943
+ failureReasons: /* @__PURE__ */ new Map(),
19815
19944
  order: [],
19816
19945
  panels: /* @__PURE__ */ new Map(),
19817
19946
  aborts: /* @__PURE__ */ new Map(),
@@ -19883,6 +20012,7 @@ function refreshAllSessions(piSessionId) {
19883
20012
  for (const id of state.order) {
19884
20013
  if (!validIds.includes(id)) {
19885
20014
  state.failures.delete(id);
20015
+ state.failureReasons.delete(id);
19886
20016
  state.panels.delete(id);
19887
20017
  }
19888
20018
  }
@@ -19914,6 +20044,7 @@ function endResearchSession(piSessionId, researchId) {
19914
20044
  const state = piSessions.get(sid);
19915
20045
  if (!state) return;
19916
20046
  state.failures.delete(researchId);
20047
+ state.failureReasons.delete(researchId);
19917
20048
  state.panels.delete(researchId);
19918
20049
  state.aborts.delete(researchId);
19919
20050
  const index = state.order.indexOf(researchId);
@@ -19931,6 +20062,7 @@ function endResearchSession(piSessionId, researchId) {
19931
20062
  piSessions.delete(sid);
19932
20063
  } else {
19933
20064
  state.failures.clear();
20065
+ state.failureReasons.clear();
19934
20066
  state.aborts.clear();
19935
20067
  }
19936
20068
  }
@@ -19952,12 +20084,23 @@ function clearAllSessionState() {
19952
20084
  clearAllSharedLinks();
19953
20085
  logger.log("[session-state] All session state cleared");
19954
20086
  }
19955
- function recordResearcherFailure(piSessionId, researchId, researcherId) {
20087
+ function recordResearcherFailure(piSessionId, researchId, researcherId, reason) {
19956
20088
  const sid = normalizeSessionId(piSessionId);
19957
20089
  const state = getPiState(sid);
19958
20090
  const failures = state.failures.get(researchId) || [];
19959
20091
  failures.push(researcherId);
19960
20092
  state.failures.set(researchId, failures);
20093
+ if (reason) {
20094
+ const reasons = state.failureReasons.get(researchId) ?? /* @__PURE__ */ new Map();
20095
+ if (!reasons.has(researcherId)) reasons.set(researcherId, reason);
20096
+ state.failureReasons.set(researchId, reasons);
20097
+ }
20098
+ }
20099
+ function getResearcherFailureReasons(piSessionId, researchId) {
20100
+ const sid = normalizeSessionId(piSessionId);
20101
+ const state = getPiState(sid);
20102
+ const reasons = state.failureReasons.get(researchId);
20103
+ return reasons ? Object.fromEntries(reasons) : {};
19961
20104
  }
19962
20105
  function getFailedResearchers(piSessionId, researchId) {
19963
20106
  const sid = normalizeSessionId(piSessionId);
@@ -19974,9 +20117,13 @@ function getResearchStopMessage(piSessionId, researchId) {
19974
20117
  const sid = normalizeSessionId(piSessionId);
19975
20118
  const failed = getFailedResearchers(sid, researchId);
19976
20119
  const count = failed.length;
20120
+ const state = getPiState(sid);
20121
+ const reasons = state.failureReasons.get(researchId);
20122
+ const recorded = reasons && reasons.size > 0 ? ["Recorded errors:", ...[...reasons].map(([id, r]) => `\u2022 researcher ${id}: ${r}`), ""] : [];
19977
20123
  return [
19978
20124
  `Research stopped: ${count} researcher(s) did not return a usable report: ${failed.join(", ")}.`,
19979
20125
  "",
20126
+ ...recorded,
19980
20127
  "Most likely cause \u2014 the research model could not produce grounded results:",
19981
20128
  '\u2022 Try a more capable research model. Very small or "thinking-only" models often',
19982
20129
  " emit no final text after their tool calls, which fails every researcher.",
@@ -20026,7 +20173,7 @@ ${currentPlan.allQueries.map((q) => `- ${q}`).join("\n")}
20026
20173
  const researcherPromptTemplate = loadPrompt("researcher");
20027
20174
  if (initialLinks.length === 0 && historicalUrls.length === 0) {
20028
20175
  logger.warn(`[ResearcherExecutor] Researcher ${id} has no initial search results or historical links; skipping.`);
20029
- recordResearcherFailure(sessionId, researchId, id);
20176
+ recordResearcherFailure(sessionId, researchId, id, "no initial search results or historical links (search produced nothing usable)");
20030
20177
  metrics.increment("researcher_skipped_total", 1, { mode: "deep", complexity: String(complexity), reason: "no_initial_links" });
20031
20178
  observer?.onResearcherFailure?.(id, "No initial search results or historical links available");
20032
20179
  return;
@@ -20280,6 +20427,34 @@ init_constants();
20280
20427
 
20281
20428
  // src/orchestration/headless-observer.ts
20282
20429
  init_logger();
20430
+ function makeSafeObserver(observer) {
20431
+ return new Proxy(observer, {
20432
+ get(target, prop) {
20433
+ let value;
20434
+ try {
20435
+ value = Reflect.get(target, prop, target);
20436
+ } catch (err) {
20437
+ logger.warn(`[observer] Ignored error reading observer.${String(prop)}:`, err);
20438
+ return void 0;
20439
+ }
20440
+ if (typeof value !== "function") return value;
20441
+ return (...args) => {
20442
+ try {
20443
+ const out = value.apply(target, args);
20444
+ if (out && typeof out.then === "function") {
20445
+ return out.catch((err) => {
20446
+ logger.warn(`[observer] Ignored rejection from observer.${String(prop)}:`, err);
20447
+ });
20448
+ }
20449
+ return out;
20450
+ } catch (err) {
20451
+ logger.warn(`[observer] Ignored error from observer.${String(prop)}:`, err);
20452
+ return void 0;
20453
+ }
20454
+ };
20455
+ }
20456
+ });
20457
+ }
20283
20458
  var HeadlessObserver = class {
20284
20459
  constructor(options = {}) {
20285
20460
  this.options = options;
@@ -20369,9 +20544,9 @@ var QuickResearchOrchestrator = class {
20369
20544
  this.options = options;
20370
20545
  this.config = options.config || getConfig(options.ctx.cwd);
20371
20546
  if (options.observer && typeof options.observer.onProgress === "function" && !(options.observer instanceof HeadlessObserver)) {
20372
- this.observer = new HeadlessObserver(options.observer);
20547
+ this.observer = makeSafeObserver(new HeadlessObserver(options.observer));
20373
20548
  } else {
20374
- this.observer = options.observer;
20549
+ this.observer = options.observer ? makeSafeObserver(options.observer) : void 0;
20375
20550
  }
20376
20551
  }
20377
20552
  options;
@@ -20685,9 +20860,9 @@ var DeepResearchOrchestrator = class {
20685
20860
  this.options = options;
20686
20861
  this.config = options.config || getConfig(options.ctx.cwd);
20687
20862
  if (options.observer && typeof options.observer.onProgress === "function" && !(options.observer instanceof HeadlessObserver)) {
20688
- this.observer = new HeadlessObserver(options.observer);
20863
+ this.observer = makeSafeObserver(new HeadlessObserver(options.observer));
20689
20864
  } else {
20690
- this.observer = options.observer;
20865
+ this.observer = options.observer ? makeSafeObserver(options.observer) : void 0;
20691
20866
  }
20692
20867
  if (options.orchestrationService) {
20693
20868
  this.orchestrationService = options.orchestrationService;
@@ -20960,13 +21135,22 @@ var DeepResearchOrchestrator = class {
20960
21135
  observer?.onEvaluationDecision?.("synthesize", finalReport, maxRounds);
20961
21136
  }
20962
21137
  let result = finalReport.content || "";
21138
+ const synthesisCheckService = await getService(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
21139
+ const hasReports = synthesisCheckService.hasReports(researchId);
21140
+ const failedIds = getFailedResearchers(this.options.sessionId, researchId);
21141
+ if (!hasReports && failedIds.length > 0) {
21142
+ const reasons = getResearcherFailureReasons(this.options.sessionId, researchId);
21143
+ const causes = failedIds.map((id) => `researcher ${id}: ${reasons[id] ?? "no usable report"}`).join("; ");
21144
+ throw new Error(
21145
+ `Research produced no report \u2014 no source material was collected. Causes: ${causes}`
21146
+ );
21147
+ }
20963
21148
  if (!result.trim()) {
20964
- const synthesisService2 = await getService(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
20965
- if (synthesisService2.hasReports(researchId)) {
20966
- logger.warn(`[DeepOrchestrator] LLM returned empty synthesis for ${researchId}, building fallback from ${synthesisService2.getReportCount(researchId)} reports`);
20967
- result = synthesisService2.buildFallbackSynthesis(researchId, this.currentRound);
21149
+ if (hasReports) {
21150
+ logger.warn(`[DeepOrchestrator] LLM returned empty synthesis for ${researchId}, building fallback from ${synthesisCheckService.getReportCount(researchId)} reports`);
21151
+ result = synthesisCheckService.buildFallbackSynthesis(researchId, this.currentRound);
20968
21152
  } else {
20969
- result = "Research completed but no summary was generated.";
21153
+ throw new Error("Research produced no report \u2014 no source material was collected.");
20970
21154
  }
20971
21155
  }
20972
21156
  try {
@@ -21268,7 +21452,7 @@ var ResearchOrchestrationService = class {
21268
21452
  logger.debug(`[ResearchOrchestrationService] Researcher ${id} cancelled (aborted).`);
21269
21453
  } else {
21270
21454
  logger.error(`[ResearchOrchestrationService] Researcher ${id} failed: ${errMsg}`);
21271
- recordResearcherFailure(sessionId, researchId, id);
21455
+ recordResearcherFailure(sessionId, researchId, id, errMsg);
21272
21456
  observer?.onResearcherFailure?.(id, errMsg);
21273
21457
  }
21274
21458
  }
@@ -21697,9 +21881,14 @@ Research performed using ${modelId}`;
21697
21881
  if (globalCitations.length === 0) {
21698
21882
  const scraped = getScrapedLinks(sessionId);
21699
21883
  if (scraped.length > 0) {
21700
- globalCitations = scraped.map((url, i) => ({ id: i + 1, url, description: "", source: "Scrape" }));
21884
+ globalCitations = scraped.map((url, i) => ({
21885
+ id: i + 1,
21886
+ url,
21887
+ description: "",
21888
+ source: isTranscribedLink(sessionId, url) ? "YouTube Transcript" : "Scrape"
21889
+ }));
21701
21890
  logger.warn(
21702
- `[ResearchSynthesisService] No citations parsed from reports; rebuilt ${scraped.length} source(s) from scrape provenance.`
21891
+ `[ResearchSynthesisService] No citations parsed from reports; rebuilt ${scraped.length} source(s) from scrape/transcript provenance.`
21703
21892
  );
21704
21893
  }
21705
21894
  }
@@ -22000,6 +22189,10 @@ async function _doInit(options = {}) {
22000
22189
  }
22001
22190
  globalApiKey = options.apiKey || process.env["PI_RESEARCH_API_KEY"];
22002
22191
  let parsedProvider = options.provider || process.env["PI_RESEARCH_PROVIDER"];
22192
+ if (options.model) {
22193
+ const explicit = typeof options.model === "string" ? options.model : `${options.model.provider}/${options.model.id}`;
22194
+ globalConfig = { ...globalConfig, RESEARCH_MODEL: explicit };
22195
+ }
22003
22196
  const effectiveModel = options.model ?? globalConfig.RESEARCH_MODEL;
22004
22197
  if (!parsedProvider && effectiveModel) {
22005
22198
  if (typeof effectiveModel === "string") {
@@ -22229,6 +22422,7 @@ async function _doShutdown() {
22229
22422
  _lastResearchId = null;
22230
22423
  _lastRunSummary = null;
22231
22424
  _lastErrorReport = null;
22425
+ resetConfig();
22232
22426
  _removeSignalHandlers();
22233
22427
  if (errors.length > 0) {
22234
22428
  throw new AggregateError(errors, `${errors.length} error(s) during SDK shutdown`);
@@ -22475,6 +22669,7 @@ Total cost: ${costStr}`;
22475
22669
 
22476
22670
  // src/cli.ts
22477
22671
  init_config();
22672
+ init_model_registry_factory();
22478
22673
  import { getAgentDir as getAgentDir2 } from "@earendil-works/pi-coding-agent";
22479
22674
  function readPkgVersion() {
22480
22675
  try {
@@ -22593,7 +22788,16 @@ function fileExists(p) {
22593
22788
  return false;
22594
22789
  }
22595
22790
  }
22596
- function detectCredentials() {
22791
+ function piKeysAvailable(piAuthPresent, provider) {
22792
+ try {
22793
+ const available = safeGetAvailable(buildModelRegistry(void 0, void 0));
22794
+ if (!provider) return available.length > 0;
22795
+ return available.some((m) => m.provider === provider);
22796
+ } catch {
22797
+ return piAuthPresent;
22798
+ }
22799
+ }
22800
+ function detectCredentials(explicitModel) {
22597
22801
  const paths = resolvedConfigPaths();
22598
22802
  const piAuthPresent = fileExists(paths.piAuth);
22599
22803
  const piModelsPresent = fileExists(paths.piModels);
@@ -22601,9 +22805,10 @@ function detectCredentials() {
22601
22805
  const providerEnv = process.env["PI_RESEARCH_PROVIDER"];
22602
22806
  const modelEnv = process.env["PI_RESEARCH_MODEL"];
22603
22807
  const modelConfig = getConfig(process.cwd(), "cli").RESEARCH_MODEL;
22604
- const model = modelEnv ?? modelConfig;
22605
- const modelFrom = modelEnv ? "PI_RESEARCH_MODEL" : modelConfig ? "config.env" : "unset";
22808
+ const model = explicitModel ?? modelEnv ?? modelConfig;
22809
+ const modelFrom = explicitModel ? "--model" : modelEnv ? "PI_RESEARCH_MODEL" : modelConfig ? "config.env" : "unset";
22606
22810
  const provider = providerEnv ?? (model && model.includes("/") ? model.slice(0, model.indexOf("/")) : void 0);
22811
+ const piKeysUsable = piKeysAvailable(piAuthPresent, provider);
22607
22812
  if (apiKey) {
22608
22813
  if (!provider) {
22609
22814
  return {
@@ -22626,7 +22831,7 @@ function detectCredentials() {
22626
22831
  modelFrom,
22627
22832
  piAuthPresent,
22628
22833
  piModelsPresent,
22629
- problem: 'PI_RESEARCH_API_KEY is set but no model is configured. Set PI_RESEARCH_MODEL to a "provider/model-id" (e.g. openai/gpt-4o).'
22834
+ problem: 'PI_RESEARCH_API_KEY is set but no model is configured. Set PI_RESEARCH_MODEL to a "provider/model-id" (e.g. openai/gpt-4o), or pass --model <provider/id> for a single run.'
22630
22835
  };
22631
22836
  }
22632
22837
  return {
@@ -22639,7 +22844,7 @@ function detectCredentials() {
22639
22844
  piModelsPresent
22640
22845
  };
22641
22846
  }
22642
- if (piAuthPresent) {
22847
+ if (piKeysUsable) {
22643
22848
  if (!model) {
22644
22849
  return {
22645
22850
  source: "pi-config",
@@ -22649,13 +22854,13 @@ function detectCredentials() {
22649
22854
  modelFrom,
22650
22855
  piAuthPresent,
22651
22856
  piModelsPresent,
22652
- problem: 'pi credentials were found (auth.json), but no research model is configured. The standalone CLI / agent skill run only on an explicitly configured model \u2014 they do not follow the model selected inside the pi extension. Set PI_RESEARCH_MODEL to a "provider/model-id" (in the environment, or in config.env / cli.env below).'
22857
+ problem: `pi credentials were found (in pi's configuration), but no research model is configured. The standalone CLI / agent skill run only on an explicitly configured model \u2014 they do not follow the model selected inside the pi extension. Set PI_RESEARCH_MODEL to a "provider/model-id" (in the environment, or in config.env / cli.env below), or pass --model <provider/id> for a single run.`
22653
22858
  };
22654
22859
  }
22655
22860
  return {
22656
22861
  source: "pi-config",
22657
22862
  apiKeyConfigured: true,
22658
- // key lives in auth.json; SDK reads it
22863
+ // key lives in pi's configuration; the SDK reads it at call time
22659
22864
  provider,
22660
22865
  model,
22661
22866
  modelFrom,
@@ -22671,7 +22876,7 @@ function detectCredentials() {
22671
22876
  modelFrom,
22672
22877
  piAuthPresent,
22673
22878
  piModelsPresent,
22674
- problem: "No model or API key is configured for pi-research. Provide credentials via environment variables (PI_RESEARCH_API_KEY + PI_RESEARCH_PROVIDER + PI_RESEARCH_MODEL) or, if you use pi, via ~/.pi/agent/auth.json."
22879
+ problem: "No model or API key is configured for pi-research. Provide credentials via environment variables (PI_RESEARCH_API_KEY + PI_RESEARCH_PROVIDER + PI_RESEARCH_MODEL) or, if you use pi, via pi's configuration (a provider entry in ~/.pi/agent/auth.json, or a per-provider apiKey in ~/.pi/agent/models.json)."
22675
22880
  };
22676
22881
  }
22677
22882
  function configBlock(det, extraNote) {
@@ -22689,13 +22894,13 @@ function configBlock(det, extraNote) {
22689
22894
  lines.push(` \u2022 api key configured: ${det.apiKeyConfigured}`);
22690
22895
  lines.push(` \u2022 provider: ${det.provider ?? "(unset \u2014 inferred from a provider/model-id model)"}`);
22691
22896
  lines.push(` \u2022 model: ${det.model ? `${det.model} [from: ${det.modelFrom}]` : "(not set \u2014 set PI_RESEARCH_MODEL, required)"}`);
22692
- lines.push(` \u2022 pi auth.json: ${det.piAuthPresent ? "present" : "absent"}`);
22693
- lines.push(` \u2022 pi models.json: ${det.piModelsPresent ? "present" : "absent"}`);
22897
+ lines.push(` \u2022 pi config (auth.json): ${det.piAuthPresent ? "present" : "absent"}`);
22898
+ lines.push(` \u2022 pi config (models.json): ${det.piModelsPresent ? "present" : "absent"}`);
22694
22899
  if (extraNote) lines.push(` \u2022 note: ${extraNote}`);
22695
22900
  return lines.join("\n");
22696
22901
  }
22697
22902
  async function cmdResearch(args) {
22698
- const det = detectCredentials();
22903
+ const det = detectCredentials(args.model);
22699
22904
  if (det.problem) {
22700
22905
  if (args.json) toStdout(pretty({ ok: false, error: det.problem, exitCode: EXIT.CONFIG }));
22701
22906
  else toStderr(`
@@ -22717,7 +22922,7 @@ Error: ${vmsg}
22717
22922
  return EXIT.USAGE;
22718
22923
  }
22719
22924
  const depth = args.depth ?? getConfig(process.cwd(), "cli").DEFAULT_RESEARCH_DEPTH;
22720
- const runModel = args.model ?? det.model;
22925
+ const runModel = det.model;
22721
22926
  toStderr(`[pi-research] starting research (depth ${depth})${runModel ? ` with ${runModel}` : ""}\u2026
22722
22927
  `);
22723
22928
  const observer = makeProgressObserver();
@@ -23035,7 +23240,7 @@ function reportError(err, what, json) {
23035
23240
  const lower = msg2.toLowerCase();
23036
23241
  const isConfigError = lower.includes("no llm model available") || lower.includes("not found in pi") || lower.includes("no api key") || lower.includes("provider must be specified") || lower.includes("invalid model string") || lower.includes("authentication") || lower.includes("unauthorized") || lower.includes("api key");
23037
23242
  const isRateLimit = lower.includes("429") || lower.includes("rate limit") || lower.includes("too many requests");
23038
- const exitCode = isConfigError ? EXIT.CONFIG : EXIT.SOFTWARE;
23243
+ const exitCode = isConfigError && !isRateLimit ? EXIT.CONFIG : EXIT.SOFTWARE;
23039
23244
  if (json) {
23040
23245
  const payload = { ok: false, error: msg2, exitCode };
23041
23246
  if (process.env["PI_RESEARCH_DEBUG"] === "true" && err instanceof Error && err.stack) {
@@ -23044,18 +23249,18 @@ function reportError(err, what, json) {
23044
23249
  toStdout(pretty(payload));
23045
23250
  return exitCode;
23046
23251
  }
23047
- if (isConfigError) {
23252
+ if (isRateLimit) {
23048
23253
  toStderr(`
23049
- Error: pi-research ${what} failed: ${msg2}
23050
-
23051
- ${configBlock(detectCredentials())}
23254
+ Error: pi-research ${what} halted: provider rate limit reached. Wait a moment and retry.
23255
+ ${msg2}
23052
23256
  `);
23053
23257
  return exitCode;
23054
23258
  }
23055
- if (isRateLimit) {
23259
+ if (isConfigError) {
23056
23260
  toStderr(`
23057
- Error: pi-research ${what} halted: provider rate limit reached. Wait a moment and retry.
23058
- ${msg2}
23261
+ Error: pi-research ${what} failed: ${msg2}
23262
+
23263
+ ${configBlock(detectCredentials())}
23059
23264
  `);
23060
23265
  return exitCode;
23061
23266
  }
@@ -23092,7 +23297,7 @@ USAGE
23092
23297
  COMMANDS
23093
23298
  research "<query>" Run multi-agent web research (default depth: 1).
23094
23299
  --depth <0-3> 0 = quick, 1 = normal (default), 2 = deep, 3 = ultra.
23095
- --model <provider/id> Override the model for this run.
23300
+ --model <provider/id> Model for this run (outranks PI_RESEARCH_MODEL; satisfies the required-model check).
23096
23301
  --exclude-tools <a,b> Disable internal tools (e.g. security_search,stackexchange).
23097
23302
  --initial-links <url ...> Seed URLs to investigate first; requires a query (-- ends options).
23098
23303
  --config <path> Use this config file instead of the base config.env.
@@ -23129,9 +23334,9 @@ CONFIGURE
23129
23334
  pi config (keys): ${p.piAuth}
23130
23335
  pi config (models): ${p.piModels}
23131
23336
 
23132
- A model is REQUIRED: set PI_RESEARCH_MODEL to a "provider/model-id". This CLI and
23133
- the agent skill run only on that configured model \u2014 they do not follow the model
23134
- selected inside the pi extension.
23337
+ A model is REQUIRED: set PI_RESEARCH_MODEL to a "provider/model-id" (or pass
23338
+ --model per run). This CLI and the agent skill run only on that configured
23339
+ model \u2014 they do not follow the model selected inside the pi extension.
23135
23340
 
23136
23341
  The pi extension has its own optional overlay file:
23137
23342
  pi extension: ${p.piIfaceEnv}