@lincoln504/pi-research 1.3.9 → 1.3.11
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 +163 -43
- package/dist/thread-worker.mjs +25 -10
- package/docs/ARCHITECTURE.md +17 -14
- package/docs/CHANGELOG.md +64 -0
- package/docs/CONFIGURATION.md +3 -2
- package/package.json +1 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +58 -12
- package/src/config.ts +6 -3
- package/src/constants.ts +21 -13
- package/src/infrastructure/browser/thread-worker.mjs +25 -10
- package/src/knowledge/embedder-init.ts +17 -1
- package/src/knowledge/store.ts +64 -1
- package/src/knowledge/writer-queue.ts +28 -0
- package/src/orchestration/quick-research-orchestrator.ts +6 -6
- package/src/orchestration/research-orchestration-service.ts +13 -0
- package/src/orchestration/research-synthesis-service.ts +4 -2
- package/src/orchestration/researcher-executor.ts +8 -6
- package/src/tools/research-tool-definition.ts +8 -1
- package/src/utils/citation-utils.ts +4 -2
- package/src/utils/text-utils.ts +17 -3
- package/src/web-research/scraper-utils.ts +58 -12
- package/src/web-research/web-scraper.ts +13 -8
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ Three depth levels (normal, deep, ultra) set the team size and number of rounds.
|
|
|
66
66
|
- **No quota and no monthly fee.** Most AI search providers cap free searches and then charge, with results served from their index on their servers. pi-research searches and scrapes locally through DuckDuckGo in a stealth browser.
|
|
67
67
|
- **You own your index.** Every finding is written to a local [LanceDB](https://lancedb.com) knowledge store that seeds future runs, so repeat questions get faster and cheaper answers.
|
|
68
68
|
- **Cited reports rather than raw dumps.** You get one sourced Markdown report instead of raw web content pasted into the conversation. Context efficient and easy to verify.
|
|
69
|
-
- **Read-only by design.** The research agent cannot run shell commands or write, edit, or delete anything. It can read
|
|
69
|
+
- **Read-only by design.** The research agent cannot run shell commands or write, edit, or delete anything. It can read files, so treat it as you would any read-only agent with access to your machine.
|
|
70
70
|
- **Search a little or a lot.** Depth levels span a quick pass to a large-scale investigation.
|
|
71
71
|
|
|
72
72
|
### Requirements / limitations
|
package/dist/cli.mjs
CHANGED
|
@@ -2148,13 +2148,18 @@ function isPlausibleCitationUrl(url) {
|
|
|
2148
2148
|
}
|
|
2149
2149
|
}
|
|
2150
2150
|
function lastCitedLinksHeaderIndex(text) {
|
|
2151
|
+
return lastCitedLinksHeader(text).textStart;
|
|
2152
|
+
}
|
|
2153
|
+
function lastCitedLinksHeader(text) {
|
|
2151
2154
|
const re = /(^|\n)([ \t]*(?:#{1,6}[ \t]*|>[ \t]*|\*\*)?)CITED LINKS\b/gi;
|
|
2152
|
-
let
|
|
2155
|
+
let lineStart = -1;
|
|
2156
|
+
let textStart = -1;
|
|
2153
2157
|
let m;
|
|
2154
2158
|
while ((m = re.exec(text)) !== null) {
|
|
2155
|
-
|
|
2159
|
+
lineStart = m.index + m[1].length;
|
|
2160
|
+
textStart = m.index + m[1].length + m[2].length;
|
|
2156
2161
|
}
|
|
2157
|
-
return
|
|
2162
|
+
return { lineStart, textStart };
|
|
2158
2163
|
}
|
|
2159
2164
|
function parseCitations(report) {
|
|
2160
2165
|
const headerIdx = lastCitedLinksHeaderIndex(report);
|
|
@@ -2889,7 +2894,7 @@ function parseEnvNumber(env, key, def, min, max, integer = false) {
|
|
|
2889
2894
|
return n;
|
|
2890
2895
|
}
|
|
2891
2896
|
function parseEnvBool(env, key, def) {
|
|
2892
|
-
const v = env[key];
|
|
2897
|
+
const v = env[key]?.trim();
|
|
2893
2898
|
if (v === void 0 || v === "") return def;
|
|
2894
2899
|
return v.toLowerCase() === "true";
|
|
2895
2900
|
}
|
|
@@ -2897,7 +2902,7 @@ function parseEnvString(env, key, def) {
|
|
|
2897
2902
|
return env[key] || def;
|
|
2898
2903
|
}
|
|
2899
2904
|
function parseEnvEnum(env, key, allowed, def) {
|
|
2900
|
-
const raw = env[key];
|
|
2905
|
+
const raw = env[key]?.trim();
|
|
2901
2906
|
if (raw === void 0 || raw === "") return def;
|
|
2902
2907
|
if (allowed.includes(raw)) return raw;
|
|
2903
2908
|
logger.warn(`[config] Invalid ${key}="${raw}" (expected one of: ${allowed.join(", ")}). Using default "${def}".`);
|
|
@@ -3049,7 +3054,8 @@ var init_config = __esm({
|
|
|
3049
3054
|
/**
|
|
3050
3055
|
* Comma-separated list of research tools to DISABLE for every researcher in a run
|
|
3051
3056
|
* (e.g. "scrape", or "youtube_transcript,stackexchange"). Valid tool names:
|
|
3052
|
-
* search, scrape, security_search, stackexchange, youtube_transcript, grep
|
|
3057
|
+
* search, scrape, security_search, stackexchange, youtube_transcript, grep, read
|
|
3058
|
+
* (RESEARCH_TOOL_NAMES in constants.ts is the canonical list).
|
|
3053
3059
|
* Disable-only: the list is merged into the per-run excludeTools stream, so disabled
|
|
3054
3060
|
* tools are both removed from each researcher's toolset AND named in the
|
|
3055
3061
|
* coordinator/evaluator "DISABLED TOOLS" prompt section. Env-only
|
|
@@ -3132,10 +3138,9 @@ function getMaxScrapeBatches(config) {
|
|
|
3132
3138
|
}
|
|
3133
3139
|
}
|
|
3134
3140
|
function resolveExcludedTools(callerExclusions, configDisabled = []) {
|
|
3135
|
-
|
|
3136
|
-
return [.../* @__PURE__ */ new Set([...base, ...configDisabled])];
|
|
3141
|
+
return [.../* @__PURE__ */ new Set([...DEFAULT_EXCLUDED_TOOLS, ...callerExclusions ?? [], ...configDisabled])];
|
|
3137
3142
|
}
|
|
3138
|
-
var MAX_GATHERING_CALLS, MAX_GREP_CALLS, MAX_SCRAPE_URLS, MAX_TEAM_SIZE_LEVEL_1, MAX_TEAM_SIZE_LEVEL_2, MAX_TEAM_SIZE_LEVEL_3, MAX_ROUNDS_LEVEL_1, MAX_ROUNDS_LEVEL_2, MAX_ROUNDS_LEVEL_3, MAX_EXTRA_ROUNDS_WITH_STEERING, OSV_TIMEOUT_MS, WEBGPU_PROBE_TIMEOUT_MS, MAX_QUERY_LENGTH, MAX_FILENAME_QUERY_LENGTH, MIN_QUERY_LENGTH, MAX_EXPORT_RETRIES, DEFAULT_MAX_RETRIES, DEFAULT_INITIAL_DELAY_MS, DEFAULT_MAX_DELAY_MS, DEFAULT_MODEL_CONTEXT_WINDOW, BATCH_2_DEFAULT_CONCURRENCY, MAX_SCRAPE_CONTENT_CHARS_PER_DOC, RESEARCHER_LAUNCH_DELAY_MS, MAX_QUERIES_PER_RESEARCHER_LEVEL_1, MAX_QUERIES_PER_RESEARCHER_LEVEL_2, MAX_QUERIES_PER_RESEARCHER_LEVEL_3, DEFAULT_EXCLUDED_TOOLS;
|
|
3143
|
+
var MAX_GATHERING_CALLS, MAX_GREP_CALLS, MAX_SCRAPE_URLS, MAX_TEAM_SIZE_LEVEL_1, MAX_TEAM_SIZE_LEVEL_2, MAX_TEAM_SIZE_LEVEL_3, MAX_ROUNDS_LEVEL_1, MAX_ROUNDS_LEVEL_2, MAX_ROUNDS_LEVEL_3, MAX_EXTRA_ROUNDS_WITH_STEERING, OSV_TIMEOUT_MS, WEBGPU_PROBE_TIMEOUT_MS, MAX_QUERY_LENGTH, MAX_FILENAME_QUERY_LENGTH, MIN_QUERY_LENGTH, MAX_EXPORT_RETRIES, DEFAULT_MAX_RETRIES, DEFAULT_INITIAL_DELAY_MS, DEFAULT_MAX_DELAY_MS, DEFAULT_MODEL_CONTEXT_WINDOW, BATCH_2_DEFAULT_CONCURRENCY, MAX_SCRAPE_CONTENT_CHARS_PER_DOC, RESEARCHER_LAUNCH_DELAY_MS, MAX_QUERIES_PER_RESEARCHER_LEVEL_1, MAX_QUERIES_PER_RESEARCHER_LEVEL_2, MAX_QUERIES_PER_RESEARCHER_LEVEL_3, DEFAULT_EXCLUDED_TOOLS, RESEARCH_TOOL_NAMES;
|
|
3139
3144
|
var init_constants = __esm({
|
|
3140
3145
|
"src/constants.ts"() {
|
|
3141
3146
|
"use strict";
|
|
@@ -3167,6 +3172,15 @@ var init_constants = __esm({
|
|
|
3167
3172
|
MAX_QUERIES_PER_RESEARCHER_LEVEL_2 = 15;
|
|
3168
3173
|
MAX_QUERIES_PER_RESEARCHER_LEVEL_3 = 20;
|
|
3169
3174
|
DEFAULT_EXCLUDED_TOOLS = ["grep"];
|
|
3175
|
+
RESEARCH_TOOL_NAMES = [
|
|
3176
|
+
"search",
|
|
3177
|
+
"scrape",
|
|
3178
|
+
"security_search",
|
|
3179
|
+
"stackexchange",
|
|
3180
|
+
"youtube_transcript",
|
|
3181
|
+
"grep",
|
|
3182
|
+
"read"
|
|
3183
|
+
];
|
|
3170
3184
|
}
|
|
3171
3185
|
});
|
|
3172
3186
|
|
|
@@ -5384,6 +5398,9 @@ var init_store = __esm({
|
|
|
5384
5398
|
logger.warn("[store] Failed to read table schema:", schemaErr);
|
|
5385
5399
|
}
|
|
5386
5400
|
} else {
|
|
5401
|
+
if (this.options.embedder.getDimension() === null) {
|
|
5402
|
+
await this.withEmbedderReconnect((embedder) => embedder.embedMany([" "]));
|
|
5403
|
+
}
|
|
5387
5404
|
this.table = await this.createTable();
|
|
5388
5405
|
}
|
|
5389
5406
|
await this.evictOldRecords();
|
|
@@ -5765,6 +5782,45 @@ var init_store = __esm({
|
|
|
5765
5782
|
}
|
|
5766
5783
|
}
|
|
5767
5784
|
}
|
|
5785
|
+
/**
|
|
5786
|
+
* Refresh the timestamp of one CURRENT generation for url+type — the
|
|
5787
|
+
* revalidation "touch" for the writer-queue's content-unchanged skip. Without
|
|
5788
|
+
* it a stable page kept its original timestamp forever: with
|
|
5789
|
+
* KNOWLEDGE_STORE_MAX_SERVE_AGE_DAYS set, the serve-age gate then treated the
|
|
5790
|
+
* entry as permanently stale (a live re-scrape on every run, for exactly the
|
|
5791
|
+
* content that never changes), and TTL eviction eventually deleted an entry
|
|
5792
|
+
* that had been re-verified byte-identical possibly hours earlier.
|
|
5793
|
+
*/
|
|
5794
|
+
async touchByUrlAndType(url, ingestionType, generationTs, newTs) {
|
|
5795
|
+
return this.trackOperation("touchByUrlAndType", void 0, async () => {
|
|
5796
|
+
let retryCount = 0;
|
|
5797
|
+
const MAX_RETRIES = 5;
|
|
5798
|
+
const BASE_DELAY = 100;
|
|
5799
|
+
const scopeFilter = this.getScopeFilter();
|
|
5800
|
+
while (retryCount <= MAX_RETRIES) {
|
|
5801
|
+
const table = await this.getFreshTable();
|
|
5802
|
+
try {
|
|
5803
|
+
const escapedUrl = url.replace(/'/g, "''");
|
|
5804
|
+
const escapedType = ingestionType.replace(/'/g, "''");
|
|
5805
|
+
await table.update({
|
|
5806
|
+
where: `url = '${escapedUrl}' AND ingestion_type = '${escapedType}' AND timestamp = ${Math.floor(generationTs)} AND (${scopeFilter})`,
|
|
5807
|
+
valuesSql: { timestamp: String(Math.floor(newTs)) }
|
|
5808
|
+
});
|
|
5809
|
+
logger.log(`[store] Refreshed timestamp for unchanged ${ingestionType} chunks of ${url}`);
|
|
5810
|
+
return;
|
|
5811
|
+
} catch (err) {
|
|
5812
|
+
if (isLanceCommitConflict(err)) {
|
|
5813
|
+
retryCount++;
|
|
5814
|
+
if (retryCount > MAX_RETRIES) throw err;
|
|
5815
|
+
const delay2 = Math.floor(Math.random() * (BASE_DELAY * Math.pow(2, retryCount - 1)));
|
|
5816
|
+
await new Promise((r) => setTimeout(r, delay2));
|
|
5817
|
+
continue;
|
|
5818
|
+
}
|
|
5819
|
+
throw err;
|
|
5820
|
+
}
|
|
5821
|
+
}
|
|
5822
|
+
});
|
|
5823
|
+
}
|
|
5768
5824
|
async findDocumentsByUrl(url) {
|
|
5769
5825
|
return this.trackOperation("findDocumentsByUrl", [], async () => {
|
|
5770
5826
|
const table = await this.getFreshTable();
|
|
@@ -6002,7 +6058,10 @@ var init_store = __esm({
|
|
|
6002
6058
|
backupDirs.map(async (name) => ({ name, ts: await this.backupDirTimestamp(name) }))
|
|
6003
6059
|
);
|
|
6004
6060
|
ranked.sort((a, b) => a.ts - b.ts);
|
|
6005
|
-
|
|
6061
|
+
const ttlDays = this.options.ttlDays ?? 30;
|
|
6062
|
+
const backupCutoff = Math.trunc(Date.now() - ttlDays * 24 * 60 * 60 * 1e3);
|
|
6063
|
+
for (const { name, ts } of ranked.slice(0, -1)) {
|
|
6064
|
+
if (ts >= backupCutoff) continue;
|
|
6006
6065
|
const fullPath = path13.join(this.options.dbDir, name);
|
|
6007
6066
|
logger.info(`[store] Removing superseded backup directory: ${name}`);
|
|
6008
6067
|
await fsPromises.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
@@ -6298,6 +6357,17 @@ var init_writer_queue = __esm({
|
|
|
6298
6357
|
metrics.increment("knowledge_ingest_stale_prune_failed_total", 1);
|
|
6299
6358
|
});
|
|
6300
6359
|
}
|
|
6360
|
+
if (this.options.store.touchByUrlAndType) {
|
|
6361
|
+
try {
|
|
6362
|
+
const recheck = (await this.options.store.findByUrl(normalizedUrl)).filter((c) => c.metadata["ingestionType"] === incomingType);
|
|
6363
|
+
const stillNewest = recheck.length > 0 && Math.max(...recheck.map((d) => Number(d.timestamp) || 0)) === newestTs;
|
|
6364
|
+
if (stillNewest) {
|
|
6365
|
+
await this.options.store.touchByUrlAndType(normalizedUrl, incomingType, newestTs, Date.now());
|
|
6366
|
+
}
|
|
6367
|
+
} catch (err) {
|
|
6368
|
+
logger.warn(`[writer-queue] Timestamp refresh for unchanged ${normalizedUrl} (${incomingType}) failed:`, err);
|
|
6369
|
+
}
|
|
6370
|
+
}
|
|
6301
6371
|
logger.log(`[writer-queue] Skipping ${normalizedUrl} (${incomingType}) \u2014 content unchanged.`);
|
|
6302
6372
|
return;
|
|
6303
6373
|
}
|
|
@@ -6776,7 +6846,17 @@ async function loadPipelineWithTimeout(model, device, timeoutMs, useCache) {
|
|
|
6776
6846
|
interOpNumThreads: 1
|
|
6777
6847
|
}
|
|
6778
6848
|
});
|
|
6779
|
-
|
|
6849
|
+
let timedOut = true;
|
|
6850
|
+
try {
|
|
6851
|
+
const p = await withTimeout3(pipelinePromise, timeoutMs, errorMessage);
|
|
6852
|
+
timedOut = false;
|
|
6853
|
+
return p;
|
|
6854
|
+
} finally {
|
|
6855
|
+
if (timedOut) {
|
|
6856
|
+
pipelinePromise.then((late) => late.dispose?.()).catch(() => {
|
|
6857
|
+
});
|
|
6858
|
+
}
|
|
6859
|
+
}
|
|
6780
6860
|
});
|
|
6781
6861
|
return { pipeline: loadedPipeline, errorMessage };
|
|
6782
6862
|
}
|
|
@@ -10421,7 +10501,7 @@ function normalizeCitations(reports) {
|
|
|
10421
10501
|
localToGlobal.set(localKey, globalId);
|
|
10422
10502
|
}
|
|
10423
10503
|
});
|
|
10424
|
-
const headerIdx =
|
|
10504
|
+
const headerIdx = lastCitedLinksHeader(report).lineStart;
|
|
10425
10505
|
let content = headerIdx >= 0 ? report.slice(0, headerIdx) : report;
|
|
10426
10506
|
content = rewriteCitationMarkers(
|
|
10427
10507
|
content,
|
|
@@ -17024,17 +17104,30 @@ function ssrfSafeLookup(hostname, options, callback, resolveImpl = dnsCb.lookup)
|
|
|
17024
17104
|
return callback(null, safe[0].address, safe[0].family);
|
|
17025
17105
|
});
|
|
17026
17106
|
}
|
|
17027
|
-
|
|
17028
|
-
|
|
17029
|
-
|
|
17107
|
+
function isSsrfBlockError(error) {
|
|
17108
|
+
let e = error;
|
|
17109
|
+
for (let depth = 0; depth < 5 && e instanceof Error; depth++) {
|
|
17110
|
+
if (e.code === "ESSRFBLOCKED" || e.message.includes("not allowed")) {
|
|
17111
|
+
return true;
|
|
17112
|
+
}
|
|
17113
|
+
e = e.cause;
|
|
17114
|
+
}
|
|
17115
|
+
return false;
|
|
17116
|
+
}
|
|
17117
|
+
var cachedSsrfFetcher = void 0;
|
|
17118
|
+
async function getSsrfSafeFetcher() {
|
|
17119
|
+
if (cachedSsrfFetcher !== void 0) return cachedSsrfFetcher;
|
|
17030
17120
|
try {
|
|
17031
|
-
const { Agent: Agent2 } = await import("undici");
|
|
17032
|
-
|
|
17121
|
+
const { Agent: Agent2, fetch: undiciFetch } = await import("undici");
|
|
17122
|
+
cachedSsrfFetcher = {
|
|
17123
|
+
fetch: undiciFetch,
|
|
17124
|
+
dispatcher: new Agent2({ connect: { lookup: ssrfSafeLookup } })
|
|
17125
|
+
};
|
|
17033
17126
|
} catch (e) {
|
|
17034
17127
|
logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
|
|
17035
|
-
|
|
17128
|
+
cachedSsrfFetcher = null;
|
|
17036
17129
|
}
|
|
17037
|
-
return
|
|
17130
|
+
return cachedSsrfFetcher;
|
|
17038
17131
|
}
|
|
17039
17132
|
function formatErrorWithCause(err) {
|
|
17040
17133
|
try {
|
|
@@ -17284,7 +17377,7 @@ function decodeHtmlBody(bytes, contentTypeHeader) {
|
|
|
17284
17377
|
}
|
|
17285
17378
|
}
|
|
17286
17379
|
function trackFetchLayerFailure(url, error) {
|
|
17287
|
-
const isSsrf = error
|
|
17380
|
+
const isSsrf = isSsrfBlockError(error);
|
|
17288
17381
|
errorTracker.trackError(error instanceof Error ? error : String(error), {
|
|
17289
17382
|
component: "scrapers",
|
|
17290
17383
|
operation: "fetch",
|
|
@@ -17296,7 +17389,8 @@ function trackFetchLayerFailure(url, error) {
|
|
|
17296
17389
|
}
|
|
17297
17390
|
async function scrapeWithFetch(url, signal) {
|
|
17298
17391
|
await validateUrlForSSRF(url);
|
|
17299
|
-
const
|
|
17392
|
+
const fetcher = await getSsrfSafeFetcher();
|
|
17393
|
+
const doFetch = fetcher?.fetch ?? ((u, init) => fetch(u, init));
|
|
17300
17394
|
const controller = new AbortController();
|
|
17301
17395
|
const timeoutId = setTimeout(() => controller.abort(), FETCH_LAYER_TIMEOUT);
|
|
17302
17396
|
safeUnref(timeoutId);
|
|
@@ -17315,15 +17409,15 @@ async function scrapeWithFetch(url, signal) {
|
|
|
17315
17409
|
let response;
|
|
17316
17410
|
const userAgent = getRandomUserAgent();
|
|
17317
17411
|
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
17318
|
-
response = await
|
|
17412
|
+
response = await doFetch(currentUrl, {
|
|
17319
17413
|
signal: controller.signal,
|
|
17320
17414
|
redirect: "manual",
|
|
17321
17415
|
headers: {
|
|
17322
17416
|
"User-Agent": userAgent,
|
|
17323
17417
|
"Accept": "text/html,application/xhtml+xml,application/pdf,*/*;q=0.8"
|
|
17324
17418
|
},
|
|
17325
|
-
//
|
|
17326
|
-
...
|
|
17419
|
+
// Only ever attached alongside the fetch that honours it (see above).
|
|
17420
|
+
...fetcher ? { dispatcher: fetcher.dispatcher } : {}
|
|
17327
17421
|
});
|
|
17328
17422
|
if (response.status >= 300 && response.status < 400) {
|
|
17329
17423
|
const location = response.headers.get("location");
|
|
@@ -17424,7 +17518,7 @@ async function scrapeWithFetch(url, signal) {
|
|
|
17424
17518
|
metrics.observe("scrape_latency_ms", fetchDuration, { layer: "fetch", content_type: "html", status: "success" });
|
|
17425
17519
|
return { source: "fetch", layer: "fetch", markdown };
|
|
17426
17520
|
} catch (error) {
|
|
17427
|
-
if (error
|
|
17521
|
+
if (isSsrfBlockError(error)) {
|
|
17428
17522
|
metrics.increment("scrape_operations_total", 1, { layer: "fetch", status: "ssrf_blocked" });
|
|
17429
17523
|
metrics.observe("scrape_latency_ms", Date.now() - fetchStart, { layer: "fetch", status: "ssrf_blocked" });
|
|
17430
17524
|
throw error;
|
|
@@ -21618,7 +21712,7 @@ async function createResearcherSession(options) {
|
|
|
21618
21712
|
|
|
21619
21713
|
// src/orchestration/researcher-executor.ts
|
|
21620
21714
|
init_text_utils();
|
|
21621
|
-
|
|
21715
|
+
init_llm_usage();
|
|
21622
21716
|
init_logger();
|
|
21623
21717
|
init_metrics();
|
|
21624
21718
|
init_service_interfaces();
|
|
@@ -22054,12 +22148,13 @@ ${prompt}`);
|
|
|
22054
22148
|
if (msg2?.["role"] !== "assistant") return;
|
|
22055
22149
|
const rawUsage = msg2["usage"];
|
|
22056
22150
|
if (rawUsage) {
|
|
22057
|
-
const { tokens, cost } =
|
|
22151
|
+
const { tokens, cost } = recordLlmUsage(resolvedModel, rawUsage, {
|
|
22152
|
+
component: "researcher",
|
|
22153
|
+
complexity,
|
|
22154
|
+
observer: { onTokensConsumed: (t, c) => safeObserve(() => observer?.onTokensConsumed?.(t, c)) }
|
|
22155
|
+
});
|
|
22058
22156
|
if (tokens > 0 || cost > 0) {
|
|
22059
|
-
metrics.increment("llm_tokens_total", tokens, { component: "researcher", complexity: String(complexity) });
|
|
22060
|
-
metrics.increment("llm_cost_total", cost, { component: "researcher", complexity: String(complexity) });
|
|
22061
22157
|
safeObserve(() => observer?.onResearcherProgress?.(id, void 0, tokens, cost));
|
|
22062
|
-
safeObserve(() => observer?.onTokensConsumed?.(tokens, cost));
|
|
22063
22158
|
}
|
|
22064
22159
|
}
|
|
22065
22160
|
} else if (event.type === "tool_execution_start") {
|
|
@@ -22186,7 +22281,7 @@ ${responseText}`);
|
|
|
22186
22281
|
|
|
22187
22282
|
// src/orchestration/quick-research-orchestrator.ts
|
|
22188
22283
|
init_prompts();
|
|
22189
|
-
|
|
22284
|
+
init_llm_usage();
|
|
22190
22285
|
init_logger();
|
|
22191
22286
|
init_config();
|
|
22192
22287
|
init_text_utils();
|
|
@@ -22467,12 +22562,13 @@ ${prompt}`);
|
|
|
22467
22562
|
if (msg2?.["role"] !== "assistant") return;
|
|
22468
22563
|
const rawUsage = msg2["usage"];
|
|
22469
22564
|
if (rawUsage) {
|
|
22470
|
-
const { tokens, cost } =
|
|
22565
|
+
const { tokens, cost } = recordLlmUsage(resolvedModel, rawUsage, {
|
|
22566
|
+
component: "quick_researcher",
|
|
22567
|
+
complexity: 0,
|
|
22568
|
+
observer
|
|
22569
|
+
});
|
|
22471
22570
|
if (tokens > 0 || cost > 0) {
|
|
22472
|
-
metrics.increment("llm_tokens_total", tokens, { component: "quick_researcher", complexity: "0" });
|
|
22473
|
-
metrics.increment("llm_cost_total", cost, { component: "quick_researcher", complexity: "0" });
|
|
22474
22571
|
observer?.onResearcherProgress?.("quick", void 0, tokens, cost);
|
|
22475
|
-
observer?.onTokensConsumed?.(tokens, cost);
|
|
22476
22572
|
}
|
|
22477
22573
|
}
|
|
22478
22574
|
} else if (event.type === "tool_execution_start") {
|
|
@@ -23075,6 +23171,13 @@ var ResearchOrchestrationService = class {
|
|
|
23075
23171
|
});
|
|
23076
23172
|
logger.info(`[ResearchOrchestrationService] Acquired research run slot ${runSlot.slotIndex} (cap ${semaphore.getMaxSlots()}).`);
|
|
23077
23173
|
} catch (err) {
|
|
23174
|
+
const isTerminal = err instanceof ResearchRunCapacityError || err instanceof Error && err.name === "AbortError";
|
|
23175
|
+
if (isTerminal) {
|
|
23176
|
+
try {
|
|
23177
|
+
observer?.onError?.(err);
|
|
23178
|
+
} catch {
|
|
23179
|
+
}
|
|
23180
|
+
}
|
|
23078
23181
|
if (err instanceof ResearchRunCapacityError) throw err;
|
|
23079
23182
|
if (err instanceof Error && err.name === "AbortError") throw err;
|
|
23080
23183
|
logger.warn(`[ResearchOrchestrationService] Run-cap unavailable, proceeding without it: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -23826,7 +23929,7 @@ Research performed using ${modelId}`;
|
|
|
23826
23929
|
if (globalId !== void 0) localToGlobal.set(localKey, globalId);
|
|
23827
23930
|
else droppedNumbers.add(localKey);
|
|
23828
23931
|
});
|
|
23829
|
-
let body = synthesis.slice(0,
|
|
23932
|
+
let body = synthesis.slice(0, lastCitedLinksHeader(synthesis).lineStart).trimEnd();
|
|
23830
23933
|
if (localToGlobal.size > 0 || droppedNumbers.size > 0) {
|
|
23831
23934
|
body = rewriteCitationMarkers(body, localToGlobal, (n) => droppedNumbers.has(n));
|
|
23832
23935
|
}
|
|
@@ -24749,6 +24852,7 @@ Total cost: ${costStr}`;
|
|
|
24749
24852
|
// src/cli.ts
|
|
24750
24853
|
init_config();
|
|
24751
24854
|
init_model_registry_factory();
|
|
24855
|
+
init_constants();
|
|
24752
24856
|
import { getAgentDir as getAgentDir2 } from "@earendil-works/pi-coding-agent";
|
|
24753
24857
|
function readPkgVersion() {
|
|
24754
24858
|
try {
|
|
@@ -24869,14 +24973,15 @@ function makeProgressObserver() {
|
|
|
24869
24973
|
var ConfigFileError = class extends Error {
|
|
24870
24974
|
};
|
|
24871
24975
|
function bridgeConfigEnv(explicitConfigPath) {
|
|
24872
|
-
const
|
|
24976
|
+
const globalBasePath = getGlobalEnvFilePath();
|
|
24977
|
+
const basePath = explicitConfigPath ?? globalBasePath;
|
|
24873
24978
|
const cliOverlayPath = getInterfaceEnvFilePath("cli");
|
|
24874
24979
|
let loaded = false;
|
|
24875
24980
|
const merged = {};
|
|
24876
24981
|
const fromExplicitBase = /* @__PURE__ */ new Set();
|
|
24877
|
-
const layers = explicitConfigPath !== void 0 ? [cliOverlayPath,
|
|
24982
|
+
const layers = explicitConfigPath !== void 0 ? [globalBasePath, cliOverlayPath, explicitConfigPath] : [globalBasePath, cliOverlayPath];
|
|
24878
24983
|
for (const filePath of layers) {
|
|
24879
|
-
const isExplicit = explicitConfigPath !== void 0 && filePath ===
|
|
24984
|
+
const isExplicit = explicitConfigPath !== void 0 && filePath === explicitConfigPath;
|
|
24880
24985
|
try {
|
|
24881
24986
|
if (!fs17.existsSync(filePath)) {
|
|
24882
24987
|
if (isExplicit) throw new ConfigFileError(`--config file not found: ${filePath}`);
|
|
@@ -25013,7 +25118,11 @@ async function detectCredentials(explicitModel) {
|
|
|
25013
25118
|
modelFrom,
|
|
25014
25119
|
piAuthPresent,
|
|
25015
25120
|
piModelsPresent,
|
|
25016
|
-
|
|
25121
|
+
// A model configured with no usable key is a different (and misleading to
|
|
25122
|
+
// conflate) gap than nothing configured at all: SKILL.md tells agents to relay
|
|
25123
|
+
// this text verbatim, so "No model … is configured" pointed users who HAD set
|
|
25124
|
+
// PI_RESEARCH_MODEL at the wrong knob.
|
|
25125
|
+
problem: model ? `A model is configured (${model}) but no API key is available for ${provider ? `provider "${provider}"` : "its provider"}. Provide one via the PI_RESEARCH_API_KEY environment variable (with PI_RESEARCH_PROVIDER), 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).` : "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)."
|
|
25017
25126
|
};
|
|
25018
25127
|
}
|
|
25019
25128
|
function configBlock(det, extraNote) {
|
|
@@ -25083,6 +25192,8 @@ Error: ${vmsg}
|
|
|
25083
25192
|
...args.excludeTools ? { excludeTools: args.excludeTools } : {},
|
|
25084
25193
|
...args.initialLinks ? { initialLinks: args.initialLinks } : {}
|
|
25085
25194
|
}, abortController.signal);
|
|
25195
|
+
const outcome = getLastResearcherOutcome();
|
|
25196
|
+
const errorReport = getLastErrorReport();
|
|
25086
25197
|
let reportPath = null;
|
|
25087
25198
|
const exportCfg = getConfig(process.cwd(), "cli");
|
|
25088
25199
|
if (exportCfg.RESEARCH_REPORT_EXPORT_ENABLED) {
|
|
@@ -25097,8 +25208,6 @@ Error: ${vmsg}
|
|
|
25097
25208
|
exit = cancellationSignal ? exitCodeForSignal(cancellationSignal) : EXIT.CANCELLED;
|
|
25098
25209
|
}
|
|
25099
25210
|
if (args.json) {
|
|
25100
|
-
const outcome = getLastResearcherOutcome();
|
|
25101
|
-
const errorReport = getLastErrorReport();
|
|
25102
25211
|
toStdout(pretty({
|
|
25103
25212
|
ok: true,
|
|
25104
25213
|
depth,
|
|
@@ -25175,8 +25284,11 @@ Configure in:
|
|
|
25175
25284
|
if (activeResearchAbortController === abortController) activeResearchAbortController = null;
|
|
25176
25285
|
await safeShutdown();
|
|
25177
25286
|
}
|
|
25287
|
+
if (cancellationRequested) {
|
|
25288
|
+
exit = cancellationSignal ? exitCodeForSignal(cancellationSignal) : EXIT.CANCELLED;
|
|
25289
|
+
}
|
|
25178
25290
|
if (json) {
|
|
25179
|
-
toStdout(pretty({ ok: true, ...result }));
|
|
25291
|
+
toStdout(pretty({ ok: true, ...cancellationRequested ? { cancelled: true } : {}, ...result }));
|
|
25180
25292
|
} else {
|
|
25181
25293
|
toStdout(result.text.endsWith("\n") ? result.text : result.text + "\n");
|
|
25182
25294
|
}
|
|
@@ -25511,6 +25623,7 @@ COMMANDS
|
|
|
25511
25623
|
--dry-run | --json Plan only; JSON output.
|
|
25512
25624
|
|
|
25513
25625
|
help, --help, -h Show this help.
|
|
25626
|
+
--version, -v Print the pi-research version.
|
|
25514
25627
|
|
|
25515
25628
|
CONFIGURE
|
|
25516
25629
|
Credentials are resolved in this order (first match wins):
|
|
@@ -25700,6 +25813,12 @@ function parseArgs(argv) {
|
|
|
25700
25813
|
} else if (a === "--exclude-tools") {
|
|
25701
25814
|
const v = takeOptionValue(rest, ++i, "--exclude-tools");
|
|
25702
25815
|
excludeTools = v.split(",").map((s) => s.trim()).filter(Boolean);
|
|
25816
|
+
const unknown = excludeTools.filter((t) => !RESEARCH_TOOL_NAMES.includes(t));
|
|
25817
|
+
if (unknown.length > 0) {
|
|
25818
|
+
throw new UsageError(
|
|
25819
|
+
`--exclude-tools: unknown tool name${unknown.length === 1 ? "" : "s"} ${unknown.map((t) => `"${t}"`).join(", ")}. Valid names: ${RESEARCH_TOOL_NAMES.join(", ")}.`
|
|
25820
|
+
);
|
|
25821
|
+
}
|
|
25703
25822
|
if (excludeTools.length === 0) excludeTools = void 0;
|
|
25704
25823
|
} else if (a === "--initial-links") {
|
|
25705
25824
|
let j = i + 1;
|
|
@@ -25713,6 +25832,7 @@ function parseArgs(argv) {
|
|
|
25713
25832
|
}
|
|
25714
25833
|
j++;
|
|
25715
25834
|
}
|
|
25835
|
+
if (j === i + 1) throw new UsageError("--initial-links requires at least one URL.");
|
|
25716
25836
|
i = j - 1;
|
|
25717
25837
|
} else if (a === "--config") {
|
|
25718
25838
|
configPath = takeOptionValue(rest, ++i, "--config");
|
package/dist/thread-worker.mjs
CHANGED
|
@@ -1512,8 +1512,9 @@ __export(scraper_utils_exports, {
|
|
|
1512
1512
|
extractDomain: () => extractDomain,
|
|
1513
1513
|
formatErrorWithCause: () => formatErrorWithCause,
|
|
1514
1514
|
getRandomUserAgent: () => getRandomUserAgent,
|
|
1515
|
-
|
|
1515
|
+
getSsrfSafeFetcher: () => getSsrfSafeFetcher,
|
|
1516
1516
|
isBenignScrapeFailure: () => isBenignScrapeFailure,
|
|
1517
|
+
isSsrfBlockError: () => isSsrfBlockError,
|
|
1517
1518
|
ssrfSafeLookup: () => ssrfSafeLookup,
|
|
1518
1519
|
stripImageLinks: () => stripImageLinks,
|
|
1519
1520
|
validateContent: () => validateContent,
|
|
@@ -1633,16 +1634,29 @@ function ssrfSafeLookup(hostname, options, callback, resolveImpl = dnsCb.lookup)
|
|
|
1633
1634
|
return callback(null, safe[0].address, safe[0].family);
|
|
1634
1635
|
});
|
|
1635
1636
|
}
|
|
1636
|
-
|
|
1637
|
-
|
|
1637
|
+
function isSsrfBlockError(error) {
|
|
1638
|
+
let e = error;
|
|
1639
|
+
for (let depth = 0; depth < 5 && e instanceof Error; depth++) {
|
|
1640
|
+
if (e.code === "ESSRFBLOCKED" || e.message.includes("not allowed")) {
|
|
1641
|
+
return true;
|
|
1642
|
+
}
|
|
1643
|
+
e = e.cause;
|
|
1644
|
+
}
|
|
1645
|
+
return false;
|
|
1646
|
+
}
|
|
1647
|
+
async function getSsrfSafeFetcher() {
|
|
1648
|
+
if (cachedSsrfFetcher !== void 0) return cachedSsrfFetcher;
|
|
1638
1649
|
try {
|
|
1639
|
-
const { Agent } = await import("undici");
|
|
1640
|
-
|
|
1650
|
+
const { Agent, fetch: undiciFetch } = await import("undici");
|
|
1651
|
+
cachedSsrfFetcher = {
|
|
1652
|
+
fetch: undiciFetch,
|
|
1653
|
+
dispatcher: new Agent({ connect: { lookup: ssrfSafeLookup } })
|
|
1654
|
+
};
|
|
1641
1655
|
} catch (e) {
|
|
1642
1656
|
logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
|
|
1643
|
-
|
|
1657
|
+
cachedSsrfFetcher = null;
|
|
1644
1658
|
}
|
|
1645
|
-
return
|
|
1659
|
+
return cachedSsrfFetcher;
|
|
1646
1660
|
}
|
|
1647
1661
|
function formatErrorWithCause(err) {
|
|
1648
1662
|
try {
|
|
@@ -1776,7 +1790,7 @@ function validateContent(html, markdown, url) {
|
|
|
1776
1790
|
throw error;
|
|
1777
1791
|
}
|
|
1778
1792
|
}
|
|
1779
|
-
var
|
|
1793
|
+
var cachedSsrfFetcher;
|
|
1780
1794
|
var init_scraper_utils = __esm({
|
|
1781
1795
|
"src/web-research/scraper-utils.ts"() {
|
|
1782
1796
|
"use strict";
|
|
@@ -1785,7 +1799,7 @@ var init_scraper_utils = __esm({
|
|
|
1785
1799
|
init_logger();
|
|
1786
1800
|
init_scraper_types();
|
|
1787
1801
|
init_browser_error_utils();
|
|
1788
|
-
|
|
1802
|
+
cachedSsrfFetcher = void 0;
|
|
1789
1803
|
}
|
|
1790
1804
|
});
|
|
1791
1805
|
|
|
@@ -2566,7 +2580,8 @@ var ConfigSchema = Type.Object({
|
|
|
2566
2580
|
/**
|
|
2567
2581
|
* Comma-separated list of research tools to DISABLE for every researcher in a run
|
|
2568
2582
|
* (e.g. "scrape", or "youtube_transcript,stackexchange"). Valid tool names:
|
|
2569
|
-
* search, scrape, security_search, stackexchange, youtube_transcript, grep
|
|
2583
|
+
* search, scrape, security_search, stackexchange, youtube_transcript, grep, read
|
|
2584
|
+
* (RESEARCH_TOOL_NAMES in constants.ts is the canonical list).
|
|
2570
2585
|
* Disable-only: the list is merged into the per-run excludeTools stream, so disabled
|
|
2571
2586
|
* tools are both removed from each researcher's toolset AND named in the
|
|
2572
2587
|
* coordinator/evaluator "DISABLED TOOLS" prompt section. Env-only
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -12,7 +12,7 @@ pi CLI
|
|
|
12
12
|
└── pi-research extension (src/index.ts)
|
|
13
13
|
├── Registered Tools research, health, research_knowledge_search (always registered; reports why when the store is disabled)
|
|
14
14
|
├── Commands /research, /research-config, /knowledge-store
|
|
15
|
-
├── Events input (mid-run steering), session_shutdown (cleanup)
|
|
15
|
+
├── Events input (mid-run steering), session_shutdown (cleanup), session_before_compact / session_compact, before_agent_start, after_provider_response
|
|
16
16
|
└── Layers
|
|
17
17
|
├── Orchestration quick/deep research coordination
|
|
18
18
|
├── Agent Tools search, scrape, youtube_transcript, security_search, stackexchange, grep, read
|
|
@@ -106,21 +106,23 @@ agent to invoke:
|
|
|
106
106
|
|------|-------|------|---------|
|
|
107
107
|
| `search` | ✓ | — | DuckDuckGo Lite via the stealth browser |
|
|
108
108
|
| `scrape` | ✓ | ✓ | Batch page fetch → Markdown via the stealth browser (up to 6 URLs per call) |
|
|
109
|
-
| `youtube_transcript` | ✓ | ✓ | YouTube captions via youtubei.js + BotGuard PoToken (≤3 videos, one call per researcher) |
|
|
109
|
+
| `youtube_transcript` | ✓ | ✓ | YouTube captions via youtubei.js + BotGuard PoToken (≤3 videos by default, configurable 1–5; one call per researcher) |
|
|
110
110
|
| `security_search` | ✓ | ✓ | NVD, CISA KEV, GitHub Advisories, OSV |
|
|
111
111
|
| `stackexchange` | ✓ | ✓ | Stack Exchange network |
|
|
112
|
-
| `grep` | — | — | Local ripgrep (from pi-coding-agent) —
|
|
112
|
+
| `grep` | — | — | Local ripgrep (from pi-coding-agent) — always excluded, see below |
|
|
113
113
|
| `read` | ✓ | ✓ | Local file reads (from pi-coding-agent) |
|
|
114
114
|
|
|
115
115
|
In deep research `search` is excluded — the coordinator runs the search burst and hands
|
|
116
116
|
out URLs directly.
|
|
117
117
|
|
|
118
|
-
`grep` is excluded
|
|
119
|
-
|
|
120
|
-
searching the local filesystem.
|
|
121
|
-
on the CLI, the `excludeTools` tool parameter in the extension
|
|
122
|
-
|
|
123
|
-
|
|
118
|
+
`grep` is excluded at **every** depth and on every front-end (CLI, SDK, agent skill,
|
|
119
|
+
pi extension): this is web research, and a capable model otherwise spends turns
|
|
120
|
+
searching the local filesystem. Both exclusion surfaces — the `excludeTools` list
|
|
121
|
+
(`--exclude-tools` on the CLI, the `excludeTools` tool parameter in the extension) and
|
|
122
|
+
`PI_RESEARCH_DISABLED_TOOLS` — are strictly additive on top of that default: they can
|
|
123
|
+
only ever remove capabilities (see [CONFIGURATION.md](CONFIGURATION.md)). Before 1.3.10
|
|
124
|
+
a non-empty `excludeTools` list replaced the default, so naming any other tool silently
|
|
125
|
+
re-enabled `grep`.
|
|
124
126
|
|
|
125
127
|
Researchers cannot write files, run shell commands, or reach the network outside these
|
|
126
128
|
tools.
|
|
@@ -288,11 +290,12 @@ src/
|
|
|
288
290
|
### Key design decisions
|
|
289
291
|
|
|
290
292
|
Read-only researchers — researcher agents are limited to the tool set above. They cannot
|
|
291
|
-
write files, spawn processes, or make arbitrary network calls. They *can* read
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
to
|
|
295
|
-
the boundary is "no mutation", not "only this
|
|
293
|
+
write files, spawn processes, or make arbitrary network calls. They *can* read files:
|
|
294
|
+
`read` is registered and the researcher exclusion list (`bash`, `write`, `edit`, `repl`,
|
|
295
|
+
`git`, `terminal`) does not cover it. Local `grep` is registered but always excluded (see
|
|
296
|
+
the tool table above). The `cwd` passed to `read` is a resolution base, not a jail — an
|
|
297
|
+
absolute path resolves to itself — so the boundary is "no mutation", not "only this
|
|
298
|
+
directory".
|
|
296
299
|
|
|
297
300
|
Worker pool over direct browser — browser processes are isolated in workers so a crash
|
|
298
301
|
in one cannot affect the orchestrator or other sessions.
|