@lincoln504/pi-research 1.4.1 → 1.5.0
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 +449 -187
- package/dist/prompts/research-knowledge-relevance-triage.md +2 -0
- package/dist/prompts/research-knowledge-search-extractor.md +2 -0
- package/dist/prompts/researcher.md +2 -14
- package/dist/thread-worker.mjs +91 -59
- package/docs/AGENT-SKILL.md +4 -3
- package/docs/CHANGELOG.md +120 -0
- package/docs/CONFIGURATION.md +2 -2
- package/docs/SDK.md +1 -1
- package/package.json +1 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +44 -3
- package/src/config.ts +36 -13
- package/src/constants.ts +23 -4
- package/src/core/llm/research-model-resolver.ts +38 -0
- package/src/core/planning-service.ts +67 -9
- package/src/index.ts +3 -1
- package/src/infrastructure/browser/browser-task-scheduler.ts +16 -9
- package/src/infrastructure/browser/config.ts +40 -2
- package/src/infrastructure/browser/thread-worker-browser.ts +69 -48
- package/src/infrastructure/browser/thread-worker-messaging.ts +65 -4
- package/src/infrastructure/browser/thread-worker.mjs +91 -59
- package/src/infrastructure/browser/thread-worker.ts +13 -5
- package/src/infrastructure/embedding/embedding-server.ts +54 -0
- package/src/knowledge/embedder-init.ts +3 -2
- package/src/knowledge/embedder.ts +11 -3
- package/src/knowledge/index.ts +36 -10
- package/src/knowledge/store.ts +85 -5
- package/src/observers/research-observer-impl.ts +14 -6
- package/src/orchestration/deep-research-orchestrator.ts +3 -1
- package/src/orchestration/quick-research-orchestrator.ts +29 -10
- package/src/orchestration/research-synthesis-service.ts +32 -0
- package/src/orchestration/researcher-executor.ts +31 -16
- package/src/orchestration/researcher.ts +2 -2
- package/src/orchestration/session-state.ts +35 -4
- package/src/prompts/research-knowledge-relevance-triage.md +2 -0
- package/src/prompts/research-knowledge-search-extractor.md +2 -0
- package/src/prompts/researcher.md +2 -14
- package/src/sdk.ts +16 -0
- package/src/skill-install/skill-installer.ts +5 -1
- package/src/stackexchange/index.ts +3 -0
- package/src/stackexchange/rest-client.ts +7 -12
- package/src/tools/index.ts +2 -1
- package/src/tools/research-knowledge-search.ts +37 -4
- package/src/tools/research-tool-definition.ts +54 -12
- package/src/tools/scrape.ts +8 -1
- package/src/tools/security.ts +13 -10
- package/src/utils/coverage-digest.ts +1 -1
- package/src/utils/host-config.ts +12 -1
- package/src/utils/log-utils.ts +15 -2
- package/src/utils/metrics-summary.ts +21 -1
- package/src/utils/tool-usage-tracker.ts +6 -3
- package/src/web-research/browser-search.ts +11 -4
- package/src/web-research/web-scraper.ts +36 -16
- package/src/youtube/index.ts +11 -1
- package/src/youtube/potoken.ts +8 -1
- package/src/youtube/transcript-client.ts +56 -5
|
@@ -195,12 +195,15 @@ export class ToolUsageTracker {
|
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
/**
|
|
198
|
-
* Create default tool limits for a researcher
|
|
198
|
+
* Create default tool limits for a researcher.
|
|
199
|
+
*
|
|
200
|
+
* @param cachingActive - Whether prompt caching is active for this run's
|
|
201
|
+
* resolved model; see getMaxScrapeBatches in constants.ts.
|
|
199
202
|
*/
|
|
200
|
-
export function createDefaultToolLimits(config?: Config): ToolLimits {
|
|
203
|
+
export function createDefaultToolLimits(config?: Config, cachingActive = false): ToolLimits {
|
|
201
204
|
return {
|
|
202
205
|
gathering: getMaxGatheringCalls(config),
|
|
203
|
-
scrape: getMaxScrapeBatches(config),
|
|
206
|
+
scrape: getMaxScrapeBatches(config, cachingActive),
|
|
204
207
|
search: 1,
|
|
205
208
|
youtube_transcript: 1,
|
|
206
209
|
grep: MAX_GREP_CALLS,
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { runWorkerSearch } from '../infrastructure/browser/task-execution-service.ts';
|
|
8
|
-
import { getMaxWorkers } from '../infrastructure/browser/config.ts';
|
|
8
|
+
import { getMaxWorkers, COLD_START_ALLOWANCE_MS } from '../infrastructure/browser/config.ts';
|
|
9
9
|
import { isTaskTimeoutError } from '../infrastructure/browser/browser-error-utils.ts';
|
|
10
10
|
import { logger } from '../logger.ts';
|
|
11
11
|
import { safeUnref } from '../utils/safe-unref.ts';
|
|
@@ -82,8 +82,9 @@ export async function performSearch(
|
|
|
82
82
|
// cannot stall the entire Promise.all burst.
|
|
83
83
|
//
|
|
84
84
|
// The budget mirrors the scheduler's own task ceiling in
|
|
85
|
-
// BrowserTaskScheduler.runSearch (= SEARCH_TIMEOUT_MS + BROWSER_TASK_TIMEOUT_MS
|
|
86
|
-
// so this layer never preempts the worker's deadline with
|
|
85
|
+
// BrowserTaskScheduler.runSearch (= SEARCH_TIMEOUT_MS + BROWSER_TASK_TIMEOUT_MS +
|
|
86
|
+
// COLD_START_ALLOWANCE_MS) so this layer never preempts the worker's deadline with
|
|
87
|
+
// a shorter one.
|
|
87
88
|
//
|
|
88
89
|
// Both clocks now start at DISPATCH (see startQueryDeadline below), which is
|
|
89
90
|
// what makes mirroring the value correct. While this one started at enqueue,
|
|
@@ -95,7 +96,13 @@ export async function performSearch(
|
|
|
95
96
|
// query's worth of wait, and a burst four times the pool's reach exhausts any
|
|
96
97
|
// constant. Measuring execution instead of waiting is the fix; the sum
|
|
97
98
|
// survives as the budget because a search legitimately costs nav plus overhead.
|
|
98
|
-
|
|
99
|
+
//
|
|
100
|
+
// COLD_START_ALLOWANCE_MS is part of the sum for the same reason it is part of
|
|
101
|
+
// the scheduler's own ceiling: the first query dispatched to a freshly created or
|
|
102
|
+
// just-reset worker pays a real browser launch + context creation inline before
|
|
103
|
+
// any navigation starts, and this layer's budget must not fire before the
|
|
104
|
+
// scheduler's own (correct, larger) deadline gets a chance to.
|
|
105
|
+
const QUERY_TIMEOUT_MS = (config?.SEARCH_TIMEOUT_MS ?? 45_000) + (config?.BROWSER_TASK_TIMEOUT_MS ?? 10_000) + COLD_START_ALLOWANCE_MS;
|
|
99
106
|
|
|
100
107
|
// Deduplicated: resultMap and the failures map are both keyed by the query
|
|
101
108
|
// string, so two tasks for the same string race last-writer-wins — instance A
|
|
@@ -75,6 +75,15 @@ async function convertToMarkdown(html: string): Promise<string> {
|
|
|
75
75
|
return converter(html);
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
// TODO(hardening, medium): this synchronous WASM parse runs on the MAIN
|
|
79
|
+
// process's event loop — a large/complex PDF can block it for seconds,
|
|
80
|
+
// starving every concurrent scrape, the TUI render loop, and timers. The
|
|
81
|
+
// correct fix is a worker_threads-based extraction (mirroring the browser
|
|
82
|
+
// pool's CPU isolation pattern), but it is deliberately NOT done in this
|
|
83
|
+
// pass: pdf-oxide-wasm is dynamically imported here and unit tests mock the
|
|
84
|
+
// module via vitest, which cannot reach into a real worker thread — moving
|
|
85
|
+
// the parse requires a companion test-seam redesign first. Bounded today by
|
|
86
|
+
// MAX_PDF_SIZE (100MB) and the scrape task's own deadline.
|
|
78
87
|
async function extractPdfToMarkdown(bytes: Uint8Array): Promise<string> {
|
|
79
88
|
if (bytes.length > MAX_PDF_SIZE) {
|
|
80
89
|
const sizeMB = Math.round(bytes.length / 1024 / 1024);
|
|
@@ -123,23 +132,34 @@ async function extractPdfToMarkdown(bytes: Uint8Array): Promise<string> {
|
|
|
123
132
|
|
|
124
133
|
try {
|
|
125
134
|
const doc = new WasmPdfDocument(bytes);
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
135
|
+
// free() is native/WASM-side memory, not GC-reclaimed — it must run on
|
|
136
|
+
// every exit, including the case toMarkdownAll() AND the per-page fallback
|
|
137
|
+
// both throw (a malformed/encrypted PDF the codebase already treats as
|
|
138
|
+
// routine on the open web, so this path fires regularly). It used to sit
|
|
139
|
+
// after the fallback loop, reachable only when nothing below it threw —
|
|
140
|
+
// the one case that needed the fallback landing squarely on the one case
|
|
141
|
+
// that skipped the free(), leaking one WasmPdfDocument per occurrence in a
|
|
142
|
+
// browser-server process that stays warm across many research sessions.
|
|
130
143
|
try {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
144
|
+
const pageCount = doc.pageCount();
|
|
145
|
+
|
|
146
|
+
let markdown = `# PDF Document\n\n**Pages:** ${pageCount}\n\n`;
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
markdown += doc.toMarkdownAll();
|
|
150
|
+
} catch {
|
|
151
|
+
for (let i = 0; i < pageCount; i++) {
|
|
152
|
+
markdown += `## Page ${i + 1}\n\n${doc.toMarkdown(i)}\n\n`;
|
|
153
|
+
}
|
|
135
154
|
}
|
|
155
|
+
|
|
156
|
+
const pdfExtractionDuration = Date.now() - pdfExtractionStart;
|
|
157
|
+
metrics.observe('scrape_pdf_conversion_ms', pdfExtractionDuration);
|
|
158
|
+
metrics.increment('scrape_pdf_conversions_total', 1, { status: 'success', pages: String(pageCount) });
|
|
159
|
+
return markdown;
|
|
160
|
+
} finally {
|
|
161
|
+
doc.free();
|
|
136
162
|
}
|
|
137
|
-
|
|
138
|
-
doc.free();
|
|
139
|
-
const pdfExtractionDuration = Date.now() - pdfExtractionStart;
|
|
140
|
-
metrics.observe('scrape_pdf_conversion_ms', pdfExtractionDuration);
|
|
141
|
-
metrics.increment('scrape_pdf_conversions_total', 1, { status: 'success', pages: String(pageCount) });
|
|
142
|
-
return markdown;
|
|
143
163
|
} catch (e) {
|
|
144
164
|
const msg = e instanceof Error ? e.message : String(e);
|
|
145
165
|
// debug, not error: a malformed/encrypted/scanned-image-only PDF is an
|
|
@@ -300,7 +320,7 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
|
|
|
300
320
|
const contentLength = response.headers.get('content-length');
|
|
301
321
|
if (contentLength) {
|
|
302
322
|
const size = parseInt(contentLength, 10);
|
|
303
|
-
if (contentType.includes('application/pdf') ||
|
|
323
|
+
if (contentType.includes('application/pdf') || currentUrl.toLowerCase().endsWith('.pdf')) {
|
|
304
324
|
if (size > MAX_PDF_SIZE) {
|
|
305
325
|
const sizeMB = Math.round(size / 1024 / 1024);
|
|
306
326
|
logger.warn(`[Scrapers] PDF too large (Content-Length: ${sizeMB}MB, max 100MB), skipping`);
|
|
@@ -319,7 +339,7 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
|
|
|
319
339
|
}
|
|
320
340
|
}
|
|
321
341
|
|
|
322
|
-
if (contentType.includes('application/pdf') ||
|
|
342
|
+
if (contentType.includes('application/pdf') || currentUrl.toLowerCase().endsWith('.pdf')) {
|
|
323
343
|
let pdfBytes: Uint8Array;
|
|
324
344
|
try {
|
|
325
345
|
pdfBytes = await readBodyCapped(response, MAX_PDF_SIZE);
|
package/src/youtube/index.ts
CHANGED
|
@@ -10,6 +10,8 @@ import type { AgentToolResult } from '@earendil-works/pi-coding-agent';
|
|
|
10
10
|
import { selectVideoIds } from './video-id.ts';
|
|
11
11
|
import { fetchVideoTranscripts, type VideoTranscript } from './transcript-client.ts';
|
|
12
12
|
import { metrics } from '../utils/metrics.ts';
|
|
13
|
+
import { truncateWithMarker } from '../utils/text-utils.ts';
|
|
14
|
+
import { MAX_SCRAPE_CONTENT_CHARS_PER_DOC } from '../constants.ts';
|
|
13
15
|
|
|
14
16
|
export interface YoutubeTranscriptCommandOptions {
|
|
15
17
|
urls: string[];
|
|
@@ -131,7 +133,15 @@ function formatResults(results: VideoTranscript[], rejected: string[]): string {
|
|
|
131
133
|
// model can copy verbatim; omitting the line entirely for untitled videos
|
|
132
134
|
// left them without any prompt-visible citation string to reuse.
|
|
133
135
|
md += `**Cite as:** ${r.url}${r.title ? ` — '${r.title}'${r.author ? ` by ${r.author}` : ''}` : ''}\n`;
|
|
134
|
-
|
|
136
|
+
// Per-document cap on content entering LLM context — parity with the
|
|
137
|
+
// scrape tool's own MAX_SCRAPE_CONTENT_CHARS_PER_DOC guard. The raw HTTP
|
|
138
|
+
// body is capped at 32MB (readTextCapped) and the knowledge-store
|
|
139
|
+
// ingestion path truncates too, but this tool's direct response to the
|
|
140
|
+
// calling agent had no cap of its own: a single long/dense-captioned
|
|
141
|
+
// video (a multi-hour livestream VOD, worst case with maxVideos up to 5)
|
|
142
|
+
// could inject an unbounded, multi-megabyte payload straight into the
|
|
143
|
+
// researcher's context.
|
|
144
|
+
md += `\n${truncateWithMarker(r.text ?? '', MAX_SCRAPE_CONTENT_CHARS_PER_DOC)}\n\n---\n\n`;
|
|
135
145
|
}
|
|
136
146
|
|
|
137
147
|
if (failed.length > 0) {
|
package/src/youtube/potoken.ts
CHANGED
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
import { logger } from '../logger.ts';
|
|
45
45
|
import { safeUnref } from '../utils/safe-unref.ts';
|
|
46
46
|
import { metrics } from '../utils/metrics.ts';
|
|
47
|
+
import { readJsonCapped } from '../utils/http-body.ts';
|
|
47
48
|
|
|
48
49
|
/**
|
|
49
50
|
* Well-known request key for the YouTube web PoToken program. This is a public
|
|
@@ -229,7 +230,13 @@ async function doMint(
|
|
|
229
230
|
body: JSON.stringify([requestKey, botguardResponse]),
|
|
230
231
|
signal: opts.signal,
|
|
231
232
|
});
|
|
232
|
-
|
|
233
|
+
if (!integrityResponse.ok) {
|
|
234
|
+
throw new Error(`HTTP ${integrityResponse.status} from attestation server`);
|
|
235
|
+
}
|
|
236
|
+
// The attestation response is a short JSON array; readJsonCapped bounds it the
|
|
237
|
+
// same way as every other externally-controlled body this codebase reads (the
|
|
238
|
+
// default 32MB cap is already generous for this endpoint).
|
|
239
|
+
const integrityJson = await readJsonCapped<unknown[]>(integrityResponse);
|
|
233
240
|
const integrityToken = integrityJson?.[0] as string | undefined;
|
|
234
241
|
if (!integrityToken) {
|
|
235
242
|
throw new Error('Integrity token rejected by attestation server (likely a non-residential IP)');
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { withTimeout, retryWithBackoff, createTimeoutSignal } from '../web-research/retry-utils.ts';
|
|
21
|
+
import { getSsrfSafeFetcher } from '../web-research/scraper-utils.ts';
|
|
21
22
|
import { CircuitBreaker } from '../utils/circuit-breaker.ts';
|
|
22
23
|
import { logger } from '../logger.ts';
|
|
23
24
|
import { redactSecrets } from '../utils/log-utils.ts';
|
|
@@ -109,7 +110,31 @@ export async function fetchVideoTranscripts(
|
|
|
109
110
|
} catch {
|
|
110
111
|
// non-fatal: logging stays at default if the API ever changes
|
|
111
112
|
}
|
|
112
|
-
|
|
113
|
+
// Connect-time SSRF pin (see scraper-utils.ts's getSsrfSafeFetcher): closes the
|
|
114
|
+
// gap between assertTrustedTimedtextUrl's host check below and fetch's own
|
|
115
|
+
// re-resolution at connect time. The host allowlist only constrains WHICH
|
|
116
|
+
// hostname a redirect can name; it says nothing about what that hostname
|
|
117
|
+
// resolves to at the moment the socket opens, and this codebase already
|
|
118
|
+
// treats a resolver answering differently between validation and connect as
|
|
119
|
+
// a real threat elsewhere (thread-worker-messaging.ts's DNS-rebinding
|
|
120
|
+
// backstop applies even to already-trusted hosts). One fetchImpl closure
|
|
121
|
+
// covers every network call in this batch: the BotGuard/attestation fetches
|
|
122
|
+
// inside mintPoTokens() and the timedtext fetch below. Falls back to the
|
|
123
|
+
// plain global fetch for non-string/URL input (never seen in practice here)
|
|
124
|
+
// since the dispatcher pairing requires a URL string; see getSsrfSafeFetcher's
|
|
125
|
+
// own doc comment for why the fetch and its dispatcher must travel together.
|
|
126
|
+
const ssrfFetcher = await getSsrfSafeFetcher();
|
|
127
|
+
const fetchImpl: typeof fetch = ssrfFetcher
|
|
128
|
+
? (input, init) => {
|
|
129
|
+
if (typeof input === 'string' || input instanceof URL) {
|
|
130
|
+
return ssrfFetcher.fetch(String(input), {
|
|
131
|
+
...(init ?? {}),
|
|
132
|
+
dispatcher: ssrfFetcher.dispatcher,
|
|
133
|
+
}) as unknown as Promise<Response>;
|
|
134
|
+
}
|
|
135
|
+
return fetch(input, init);
|
|
136
|
+
}
|
|
137
|
+
: (input, init) => fetch(input, init);
|
|
113
138
|
|
|
114
139
|
// 1) Throwaway session to obtain stable visitor data for the PoToken binding.
|
|
115
140
|
let visitorData: string;
|
|
@@ -245,10 +270,36 @@ async function fetchOne(
|
|
|
245
270
|
// response headers AND the body read are bounded and tear the socket
|
|
246
271
|
// down on timeout/cancellation (withTimeout alone would not bound text()).
|
|
247
272
|
const fetchSignal = createTimeoutSignal(timeoutMs, signal);
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
273
|
+
// Manual redirect following, re-validating each hop against the same
|
|
274
|
+
// trusted-host allowlist as the first hop. `redirect: 'follow' (the
|
|
275
|
+
// default) would carry the PoToken-bearing request to whatever host a
|
|
276
|
+
// 3xx Location header names with no revalidation — assertTrustedTimedtextUrl
|
|
277
|
+
// above only ever checked base_url, the pre-redirect URL. fetchImpl
|
|
278
|
+
// carries the connect-time IP pin (see its construction above), so each
|
|
279
|
+
// hop is also protected against a resolver answering differently
|
|
280
|
+
// between this host check and the moment the socket actually opens.
|
|
281
|
+
const MAX_REDIRECTS = 5;
|
|
282
|
+
let currentUrl = url;
|
|
283
|
+
let response!: Response;
|
|
284
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
285
|
+
response = await fetchImpl(currentUrl, {
|
|
286
|
+
signal: fetchSignal,
|
|
287
|
+
redirect: 'manual',
|
|
288
|
+
headers: { 'User-Agent': 'Mozilla/5.0', 'Accept-Language': lang },
|
|
289
|
+
});
|
|
290
|
+
if (response.status >= 300 && response.status < 400) {
|
|
291
|
+
const location = response.headers.get('location');
|
|
292
|
+
if (!location) break; // No Location header — treat as final response
|
|
293
|
+
const resolved = new URL(location, currentUrl).href;
|
|
294
|
+
assertTrustedTimedtextUrl(resolved);
|
|
295
|
+
// Drain the redirect response before following: an unconsumed body
|
|
296
|
+
// pins its socket (undici keeps it open until read/cancelled or GC).
|
|
297
|
+
void response.body?.cancel()?.catch(() => { /* best-effort */ });
|
|
298
|
+
currentUrl = resolved;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
break; // Non-redirect response
|
|
302
|
+
}
|
|
252
303
|
// Surface the real HTTP status. Without this, a 403 (age-restricted) or
|
|
253
304
|
// 404 (removed) returns HTML that parseJson3 yields '' for, which would
|
|
254
305
|
// be misreported below as a bot-protection/PoToken failure.
|