@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/README.md +1 -1
- package/dist/cli.mjs +207 -40
- package/dist/prompts/researcher.md +1 -1
- package/docs/ARCHITECTURE.md +4 -3
- package/docs/CONFIGURATION.md +1 -1
- package/docs/KNOWLEDGE-STORE.md +5 -3
- package/package.json +1 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +27 -15
- package/src/knowledge/store.ts +17 -7
- package/src/orchestration/deep-research-orchestrator.ts +37 -26
- package/src/orchestration/headless-observer.ts +63 -0
- package/src/orchestration/quick-research-orchestrator.ts +5 -4
- package/src/orchestration/research-synthesis-service.ts +11 -3
- package/src/prompts/researcher.md +1 -1
- package/src/sdk.ts +7 -1
- package/src/security/cisa-kev.ts +3 -0
- package/src/security/github-advisories.ts +5 -0
- package/src/security/nvd-client.ts +3 -0
- package/src/security/osv-client.ts +3 -0
- package/src/tools/youtube-transcript.ts +6 -2
- package/src/utils/shared-links.ts +26 -0
- package/src/web-research/scraper-utils.ts +10 -3
- package/src/web-research/web-scraper.ts +95 -4
- package/src/youtube/index.ts +7 -3
- package/src/youtube/transcript-client.ts +2 -0
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
|
|
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
|
-
|
|
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
|
-
|
|
15777
|
-
|
|
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
|
-
|
|
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,
|
|
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,45 @@ init_constants();
|
|
|
20309
20427
|
|
|
20310
20428
|
// src/orchestration/headless-observer.ts
|
|
20311
20429
|
init_logger();
|
|
20430
|
+
function makeSafeObserver(observer) {
|
|
20431
|
+
return new Proxy({}, {
|
|
20432
|
+
get(_target, prop) {
|
|
20433
|
+
let value;
|
|
20434
|
+
try {
|
|
20435
|
+
value = Reflect.get(observer, prop, observer);
|
|
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(observer, 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
|
+
has(_target, prop) {
|
|
20457
|
+
try {
|
|
20458
|
+
return Reflect.has(observer, prop);
|
|
20459
|
+
} catch {
|
|
20460
|
+
return false;
|
|
20461
|
+
}
|
|
20462
|
+
},
|
|
20463
|
+
// Keep `instanceof` transparent (legal on an extensible target).
|
|
20464
|
+
getPrototypeOf() {
|
|
20465
|
+
return Reflect.getPrototypeOf(observer);
|
|
20466
|
+
}
|
|
20467
|
+
});
|
|
20468
|
+
}
|
|
20312
20469
|
var HeadlessObserver = class {
|
|
20313
20470
|
constructor(options = {}) {
|
|
20314
20471
|
this.options = options;
|
|
@@ -20398,9 +20555,9 @@ var QuickResearchOrchestrator = class {
|
|
|
20398
20555
|
this.options = options;
|
|
20399
20556
|
this.config = options.config || getConfig(options.ctx.cwd);
|
|
20400
20557
|
if (options.observer && typeof options.observer.onProgress === "function" && !(options.observer instanceof HeadlessObserver)) {
|
|
20401
|
-
this.observer = new HeadlessObserver(options.observer);
|
|
20558
|
+
this.observer = makeSafeObserver(new HeadlessObserver(options.observer));
|
|
20402
20559
|
} else {
|
|
20403
|
-
this.observer = options.observer;
|
|
20560
|
+
this.observer = options.observer ? makeSafeObserver(options.observer) : void 0;
|
|
20404
20561
|
}
|
|
20405
20562
|
}
|
|
20406
20563
|
options;
|
|
@@ -20714,9 +20871,9 @@ var DeepResearchOrchestrator = class {
|
|
|
20714
20871
|
this.options = options;
|
|
20715
20872
|
this.config = options.config || getConfig(options.ctx.cwd);
|
|
20716
20873
|
if (options.observer && typeof options.observer.onProgress === "function" && !(options.observer instanceof HeadlessObserver)) {
|
|
20717
|
-
this.observer = new HeadlessObserver(options.observer);
|
|
20874
|
+
this.observer = makeSafeObserver(new HeadlessObserver(options.observer));
|
|
20718
20875
|
} else {
|
|
20719
|
-
this.observer = options.observer;
|
|
20876
|
+
this.observer = options.observer ? makeSafeObserver(options.observer) : void 0;
|
|
20720
20877
|
}
|
|
20721
20878
|
if (options.orchestrationService) {
|
|
20722
20879
|
this.orchestrationService = options.orchestrationService;
|
|
@@ -20989,18 +21146,22 @@ var DeepResearchOrchestrator = class {
|
|
|
20989
21146
|
observer?.onEvaluationDecision?.("synthesize", finalReport, maxRounds);
|
|
20990
21147
|
}
|
|
20991
21148
|
let result = finalReport.content || "";
|
|
21149
|
+
const synthesisCheckService = await getService(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
|
|
21150
|
+
const hasReports = synthesisCheckService.hasReports(researchId);
|
|
21151
|
+
const failedIds = getFailedResearchers(this.options.sessionId, researchId);
|
|
21152
|
+
if (!hasReports && failedIds.length > 0) {
|
|
21153
|
+
const reasons = getResearcherFailureReasons(this.options.sessionId, researchId);
|
|
21154
|
+
const causes = failedIds.map((id) => `researcher ${id}: ${reasons[id] ?? "no usable report"}`).join("; ");
|
|
21155
|
+
throw new Error(
|
|
21156
|
+
`Research produced no report \u2014 no source material was collected. Causes: ${causes}`
|
|
21157
|
+
);
|
|
21158
|
+
}
|
|
20992
21159
|
if (!result.trim()) {
|
|
20993
|
-
|
|
20994
|
-
|
|
20995
|
-
|
|
20996
|
-
result = synthesisService2.buildFallbackSynthesis(researchId, this.currentRound);
|
|
21160
|
+
if (hasReports) {
|
|
21161
|
+
logger.warn(`[DeepOrchestrator] LLM returned empty synthesis for ${researchId}, building fallback from ${synthesisCheckService.getReportCount(researchId)} reports`);
|
|
21162
|
+
result = synthesisCheckService.buildFallbackSynthesis(researchId, this.currentRound);
|
|
20997
21163
|
} else {
|
|
20998
|
-
|
|
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
|
-
);
|
|
21164
|
+
throw new Error("Research produced no report \u2014 no source material was collected.");
|
|
21004
21165
|
}
|
|
21005
21166
|
}
|
|
21006
21167
|
try {
|
|
@@ -21731,9 +21892,14 @@ Research performed using ${modelId}`;
|
|
|
21731
21892
|
if (globalCitations.length === 0) {
|
|
21732
21893
|
const scraped = getScrapedLinks(sessionId);
|
|
21733
21894
|
if (scraped.length > 0) {
|
|
21734
|
-
globalCitations = scraped.map((url, i) => ({
|
|
21895
|
+
globalCitations = scraped.map((url, i) => ({
|
|
21896
|
+
id: i + 1,
|
|
21897
|
+
url,
|
|
21898
|
+
description: "",
|
|
21899
|
+
source: isTranscribedLink(sessionId, url) ? "YouTube Transcript" : "Scrape"
|
|
21900
|
+
}));
|
|
21735
21901
|
logger.warn(
|
|
21736
|
-
`[ResearchSynthesisService] No citations parsed from reports; rebuilt ${scraped.length} source(s) from scrape provenance.`
|
|
21902
|
+
`[ResearchSynthesisService] No citations parsed from reports; rebuilt ${scraped.length} source(s) from scrape/transcript provenance.`
|
|
21737
21903
|
);
|
|
21738
21904
|
}
|
|
21739
21905
|
}
|
|
@@ -22267,6 +22433,7 @@ async function _doShutdown() {
|
|
|
22267
22433
|
_lastResearchId = null;
|
|
22268
22434
|
_lastRunSummary = null;
|
|
22269
22435
|
_lastErrorReport = null;
|
|
22436
|
+
resetConfig();
|
|
22270
22437
|
_removeSignalHandlers();
|
|
22271
22438
|
if (errors.length > 0) {
|
|
22272
22439
|
throw new AggregateError(errors, `${errors.length} error(s) during SDK shutdown`);
|
|
@@ -22641,7 +22808,7 @@ function piKeysAvailable(piAuthPresent, provider) {
|
|
|
22641
22808
|
return piAuthPresent;
|
|
22642
22809
|
}
|
|
22643
22810
|
}
|
|
22644
|
-
function detectCredentials() {
|
|
22811
|
+
function detectCredentials(explicitModel) {
|
|
22645
22812
|
const paths = resolvedConfigPaths();
|
|
22646
22813
|
const piAuthPresent = fileExists(paths.piAuth);
|
|
22647
22814
|
const piModelsPresent = fileExists(paths.piModels);
|
|
@@ -22649,8 +22816,8 @@ function detectCredentials() {
|
|
|
22649
22816
|
const providerEnv = process.env["PI_RESEARCH_PROVIDER"];
|
|
22650
22817
|
const modelEnv = process.env["PI_RESEARCH_MODEL"];
|
|
22651
22818
|
const modelConfig = getConfig(process.cwd(), "cli").RESEARCH_MODEL;
|
|
22652
|
-
const model = modelEnv ?? modelConfig;
|
|
22653
|
-
const modelFrom = modelEnv ? "PI_RESEARCH_MODEL" : modelConfig ? "config.env" : "unset";
|
|
22819
|
+
const model = explicitModel ?? modelEnv ?? modelConfig;
|
|
22820
|
+
const modelFrom = explicitModel ? "--model" : modelEnv ? "PI_RESEARCH_MODEL" : modelConfig ? "config.env" : "unset";
|
|
22654
22821
|
const provider = providerEnv ?? (model && model.includes("/") ? model.slice(0, model.indexOf("/")) : void 0);
|
|
22655
22822
|
const piKeysUsable = piKeysAvailable(piAuthPresent, provider);
|
|
22656
22823
|
if (apiKey) {
|
|
@@ -22675,7 +22842,7 @@ function detectCredentials() {
|
|
|
22675
22842
|
modelFrom,
|
|
22676
22843
|
piAuthPresent,
|
|
22677
22844
|
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).'
|
|
22845
|
+
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
22846
|
};
|
|
22680
22847
|
}
|
|
22681
22848
|
return {
|
|
@@ -22698,7 +22865,7 @@ function detectCredentials() {
|
|
|
22698
22865
|
modelFrom,
|
|
22699
22866
|
piAuthPresent,
|
|
22700
22867
|
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).`
|
|
22868
|
+
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
22869
|
};
|
|
22703
22870
|
}
|
|
22704
22871
|
return {
|
|
@@ -22738,13 +22905,13 @@ function configBlock(det, extraNote) {
|
|
|
22738
22905
|
lines.push(` \u2022 api key configured: ${det.apiKeyConfigured}`);
|
|
22739
22906
|
lines.push(` \u2022 provider: ${det.provider ?? "(unset \u2014 inferred from a provider/model-id model)"}`);
|
|
22740
22907
|
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:
|
|
22742
|
-
lines.push(` \u2022 pi models.json:
|
|
22908
|
+
lines.push(` \u2022 pi config (auth.json): ${det.piAuthPresent ? "present" : "absent"}`);
|
|
22909
|
+
lines.push(` \u2022 pi config (models.json): ${det.piModelsPresent ? "present" : "absent"}`);
|
|
22743
22910
|
if (extraNote) lines.push(` \u2022 note: ${extraNote}`);
|
|
22744
22911
|
return lines.join("\n");
|
|
22745
22912
|
}
|
|
22746
22913
|
async function cmdResearch(args) {
|
|
22747
|
-
const det = detectCredentials();
|
|
22914
|
+
const det = detectCredentials(args.model);
|
|
22748
22915
|
if (det.problem) {
|
|
22749
22916
|
if (args.json) toStdout(pretty({ ok: false, error: det.problem, exitCode: EXIT.CONFIG }));
|
|
22750
22917
|
else toStderr(`
|
|
@@ -22766,7 +22933,7 @@ Error: ${vmsg}
|
|
|
22766
22933
|
return EXIT.USAGE;
|
|
22767
22934
|
}
|
|
22768
22935
|
const depth = args.depth ?? getConfig(process.cwd(), "cli").DEFAULT_RESEARCH_DEPTH;
|
|
22769
|
-
const runModel =
|
|
22936
|
+
const runModel = det.model;
|
|
22770
22937
|
toStderr(`[pi-research] starting research (depth ${depth})${runModel ? ` with ${runModel}` : ""}\u2026
|
|
22771
22938
|
`);
|
|
22772
22939
|
const observer = makeProgressObserver();
|
|
@@ -23141,7 +23308,7 @@ USAGE
|
|
|
23141
23308
|
COMMANDS
|
|
23142
23309
|
research "<query>" Run multi-agent web research (default depth: 1).
|
|
23143
23310
|
--depth <0-3> 0 = quick, 1 = normal (default), 2 = deep, 3 = ultra.
|
|
23144
|
-
--model <provider/id>
|
|
23311
|
+
--model <provider/id> Model for this run (outranks PI_RESEARCH_MODEL; satisfies the required-model check).
|
|
23145
23312
|
--exclude-tools <a,b> Disable internal tools (e.g. security_search,stackexchange).
|
|
23146
23313
|
--initial-links <url ...> Seed URLs to investigate first; requires a query (-- ends options).
|
|
23147
23314
|
--config <path> Use this config file instead of the base config.env.
|
|
@@ -23178,9 +23345,9 @@ CONFIGURE
|
|
|
23178
23345
|
pi config (keys): ${p.piAuth}
|
|
23179
23346
|
pi config (models): ${p.piModels}
|
|
23180
23347
|
|
|
23181
|
-
A model is REQUIRED: set PI_RESEARCH_MODEL to a "provider/model-id"
|
|
23182
|
-
the agent skill run only on that configured
|
|
23183
|
-
selected inside the pi extension.
|
|
23348
|
+
A model is REQUIRED: set PI_RESEARCH_MODEL to a "provider/model-id" (or pass
|
|
23349
|
+
--model per run). This CLI and the agent skill run only on that configured
|
|
23350
|
+
model \u2014 they do not follow the model selected inside the pi extension.
|
|
23184
23351
|
|
|
23185
23352
|
The pi extension has its own optional overlay file:
|
|
23186
23353
|
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.
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -25,8 +25,8 @@ pi CLI
|
|
|
25
25
|
in-session, the calling agent picks the depth (1–3) from the user's wording and the
|
|
26
26
|
task's complexity, guided by the tool's usage prompt (`src/prompts/research-tool-usage.md`).
|
|
27
27
|
CLI and SDK callers pass the depth explicitly.
|
|
28
|
-
2. Depth 0 takes the quick path; depth 1–3 takes the deep path (below). The
|
|
29
|
-
|
|
28
|
+
2. Depth 0 takes the quick path; depth 1–3 takes the deep path (below). The pi extension's
|
|
29
|
+
tool and the TUI are restricted to levels 1–3; the CLI, SDK, and agent skill can pass 0.
|
|
30
30
|
3. On the deep path the coordinator plans the research tracks and runs one initial
|
|
31
31
|
search burst, then hands each researcher a set of result URLs to start from.
|
|
32
32
|
4. Researchers scrape and read those pages through the scrape tooling and return cited
|
|
@@ -154,7 +154,8 @@ WriterQueue (async, non-blocking)
|
|
|
154
154
|
Key files: `src/knowledge/store.ts` (LanceDB operations), `embedder.ts` (model load +
|
|
155
155
|
batched inference), `writer-queue.ts` (async writes + content-hash dedup), `chunker.ts`
|
|
156
156
|
(chunking), `webgpu-viability.ts` (out-of-process GPU probe + cached verdict),
|
|
157
|
-
`migration.ts` (
|
|
157
|
+
`migration.ts` (migration strategy types — the drop / backup / re-embed logic
|
|
158
|
+
itself lives in `store.ts`).
|
|
158
159
|
|
|
159
160
|
The store needs native ONNX-runtime and LanceDB bindings. On platforms with no prebuilt
|
|
160
161
|
binary — notably Intel macOS (`darwin-x64`) — it is absent: the health check reports it
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -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. |
|
package/docs/KNOWLEDGE-STORE.md
CHANGED
|
@@ -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
|
|
178
|
-
|
|
179
|
-
|
|
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
|
@@ -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.
|
|
5
|
+
metadata: { "author": "Lincoln Deen", "version": "1.0.6", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
|
|
6
6
|
allowed-tools: Bash(node:*)
|
|
7
7
|
---
|
|
8
8
|
|