@lincoln504/pi-research 1.0.4 → 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/README.md CHANGED
@@ -63,7 +63,7 @@ pi install npm:@lincoln504/pi-research
63
63
 
64
64
  See [Agent skill](docs/AGENT-SKILL.md) or [SDK](docs/SDK.md) for standalone (no pi extension) installation.
65
65
 
66
- The pi extension works out of the box — it runs on your pi session's model and pi's own configuration. Standalone use (agent skill / CLI / SDK) requires a configured model: set `PI_RESEARCH_MODEL` (e.g. `openai/gpt-4o`) as an environment variable or in `~/.pi/research/config.env`, plus `PI_RESEARCH_API_KEY` if you don't use pi. See [Configuration](docs/CONFIGURATION.md).
66
+ The pi extension works out of the box — it runs on your pi session's model and pi's own configuration. Standalone use (agent skill / CLI) requires a configured model: set `PI_RESEARCH_MODEL` (e.g. `openai/gpt-4o`) as an environment variable or in `~/.pi/research/config.env` (the CLI also accepts a per-run `--model`), plus `PI_RESEARCH_API_KEY` if you don't use pi; the SDK takes a `model` option. See [Configuration](docs/CONFIGURATION.md).
67
67
 
68
68
  The first install pulls the stealth browser engine, which takes a few minutes.
69
69
 
