@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.
Files changed (58) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.mjs +449 -187
  3. package/dist/prompts/research-knowledge-relevance-triage.md +2 -0
  4. package/dist/prompts/research-knowledge-search-extractor.md +2 -0
  5. package/dist/prompts/researcher.md +2 -14
  6. package/dist/thread-worker.mjs +91 -59
  7. package/docs/AGENT-SKILL.md +4 -3
  8. package/docs/CHANGELOG.md +120 -0
  9. package/docs/CONFIGURATION.md +2 -2
  10. package/docs/SDK.md +1 -1
  11. package/package.json +1 -1
  12. package/skills/pi-research/SKILL.md +1 -1
  13. package/src/cli.ts +44 -3
  14. package/src/config.ts +36 -13
  15. package/src/constants.ts +23 -4
  16. package/src/core/llm/research-model-resolver.ts +38 -0
  17. package/src/core/planning-service.ts +67 -9
  18. package/src/index.ts +3 -1
  19. package/src/infrastructure/browser/browser-task-scheduler.ts +16 -9
  20. package/src/infrastructure/browser/config.ts +40 -2
  21. package/src/infrastructure/browser/thread-worker-browser.ts +69 -48
  22. package/src/infrastructure/browser/thread-worker-messaging.ts +65 -4
  23. package/src/infrastructure/browser/thread-worker.mjs +91 -59
  24. package/src/infrastructure/browser/thread-worker.ts +13 -5
  25. package/src/infrastructure/embedding/embedding-server.ts +54 -0
  26. package/src/knowledge/embedder-init.ts +3 -2
  27. package/src/knowledge/embedder.ts +11 -3
  28. package/src/knowledge/index.ts +36 -10
  29. package/src/knowledge/store.ts +85 -5
  30. package/src/observers/research-observer-impl.ts +14 -6
  31. package/src/orchestration/deep-research-orchestrator.ts +3 -1
  32. package/src/orchestration/quick-research-orchestrator.ts +29 -10
  33. package/src/orchestration/research-synthesis-service.ts +32 -0
  34. package/src/orchestration/researcher-executor.ts +31 -16
  35. package/src/orchestration/researcher.ts +2 -2
  36. package/src/orchestration/session-state.ts +35 -4
  37. package/src/prompts/research-knowledge-relevance-triage.md +2 -0
  38. package/src/prompts/research-knowledge-search-extractor.md +2 -0
  39. package/src/prompts/researcher.md +2 -14
  40. package/src/sdk.ts +16 -0
  41. package/src/skill-install/skill-installer.ts +5 -1
  42. package/src/stackexchange/index.ts +3 -0
  43. package/src/stackexchange/rest-client.ts +7 -12
  44. package/src/tools/index.ts +2 -1
  45. package/src/tools/research-knowledge-search.ts +37 -4
  46. package/src/tools/research-tool-definition.ts +54 -12
  47. package/src/tools/scrape.ts +8 -1
  48. package/src/tools/security.ts +13 -10
  49. package/src/utils/coverage-digest.ts +1 -1
  50. package/src/utils/host-config.ts +12 -1
  51. package/src/utils/log-utils.ts +15 -2
  52. package/src/utils/metrics-summary.ts +21 -1
  53. package/src/utils/tool-usage-tracker.ts +6 -3
  54. package/src/web-research/browser-search.ts +11 -4
  55. package/src/web-research/web-scraper.ts +36 -16
  56. package/src/youtube/index.ts +11 -1
  57. package/src/youtube/potoken.ts +8 -1
  58. 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 a shorter one.
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
- const QUERY_TIMEOUT_MS = (config?.SEARCH_TIMEOUT_MS ?? 45_000) + (config?.BROWSER_TASK_TIMEOUT_MS ?? 10_000);
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
- const pageCount = doc.pageCount();
127
-
128
- let markdown = `# PDF Document\n\n**Pages:** ${pageCount}\n\n`;
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
- markdown += doc.toMarkdownAll();
132
- } catch {
133
- for (let i = 0; i < pageCount; i++) {
134
- markdown += `## Page ${i + 1}\n\n${doc.toMarkdown(i)}\n\n`;
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') || url.toLowerCase().endsWith('.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') || url.toLowerCase().endsWith('.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);
@@ -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
- md += `\n${r.text}\n\n---\n\n`;
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) {
@@ -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
- const integrityJson = (await integrityResponse.json()) as unknown[];
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
- const fetchImpl: typeof fetch = (input, init) => fetch(input, init);
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
- const response = await fetchImpl(url, {
249
- signal: fetchSignal,
250
- headers: { 'User-Agent': 'Mozilla/5.0', 'Accept-Language': lang },
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.