@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
|
@@ -140,6 +140,57 @@ function decodeHtmlBody(bytes: Uint8Array, contentTypeHeader: string): string {
|
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/** Thrown by readBodyCapped when a streamed body crosses its byte cap. */
|
|
144
|
+
class BodyTooLargeError extends Error {
|
|
145
|
+
constructor(public readonly bytesRead: number, public readonly maxBytes: number) {
|
|
146
|
+
super(`Response body exceeded ${maxBytes} byte cap`);
|
|
147
|
+
this.name = 'BodyTooLargeError';
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Read a response body with a HARD byte cap, streaming chunk-by-chunk so an
|
|
153
|
+
* over-limit body aborts the moment the cap is crossed. The Content-Length
|
|
154
|
+
* pre-screen above is advisory only: a chunked (or lying) response carries no
|
|
155
|
+
* usable length header, and `response.arrayBuffer()` would otherwise buffer the
|
|
156
|
+
* entire body — potentially hundreds of MB from a hostile server — into the
|
|
157
|
+
* main process before any size check ran.
|
|
158
|
+
*
|
|
159
|
+
* A body-less response (test doubles, some mocks) falls back to arrayBuffer();
|
|
160
|
+
* callers keep their post-read size checks as the belt for that path.
|
|
161
|
+
*/
|
|
162
|
+
async function readBodyCapped(response: Response, maxBytes: number): Promise<Uint8Array> {
|
|
163
|
+
const body = response.body;
|
|
164
|
+
// Also tolerate non-standard doubles that expose a body without getReader.
|
|
165
|
+
if (!body || typeof body.getReader !== 'function') {
|
|
166
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const reader = body.getReader();
|
|
170
|
+
const chunks: Uint8Array[] = [];
|
|
171
|
+
let total = 0;
|
|
172
|
+
try {
|
|
173
|
+
for (;;) {
|
|
174
|
+
const { done, value } = await reader.read();
|
|
175
|
+
if (done) break;
|
|
176
|
+
if (value) {
|
|
177
|
+
total += value.byteLength;
|
|
178
|
+
if (total > maxBytes) throw new BodyTooLargeError(total, maxBytes);
|
|
179
|
+
chunks.push(value);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
} finally {
|
|
183
|
+
// Over-cap or downstream throw: release the connection instead of leaving
|
|
184
|
+
// the socket open (and the server still sending) until GC. No-op after a
|
|
185
|
+
// clean done=true read.
|
|
186
|
+
try { await reader.cancel(); } catch { /* already closed */ }
|
|
187
|
+
}
|
|
188
|
+
const out = new Uint8Array(total);
|
|
189
|
+
let offset = 0;
|
|
190
|
+
for (const c of chunks) { out.set(c, offset); offset += c.byteLength; }
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
143
194
|
async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<ScrapeLayerResult> {
|
|
144
195
|
await validateUrlForSSRF(url);
|
|
145
196
|
|
|
@@ -189,6 +240,9 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
|
|
|
189
240
|
if (!location) break; // No Location header — treat as final response
|
|
190
241
|
const resolved = new URL(location, currentUrl).href;
|
|
191
242
|
await validateUrlForSSRF(resolved); // Block SSRF targets in redirects
|
|
243
|
+
// Drain the redirect response before following: an unconsumed body pins
|
|
244
|
+
// its socket (undici keeps it open until read/cancelled or GC).
|
|
245
|
+
try { await response.body?.cancel(); } catch { /* best-effort */ }
|
|
192
246
|
currentUrl = resolved;
|
|
193
247
|
continue;
|
|
194
248
|
}
|
|
@@ -197,6 +251,8 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
|
|
|
197
251
|
|
|
198
252
|
if (!response.ok) {
|
|
199
253
|
metrics.increment('scrape_errors_total', 1, { error_type: 'http_error', status_code: String(response.status) });
|
|
254
|
+
// Same socket-pinning concern as the redirect drain above.
|
|
255
|
+
try { await response.body?.cancel(); } catch { /* best-effort */ }
|
|
200
256
|
throw new Error(`HTTP ${response.status}`);
|
|
201
257
|
}
|
|
202
258
|
|
|
@@ -211,19 +267,34 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
|
|
|
211
267
|
const sizeMB = Math.round(size / 1024 / 1024);
|
|
212
268
|
logger.warn(`[Scrapers] PDF too large (Content-Length: ${sizeMB}MB, max 100MB), skipping`);
|
|
213
269
|
metrics.increment('scrape_pdf_errors_total', 1, { error_type: 'size_exceeded' });
|
|
270
|
+
// Same socket-pinning concern as the redirect/!ok drains above — and this
|
|
271
|
+
// throw fires precisely on the LARGEST responses.
|
|
272
|
+
try { await response.body?.cancel(); } catch { /* best-effort */ }
|
|
214
273
|
throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
|
|
215
274
|
}
|
|
216
275
|
} else if (size > MAX_HTML_SIZE) {
|
|
217
276
|
const sizeMB = Math.round(size / 1024 / 1024);
|
|
218
277
|
logger.warn(`[Scrapers] HTML response too large (Content-Length: ${sizeMB}MB, max 25MB), skipping`);
|
|
219
278
|
metrics.increment('scrape_errors_total', 1, { error_type: 'size_exceeded', content_type: contentType.split(';')[0] || 'unknown' });
|
|
279
|
+
try { await response.body?.cancel(); } catch { /* best-effort */ }
|
|
220
280
|
throw new Error(`HTML response too large (${sizeMB}MB, max 25MB)`);
|
|
221
281
|
}
|
|
222
282
|
}
|
|
223
283
|
|
|
224
284
|
if (contentType.includes('application/pdf') || url.toLowerCase().endsWith('.pdf')) {
|
|
225
|
-
|
|
226
|
-
|
|
285
|
+
let pdfBytes: Uint8Array;
|
|
286
|
+
try {
|
|
287
|
+
pdfBytes = await readBodyCapped(response, MAX_PDF_SIZE);
|
|
288
|
+
} catch (e) {
|
|
289
|
+
if (e instanceof BodyTooLargeError) {
|
|
290
|
+
const sizeMB = Math.round(e.bytesRead / 1024 / 1024);
|
|
291
|
+
logger.warn(`[Scrapers] PDF too large (streamed ${sizeMB}MB, max 100MB), skipping`);
|
|
292
|
+
metrics.increment('scrape_pdf_errors_total', 1, { error_type: 'size_exceeded' });
|
|
293
|
+
throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`, { cause: e });
|
|
294
|
+
}
|
|
295
|
+
throw e;
|
|
296
|
+
}
|
|
297
|
+
const markdown = await extractPdfToMarkdown(pdfBytes);
|
|
227
298
|
validateContent('', markdown, url);
|
|
228
299
|
metrics.increment('scrape_operations_total', 1, { layer: 'fetch', content_type: 'pdf', status: 'success' });
|
|
229
300
|
metrics.observe('scrape_latency_ms', fetchDuration, { layer: 'fetch', content_type: 'pdf', status: 'success' });
|
|
@@ -232,8 +303,28 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
|
|
|
232
303
|
|
|
233
304
|
// Read the raw bytes and decode with the DECLARED charset. response.text() blindly UTF-8-decodes,
|
|
234
305
|
// which mangles Shift_JIS/GBK/EUC-KR/ISO-8859-1 pages into mojibake that then gets cached + cited.
|
|
235
|
-
// Size-check
|
|
236
|
-
|
|
306
|
+
// Size-capped while streaming; the post-read check below is the belt for the
|
|
307
|
+
// body-less fallback path inside readBodyCapped.
|
|
308
|
+
let bodyBytes: Uint8Array;
|
|
309
|
+
try {
|
|
310
|
+
bodyBytes = await readBodyCapped(response, MAX_HTML_SIZE);
|
|
311
|
+
} catch (e) {
|
|
312
|
+
if (e instanceof BodyTooLargeError) {
|
|
313
|
+
const sizeMB = Math.round(e.bytesRead / 1024 / 1024);
|
|
314
|
+
logger.warn(`[Scrapers] HTML response too large (streamed ${sizeMB}MB, max 25MB), skipping`);
|
|
315
|
+
metrics.increment('scrape_errors_total', 1, { error_type: 'size_exceeded', content_type: 'html' });
|
|
316
|
+
errorTracker.trackError(new Error(`HTML response too large (${sizeMB}MB, max 25MB)`), {
|
|
317
|
+
component: 'scrapers',
|
|
318
|
+
operation: 'fetch',
|
|
319
|
+
url,
|
|
320
|
+
domain: extractDomain(url),
|
|
321
|
+
layer: 'fetch',
|
|
322
|
+
contentType: 'html',
|
|
323
|
+
});
|
|
324
|
+
throw new Error(`HTML response too large (${sizeMB}MB, max 25MB)`, { cause: e });
|
|
325
|
+
}
|
|
326
|
+
throw e;
|
|
327
|
+
}
|
|
237
328
|
|
|
238
329
|
if (bodyBytes.length > MAX_HTML_SIZE) {
|
|
239
330
|
const sizeMB = Math.round(bodyBytes.length / 1024 / 1024);
|
package/src/youtube/index.ts
CHANGED
|
@@ -121,10 +121,14 @@ function formatResults(results: VideoTranscript[], rejected: string[]): string {
|
|
|
121
121
|
if (r.lang) meta.push(`Language: ${r.lang}`);
|
|
122
122
|
if (typeof r.charCount === 'number') meta.push(`${r.charCount.toLocaleString()} chars`);
|
|
123
123
|
if (meta.length) md += `**${meta.join(' · ')}**\n`;
|
|
124
|
-
// Exact, copy-ready citation string
|
|
125
|
-
//
|
|
124
|
+
// Exact, copy-ready citation string. It MUST lead with the watch URL: a
|
|
125
|
+
// title-only citation cannot be matched to a source by the synthesis
|
|
126
|
+
// citation pipeline (parseCitations extracts URLs), so the transcript's
|
|
127
|
+
// provenance would silently drop out of the final report's CITED LINKS.
|
|
128
|
+
// The title/channel follows so the report names the real video, not a
|
|
129
|
+
// paraphrase (the watch URL alone is not human-identifiable).
|
|
126
130
|
if (r.title) {
|
|
127
|
-
md += `**Cite as:** '${r.title}'${r.author ? ` by ${r.author}` : ''}\n`;
|
|
131
|
+
md += `**Cite as:** ${r.url} — '${r.title}'${r.author ? ` by ${r.author}` : ''}\n`;
|
|
128
132
|
}
|
|
129
133
|
md += `\n${r.text}\n\n---\n\n`;
|
|
130
134
|
}
|
|
@@ -251,6 +251,8 @@ async function fetchOne(
|
|
|
251
251
|
// 404 (removed) returns HTML that parseJson3 yields '' for, which would
|
|
252
252
|
// be misreported below as a bot-protection/PoToken failure.
|
|
253
253
|
if (!response.ok) {
|
|
254
|
+
// Drain before throwing so the error body doesn't pin its socket.
|
|
255
|
+
void response.body?.cancel()?.catch(() => { /* best-effort */ });
|
|
254
256
|
throw new Error(`HTTP ${response.status} from timedtext endpoint`);
|
|
255
257
|
}
|
|
256
258
|
const body = await response.text();
|