package/dist/cli.mjs CHANGED
@@ -4712,9 +4712,6 @@ var init_store = __esm({
4712
4712
  if (strategy !== "drop" && strategy !== "backup") {
4713
4713
  logger.warn("[store] Falling back to backup strategy after migration failure");
4714
4714
  await this.handleModelChange(storedModel || "unknown", this.options.modelName, "backup");
4715
- } else if (strategy === "backup") {
4716
- logger.warn("[store] Falling back to drop strategy after backup failure");
4717
- await this.handleModelChange(storedModel || "unknown", this.options.modelName, "drop");
4718
4715
  } else {
4719
4716
  throw new Error(errorMsg, { cause: err });
4720
4717
  }
@@ -4763,7 +4760,12 @@ var init_store = __esm({
4763
4760
  if (!this.table || !this.db) {
4764
4761
  throw new Error("Table not connected");
4765
4762
  }
4766
- 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
+ }
4767
4769
  this.table = null;
4768
4770
  const oldDir = path13.join(this.options.dbDir, `${this.tableName}.lance`);
4769
4771
  const backupDir = path13.join(this.options.dbDir, `${backupTableName}.lance`);
@@ -4778,8 +4780,8 @@ var init_store = __esm({
4778
4780
  }
4779
4781
  this.table = await this.createTable();
4780
4782
  await this.saveManifest();
4781
- logger.info(`[store] Migration complete: ${count} documents backed up, fresh table created with model ${newModel}`);
4782
- 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) };
4783
4785
  }
4784
4786
  async migrationDrop(_oldModel, newModel) {
4785
4787
  logger.warn(`[store] Dropping table and recreating with model ${newModel} (data will be lost)`);
@@ -9368,6 +9370,7 @@ init_constants();
9368
9370
  var sessionLinks = /* @__PURE__ */ new Map();
9369
9371
  var sessionScrapedContent = /* @__PURE__ */ new Map();
9370
9372
  var researcherScrapes = /* @__PURE__ */ new Map();
9373
+ var transcribedUrls = /* @__PURE__ */ new Map();
9371
9374
  var sessionTimestamps = /* @__PURE__ */ new Map();
9372
9375
  var SESSION_MAX_AGE_MS = 2 * 60 * 60 * 1e3;
9373
9376
  var MAX_CACHED_CONTENT_PER_SESSION = 500;
@@ -9383,6 +9386,7 @@ function startCleanupTimer() {
9383
9386
  sessionScrapedContent.delete(researchId);
9384
9387
  sessionTimestamps.delete(researchId);
9385
9388
  researcherScrapes.delete(researchId);
9389
+ transcribedUrls.delete(researchId);
9386
9390
  cleaned++;
9387
9391
  }
9388
9392
  }
@@ -9420,6 +9424,17 @@ function registerScrapedLinks(researchId, links) {
9420
9424
  function getScrapedLinks(researchId) {
9421
9425
  return Array.from(sessionLinks.get(researchId) || []);
9422
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
+ }
9423
9438
  function deduplicateUrls(urls, researchId) {
9424
9439
  const pool = sessionLinks.get(researchId) || /* @__PURE__ */ new Set();
9425
9440
  const kept = [];
@@ -9439,6 +9454,7 @@ function cleanupSharedLinks(researchId) {
9439
9454
  sessionScrapedContent.delete(researchId);
9440
9455
  sessionTimestamps.delete(researchId);
9441
9456
  researcherScrapes.delete(researchId);
9457
+ transcribedUrls.delete(researchId);
9442
9458
  logger.debug(`[Shared Links] Cleaned up session: ${researchId}`);
9443
9459
  }
9444
9460
  function clearAllSharedLinks() {
@@ -9450,6 +9466,7 @@ function clearAllSharedLinks() {
9450
9466
  sessionScrapedContent.clear();
9451
9467
  sessionTimestamps.clear();
9452
9468
  researcherScrapes.clear();
9469
+ transcribedUrls.clear();
9453
9470
  logger.debug("[Shared Links] All global state cleared");
9454
9471
  }
9455
9472
  function registerResearcherScrapes(researchId, researcherId, urls) {
@@ -15709,6 +15726,48 @@ function decodeHtmlBody(bytes, contentTypeHeader) {
15709
15726
  return new TextDecoder("utf-8").decode(bytes);
15710
15727
  }
15711
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
+ }
15712
15771
  async function scrapeWithFetch(url, signal) {
15713
15772
  await validateUrlForSSRF(url);
15714
15773
  const dispatcher = await getSsrfSafeDispatcher();
@@ -15744,6 +15803,10 @@ async function scrapeWithFetch(url, signal) {
15744
15803
  if (!location) break;
15745
15804
  const resolved = new URL(location, currentUrl).href;
15746
15805
  await validateUrlForSSRF(resolved);
15806
+ try {
15807
+ await response.body?.cancel();
15808
+ } catch {
15809
+ }
15747
15810
  currentUrl = resolved;
15748
15811
  continue;
15749
15812
  }
@@ -15751,6 +15814,10 @@ async function scrapeWithFetch(url, signal) {
15751
15814
  }
15752
15815
  if (!response.ok) {
15753
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
+ }
15754
15821
  throw new Error(`HTTP ${response.status}`);
15755
15822
  }
15756
15823
  const contentType = response.headers.get("content-type") ?? "";
@@ -15763,24 +15830,62 @@ async function scrapeWithFetch(url, signal) {
15763
15830
  const sizeMB = Math.round(size / 1024 / 1024);
15764
15831
  logger.warn(`[Scrapers] PDF too large (Content-Length: ${sizeMB}MB, max 100MB), skipping`);
15765
15832
  metrics.increment("scrape_pdf_errors_total", 1, { error_type: "size_exceeded" });
15833
+ try {
15834
+ await response.body?.cancel();
15835
+ } catch {
15836
+ }
15766
15837
  throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
15767
15838
  }
15768
15839
  } else if (size > MAX_HTML_SIZE) {
15769
15840
  const sizeMB = Math.round(size / 1024 / 1024);
15770
15841
  logger.warn(`[Scrapers] HTML response too large (Content-Length: ${sizeMB}MB, max 25MB), skipping`);
15771
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
+ }
15772
15847
  throw new Error(`HTML response too large (${sizeMB}MB, max 25MB)`);
15773
15848
  }
15774
15849
  }
15775
15850
  if (contentType.includes("application/pdf") || url.toLowerCase().endsWith(".pdf")) {
15776
- const buffer = await response.arrayBuffer();
15777
- 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);
15778
15864
  validateContent("", markdown2, url);
15779
15865
  metrics.increment("scrape_operations_total", 1, { layer: "fetch", content_type: "pdf", status: "success" });
15780
15866
  metrics.observe("scrape_latency_ms", fetchDuration, { layer: "fetch", content_type: "pdf", status: "success" });
15781
15867
  return { source: "fetch", layer: "fetch", markdown: markdown2 };
15782
15868
  }
15783
- 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
+ }
15784
15889
  if (bodyBytes.length > MAX_HTML_SIZE) {
15785
15890
  const sizeMB = Math.round(bodyBytes.length / 1024 / 1024);
15786
15891
  logger.warn(`[Scrapers] HTML response too large (actual: ${sizeMB}MB, max 25MB), skipping`);
@@ -16478,6 +16583,8 @@ function handleFetchError(error) {
16478
16583
  }
16479
16584
  function handleResponseStatus(response) {
16480
16585
  if (!response.ok) {
16586
+ void response.body?.cancel()?.catch(() => {
16587
+ });
16481
16588
  if (response.status === 429) {
16482
16589
  throw new Error("NVD API rate limit exceeded (HTTP 429). Retrying with backoff...");
16483
16590
  }
@@ -16672,6 +16779,8 @@ async function fetchWithRetryImpl(url, options, signal) {
16672
16779
  async () => {
16673
16780
  const response = await fetch(url, options);
16674
16781
  if (!response.ok) {
16782
+ void response.body?.cancel()?.catch(() => {
16783
+ });
16675
16784
  const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
16676
16785
  error.status = response.status;
16677
16786
  metrics.increment("cisa_kev_errors_total", 1, { endpoint, status_code: response.status.toString() });
@@ -17028,6 +17137,8 @@ async function searchGitHubAdvisories(terms, options) {
17028
17137
  signal: createTimeoutSignal(1e4, options?.signal)
17029
17138
  });
17030
17139
  if (!resp.ok) {
17140
+ void resp.body?.cancel()?.catch(() => {
17141
+ });
17031
17142
  if (resp.status === 404) {
17032
17143
  throw new Error(`Repository "${owner}/${name}" not found or no access to security advisories.`);
17033
17144
  }
@@ -17095,6 +17206,8 @@ async function searchGitHubAdvisories(terms, options) {
17095
17206
  signal: createTimeoutSignal(1e4, options?.signal)
17096
17207
  });
17097
17208
  if (!resp.ok) {
17209
+ void resp.body?.cancel()?.catch(() => {
17210
+ });
17098
17211
  if (resp.status === 404) {
17099
17212
  throw new Error("Advisory not found (HTTP 404)");
17100
17213
  }
@@ -17415,6 +17528,8 @@ async function fetchWithRetry2(url, options, signal) {
17415
17528
  async () => {
17416
17529
  const response = await fetch(url, options);
17417
17530
  if (!response.ok) {
17531
+ void response.body?.cancel()?.catch(() => {
17532
+ });
17418
17533
  const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
17419
17534
  error.status = response.status;
17420
17535
  metrics.increment("osv_errors_total", 1, { endpoint, status_code: response.status.toString() });
@@ -19153,6 +19268,8 @@ async function fetchOne(session, videoId, contentToken, lang, timeoutMs, signal,
19153
19268
  headers: { "User-Agent": "Mozilla/5.0", "Accept-Language": lang }
19154
19269
  });
19155
19270
  if (!response.ok) {
19271
+ void response.body?.cancel()?.catch(() => {
19272
+ });
19156
19273
  throw new Error(`HTTP ${response.status} from timedtext endpoint`);
19157
19274
  }
19158
19275
  const body = await response.text();
@@ -19329,7 +19446,7 @@ function formatResults(results, rejected) {
19329
19446
  if (meta.length) md += `**${meta.join(" \xB7 ")}**
19330
19447
  `;
19331
19448
  if (r.title) {
19332
- md += `**Cite as:** '${r.title}'${r.author ? ` by ${r.author}` : ""}
19449
+ md += `**Cite as:** ${r.url} \u2014 '${r.title}'${r.author ? ` by ${r.author}` : ""}
19333
19450
  `;
19334
19451
  }
19335
19452
  md += `
@@ -19388,7 +19505,7 @@ function createYoutubeTranscriptTool(options) {
19388
19505
  "You may call this tool only ONCE, so choose the videos carefully before calling.",
19389
19506
  "Pass full watch URLs, youtu.be links, or shorts/embed/live links. Non-YouTube links are ignored.",
19390
19507
  "Videos without captions, or that are private/age-restricted, are reported as unavailable \u2014 the rest still succeed.",
19391
- `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.`
19392
19509
  ],
19393
19510
  parameters: YoutubeParamsSchema,
19394
19511
  executionMode: "parallel",
@@ -19439,6 +19556,7 @@ function createYoutubeTranscriptTool(options) {
19439
19556
  });
19440
19557
  if (researchId && cachedUrls.length > 0) {
19441
19558
  registerScrapedLinks(researchId, cachedUrls);
19559
+ registerTranscribedLinks(researchId, cachedUrls);
19442
19560
  options.updateGlobalLinks?.(cachedUrls);
19443
19561
  if (options.researcherId) {
19444
19562
  registerResearcherScrapes(researchId, options.researcherId, cachedUrls);
@@ -20309,6 +20427,34 @@ init_constants();
20309
20427
 
20310
20428
  // src/orchestration/headless-observer.ts
20311
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
+ }
20312
20458
  var HeadlessObserver = class {
20313
20459
  constructor(options = {}) {
20314
20460
  this.options = options;
@@ -20398,9 +20544,9 @@ var QuickResearchOrchestrator = class {
20398
20544
  this.options = options;
20399
20545
  this.config = options.config || getConfig(options.ctx.cwd);
20400
20546
  if (options.observer && typeof options.observer.onProgress === "function" && !(options.observer instanceof HeadlessObserver)) {
20401
- this.observer = new HeadlessObserver(options.observer);
20547
+ this.observer = makeSafeObserver(new HeadlessObserver(options.observer));
20402
20548
  } else {
20403
- this.observer = options.observer;
20549
+ this.observer = options.observer ? makeSafeObserver(options.observer) : void 0;
20404
20550
  }
20405
20551
  }
20406
20552
  options;
@@ -20714,9 +20860,9 @@ var DeepResearchOrchestrator = class {
20714
20860
  this.options = options;
20715
20861
  this.config = options.config || getConfig(options.ctx.cwd);
20716
20862
  if (options.observer && typeof options.observer.onProgress === "function" && !(options.observer instanceof HeadlessObserver)) {
20717
- this.observer = new HeadlessObserver(options.observer);
20863
+ this.observer = makeSafeObserver(new HeadlessObserver(options.observer));
20718
20864
  } else {
20719
- this.observer = options.observer;
20865
+ this.observer = options.observer ? makeSafeObserver(options.observer) : void 0;
20720
20866
  }
20721
20867
  if (options.orchestrationService) {
20722
20868
  this.orchestrationService = options.orchestrationService;
@@ -20989,18 +21135,22 @@ var DeepResearchOrchestrator = class {
20989
21135
  observer?.onEvaluationDecision?.("synthesize", finalReport, maxRounds);
20990
21136
  }
20991
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
+ }
20992
21148
  if (!result.trim()) {
20993
- const synthesisService2 = await getService(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
20994
- if (synthesisService2.hasReports(researchId)) {
20995
- logger.warn(`[DeepOrchestrator] LLM returned empty synthesis for ${researchId}, building fallback from ${synthesisService2.getReportCount(researchId)} reports`);
20996
- 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);
20997
21152
  } else {
20998
- const failedIds = getFailedResearchers(this.options.sessionId, researchId);
20999
- const reasons = getResearcherFailureReasons(this.options.sessionId, researchId);
21000
- const causes = failedIds.map((id) => `researcher ${id}: ${reasons[id] ?? "no usable report"}`).join("; ");
21001
- throw new Error(
21002
- "Research produced no report \u2014 no source material was collected" + (causes ? `. Causes: ${causes}` : ".")
21003
- );
21153
+ throw new Error("Research produced no report \u2014 no source material was collected.");
21004
21154
  }
21005
21155
  }
21006
21156
  try {
@@ -21731,9 +21881,14 @@ Research performed using ${modelId}`;
21731
21881
  if (globalCitations.length === 0) {
21732
21882
  const scraped = getScrapedLinks(sessionId);
21733
21883
  if (scraped.length > 0) {
21734
- 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
+ }));
21735
21890
  logger.warn(
21736
- `[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.`
21737
21892
  );
21738
21893
  }
21739
21894
  }
@@ -22267,6 +22422,7 @@ async function _doShutdown() {
22267
22422
  _lastResearchId = null;
22268
22423
  _lastRunSummary = null;
22269
22424
  _lastErrorReport = null;
22425
+ resetConfig();
22270
22426
  _removeSignalHandlers();
22271
22427
  if (errors.length > 0) {
22272
22428
  throw new AggregateError(errors, `${errors.length} error(s) during SDK shutdown`);
@@ -22641,7 +22797,7 @@ function piKeysAvailable(piAuthPresent, provider) {
22641
22797
  return piAuthPresent;
22642
22798
  }
22643
22799
  }
22644
- function detectCredentials() {
22800
+ function detectCredentials(explicitModel) {
22645
22801
  const paths = resolvedConfigPaths();
22646
22802
  const piAuthPresent = fileExists(paths.piAuth);
22647
22803
  const piModelsPresent = fileExists(paths.piModels);
@@ -22649,8 +22805,8 @@ function detectCredentials() {
22649
22805
  const providerEnv = process.env["PI_RESEARCH_PROVIDER"];
22650
22806
  const modelEnv = process.env["PI_RESEARCH_MODEL"];
22651
22807
  const modelConfig = getConfig(process.cwd(), "cli").RESEARCH_MODEL;
22652
- const model = modelEnv ?? modelConfig;
22653
- 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";
22654
22810
  const provider = providerEnv ?? (model && model.includes("/") ? model.slice(0, model.indexOf("/")) : void 0);
22655
22811
  const piKeysUsable = piKeysAvailable(piAuthPresent, provider);
22656
22812
  if (apiKey) {
@@ -22675,7 +22831,7 @@ function detectCredentials() {
22675
22831
  modelFrom,
22676
22832
  piAuthPresent,
22677
22833
  piModelsPresent,
22678
- 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.'
22679
22835
  };
22680
22836
  }
22681
22837
  return {
@@ -22698,7 +22854,7 @@ function detectCredentials() {
22698
22854
  modelFrom,
22699
22855
  piAuthPresent,
22700
22856
  piModelsPresent,
22701
- 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).`
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.`
22702
22858
  };
22703
22859
  }
22704
22860
  return {
@@ -22738,13 +22894,13 @@ function configBlock(det, extraNote) {
22738
22894
  lines.push(` \u2022 api key configured: ${det.apiKeyConfigured}`);
22739
22895
  lines.push(` \u2022 provider: ${det.provider ?? "(unset \u2014 inferred from a provider/model-id model)"}`);
22740
22896
  lines.push(` \u2022 model: ${det.model ? `${det.model} [from: ${det.modelFrom}]` : "(not set \u2014 set PI_RESEARCH_MODEL, required)"}`);
22741
- lines.push(` \u2022 pi auth.json: ${det.piAuthPresent ? "present" : "absent"}`);
22742
- 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"}`);
22743
22899
  if (extraNote) lines.push(` \u2022 note: ${extraNote}`);
22744
22900
  return lines.join("\n");
22745
22901
  }
22746
22902
  async function cmdResearch(args) {
22747
- const det = detectCredentials();
22903
+ const det = detectCredentials(args.model);
22748
22904
  if (det.problem) {
22749
22905
  if (args.json) toStdout(pretty({ ok: false, error: det.problem, exitCode: EXIT.CONFIG }));
22750
22906
  else toStderr(`
@@ -22766,7 +22922,7 @@ Error: ${vmsg}
22766
22922
  return EXIT.USAGE;
22767
22923
  }
22768
22924
  const depth = args.depth ?? getConfig(process.cwd(), "cli").DEFAULT_RESEARCH_DEPTH;
22769
- const runModel = args.model ?? det.model;
22925
+ const runModel = det.model;
22770
22926
  toStderr(`[pi-research] starting research (depth ${depth})${runModel ? ` with ${runModel}` : ""}\u2026
22771
22927
  `);
22772
22928
  const observer = makeProgressObserver();
@@ -23141,7 +23297,7 @@ USAGE
23141
23297
  COMMANDS
23142
23298
  research "<query>" Run multi-agent web research (default depth: 1).
23143
23299
  --depth <0-3> 0 = quick, 1 = normal (default), 2 = deep, 3 = ultra.
23144
- --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).
23145
23301
  --exclude-tools <a,b> Disable internal tools (e.g. security_search,stackexchange).
23146
23302
  --initial-links <url ...> Seed URLs to investigate first; requires a query (-- ends options).
23147
23303
  --config <path> Use this config file instead of the base config.env.
@@ -23178,9 +23334,9 @@ CONFIGURE
23178
23334
  pi config (keys): ${p.piAuth}
23179
23335
  pi config (models): ${p.piModels}
23180
23336
 
23181
- A model is REQUIRED: set PI_RESEARCH_MODEL to a "provider/model-id". This CLI and
23182
- the agent skill run only on that configured model \u2014 they do not follow the model
23183
- 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.
23184
23340
 
23185
23341
  The pi extension has its own optional overlay file:
23186
23342
  pi extension: ${p.piIfaceEnv}
@@ -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.
@@ -75,7 +75,7 @@ Research
75
75
  | `PI_RESEARCH_RETRY_DELAY_MS` | `2000` | 100–10000 | Base delay between retries. |
76
76
  | `PI_RESEARCH_WORKER_THREADS` | `4` | 1–10 | Browser worker processes. Higher = more throughput, more CPU/RAM. |
77
77
  | `PI_RESEARCH_WORKER_CONCURRENCY` | `2` | 1–10 | Tasks per worker process. |
78
- | `PI_RESEARCH_MODEL` | _(pi: session model; CLI/skill: required)_ | — | The model research runs on. **Required for the standalone CLI / agent skill** — they use only this configured model (never the model selected inside the pi extension) and refuse to start without one. On the SDK it selects the session model when no `model` option is given. In the pi extension it overrides researcher sub-agents and knowledge synthesis, while the coordinator and evaluator keep using the session model. Accepts `provider/id` or a bare model id. |
78
+ | `PI_RESEARCH_MODEL` | _(pi: session model; CLI/skill: required)_ | — | The model research runs on. **Required for the standalone CLI / agent skill** — they use only this configured model (never the model selected inside the pi extension) and refuse to start without one (the CLI's per-run `--model` flag also satisfies this). On the SDK it selects the session model when no `model` option is given. In the pi extension it overrides researcher sub-agents and knowledge synthesis, while the coordinator and evaluator keep using the session model. Accepts `provider/id` or a bare model id. |
79
79
  | `PI_RESEARCH_DISABLED_TOOLS` | _(none)_ | — | Comma-separated research tools to disable for a run (`search`, `scrape`, `security_search`, `stackexchange`, `youtube_transcript`, `grep`). Removed from every researcher's toolset and named in the coordinator/evaluator prompt. |
80
80
  | `PI_RESEARCH_REPORT_EXPORT_ENABLED` (TUI) | `false` | — | Front-ends write a Markdown report to disk and surface its path. |
81
81
  | `PI_RESEARCH_REPORT_EXPORT_DIR` | _(smart cwd)_ | — | Pin exported reports to a fixed directory, bypassing the cwd-relative resolution. Useful for the agent skill, which runs from the host agent's arbitrary directory. |
@@ -174,9 +174,11 @@ built with, the store is migrated according to `PI_RESEARCH_MIGRATION_STRATEGY`:
174
174
  | `drop` | The old table is discarded and a fresh one created. Fast; no backup. |
175
175
  | `re-embed` | Every stored document is re-embedded with the new model into a new table, preserving history. Slowest. |
176
176
 
177
- If the chosen strategy fails, pi-research falls back to `backup`, then to `drop`,
178
- rather than leaving the store in a broken state. Changing the model from the
179
- `/research-config` menu always clears the current store and starts fresh.
177
+ If `re-embed` fails, pi-research falls back to `backup`. A failed `backup` (or
178
+ `drop`) aborts the migration instead: the store stays on the old model and the
179
+ next open retries — data is never dropped unless `drop` was chosen explicitly.
180
+ Changing the model from the `/research-config` menu always clears the current
181
+ store and starts fresh.
180
182
 
181
183
  ### Managing the store
182
184
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "A set of web research tools (search, scrape, and others) and research agent system that uses them safely and efficiently.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -2,7 +2,7 @@
2
2
  name: pi-research
3
3
  description: "Multi-agent web research for any coding agent. Use for anything requiring current information from the internet — news, trends, product/service/company facts, statistics, people, places, events, APIs, library behavior, or 'what is X / how does X work' questions. Also use to search a local knowledge store of previously researched findings before doing live research. Powered by the pi-research engine (search, stealth scraping, security databases, Stack Exchange). Not for reading local files, running project commands, or analyzing this repository's own source."
4
4
  license: MIT
5
- metadata: { "author": "Lincoln Deen", "version": "1.0.4", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
5
+ metadata: { "author": "Lincoln Deen", "version": "1.0.5", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
6
6
  allowed-tools: Bash(node:*)
7
7
  ---
8
8