@lincoln504/pi-research 1.3.11 → 1.3.12
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/dist/cli.mjs +139 -27
- package/dist/prompts/system-coordinator.md +1 -1
- package/dist/prompts/system-lead-evaluator.md +1 -1
- package/dist/thread-worker.mjs +29 -11
- package/docs/ARCHITECTURE.md +51 -14
- package/docs/CHANGELOG.md +66 -0
- package/docs/CONFIGURATION.md +3 -2
- package/package.json +18 -10
- package/scripts/cleanup.cjs +26 -5
- package/scripts/setup.cjs +25 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +36 -1
- package/src/core/service-registry.ts +19 -3
- package/src/infrastructure/browser/config.ts +36 -5
- package/src/infrastructure/browser/task-execution-service.ts +12 -2
- package/src/infrastructure/browser/thread-worker-browser.ts +34 -18
- package/src/infrastructure/browser/thread-worker.mjs +29 -11
- package/src/infrastructure/gpu-resource-service.ts +15 -1
- package/src/knowledge/embedder-init.ts +17 -1
- package/src/knowledge/store.ts +11 -5
- package/src/orchestration/research-orchestration-service.ts +22 -1
- package/src/prompts/system-coordinator.md +1 -1
- package/src/prompts/system-lead-evaluator.md +1 -1
- package/src/sdk.ts +10 -1
- package/src/security/nvd-client.ts +55 -4
- package/src/security/osv-client.ts +13 -1
- package/src/tools/research-knowledge-search.ts +18 -1
- package/src/utils/log-utils.ts +16 -1
- package/src/web-research/scraper-utils.ts +55 -14
package/src/sdk.ts
CHANGED
|
@@ -44,7 +44,7 @@ import { runHealthCheck } from './healthcheck/index.ts';
|
|
|
44
44
|
import { buildModelRegistry as sharedBuildModelRegistry, resolveModel } from './core/llm/model-registry-factory.ts';
|
|
45
45
|
import { scrapeSingle } from './web-research/web-scraper.ts';
|
|
46
46
|
import { validateInitialLinks } from './utils/url-utils.ts';
|
|
47
|
-
import { validateUrlForSSRF } from './web-research/scraper-utils.ts';
|
|
47
|
+
import { validateUrlForSSRF, disposeSsrfSafeFetcher } from './web-research/scraper-utils.ts';
|
|
48
48
|
import type { ScrapeResult } from './core/interfaces/scheduler-interfaces.ts';
|
|
49
49
|
import { randomUUID } from 'node:crypto';
|
|
50
50
|
import type { ResearchDepth } from './types/index.ts';
|
|
@@ -954,6 +954,15 @@ async function _doShutdown(): Promise<void> {
|
|
|
954
954
|
errors.push(err instanceof Error ? err : new Error(String(err)));
|
|
955
955
|
}
|
|
956
956
|
|
|
957
|
+
// Release the module-level undici Agent's keep-alive sockets. Nothing else
|
|
958
|
+
// owns it, so a long-lived host would otherwise hold them past teardown.
|
|
959
|
+
try {
|
|
960
|
+
await disposeSsrfSafeFetcher();
|
|
961
|
+
} catch (err) {
|
|
962
|
+
logger.error('[SDK] Error disposing SSRF fetcher:', err);
|
|
963
|
+
errors.push(err instanceof Error ? err : new Error(String(err)));
|
|
964
|
+
}
|
|
965
|
+
|
|
957
966
|
if (globalContainer) {
|
|
958
967
|
// Dispose all services in one DAG-ordered teardown. disposeCoreServices first
|
|
959
968
|
// clears the embedding-server state while the StateManager is still alive, then
|
|
@@ -22,7 +22,6 @@ import type {
|
|
|
22
22
|
} from './nvd-types.ts';
|
|
23
23
|
import { createTimeoutSignal, retryWithBackoff, isTransientError } from '../web-research/retry-utils.ts';
|
|
24
24
|
import { CircuitBreaker } from '../utils/circuit-breaker.ts';
|
|
25
|
-
import { safeUnref } from '../utils/safe-unref.ts';
|
|
26
25
|
import { metrics } from '../utils/metrics.ts';
|
|
27
26
|
import { readJsonCapped, BodyTooLargeError, MAX_BULK_JSON_BODY_BYTES } from '../utils/http-body.ts';
|
|
28
27
|
import {
|
|
@@ -77,7 +76,15 @@ class NVDRateLimiter {
|
|
|
77
76
|
signal?.removeEventListener('abort', onAbort);
|
|
78
77
|
resolve();
|
|
79
78
|
}, waitTime);
|
|
80
|
-
|
|
79
|
+
// NOT unref'd. This timer IS the operation, not a watchdog racing one:
|
|
80
|
+
// nothing else will ever settle this promise, so unref'ing it let Node
|
|
81
|
+
// consider the process idle while an NVD search was mid-spacing. With no
|
|
82
|
+
// other ref'd handle the search simply never resumed — the awaited
|
|
83
|
+
// promise stayed unsettled and the caller got no result and no error.
|
|
84
|
+
// Every other unref in this codebase guards a timeout against real I/O
|
|
85
|
+
// that holds the loop open by itself; this one had nothing behind it.
|
|
86
|
+
// Bounded (≤6s, or ≤600ms with NVD_API_KEY) and abortable below, so
|
|
87
|
+
// keeping it ref'd cannot delay shutdown beyond one spacing interval.
|
|
81
88
|
const onAbort = () => {
|
|
82
89
|
clearTimeout(timeoutId as unknown as ReturnType<typeof setTimeout>);
|
|
83
90
|
reject(Object.assign(new Error('NVD search aborted'), { name: 'AbortError' }));
|
|
@@ -392,6 +399,8 @@ async function fetchPaginated(
|
|
|
392
399
|
const signal = options?.signal;
|
|
393
400
|
let startIndex = 0;
|
|
394
401
|
let totalPagesFetched = 0;
|
|
402
|
+
// Whether the one-time jump to the newest window (below) has been performed.
|
|
403
|
+
let seekedToNewest = false;
|
|
395
404
|
|
|
396
405
|
while (totalPagesFetched < maxPages && allVulnerabilities.length < maxResults) {
|
|
397
406
|
if (signal?.aborted) break; // stop paginating promptly on cancel
|
|
@@ -433,10 +442,40 @@ async function fetchPaginated(
|
|
|
433
442
|
}
|
|
434
443
|
metrics.increment('nvd_cache_hits_total', 1);
|
|
435
444
|
|
|
445
|
+
const responseData = data as Record<string, unknown> | undefined;
|
|
446
|
+
const reportedTotal = typeof responseData?.['totalResults'] === 'number'
|
|
447
|
+
? responseData['totalResults'] as number
|
|
448
|
+
: undefined;
|
|
449
|
+
|
|
450
|
+
// NVD 2.0 returns keyword matches OLDEST FIRST and offers no sort parameter
|
|
451
|
+
// (`sortBy` is a 404), so paging from index 0 and stopping at maxResults kept
|
|
452
|
+
// precisely the least useful window: a `security_search` for "openssl"
|
|
453
|
+
// returned CVE-1999-0428 through 2004 out of 566 matches and could never
|
|
454
|
+
// reach the ones published this month. Seek once to the tail instead, so the
|
|
455
|
+
// rows we keep are the most recent. Only fires when the match set is larger
|
|
456
|
+
// than what we return — a query whose results all fit is already complete, so
|
|
457
|
+
// the common (and every mocked) case still costs exactly one request.
|
|
458
|
+
//
|
|
459
|
+
// Interaction with the `v2` supplemental pass (searchSingleTerm), deliberate:
|
|
460
|
+
// that pass admits only entries carrying NO v3/v4 metric, and NVD scores
|
|
461
|
+
// essentially every modern CVE with v3 — so v2-only rows live at the OLD end.
|
|
462
|
+
// Seeking to the tail therefore makes the supplement return little or nothing
|
|
463
|
+
// once a match set is large. That is the intended trade, not an oversight: the
|
|
464
|
+
// supplement was the channel feeding 1999-era advisories into results that are
|
|
465
|
+
// supposed to describe the current picture. A genuinely legacy product still
|
|
466
|
+
// works, because its own v2 match set is small enough that no seek fires.
|
|
467
|
+
|
|
468
|
+
if (!seekedToNewest && startIndex === 0 && reportedTotal !== undefined && reportedTotal > maxResults) {
|
|
469
|
+
seekedToNewest = true;
|
|
470
|
+
startIndex = reportedTotal - maxResults;
|
|
471
|
+
allVulnerabilities.length = 0; // the oldest window we just read is not what we want
|
|
472
|
+
totalPagesFetched++;
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
|
|
436
476
|
allVulnerabilities.push(...admitted);
|
|
437
477
|
|
|
438
|
-
|
|
439
|
-
if (responseData?.['totalResults'] !== undefined && typeof responseData['totalResults'] === 'number' && startIndex + pageSize >= responseData['totalResults']) {
|
|
478
|
+
if (reportedTotal !== undefined && startIndex + pageSize >= reportedTotal) {
|
|
440
479
|
break;
|
|
441
480
|
}
|
|
442
481
|
|
|
@@ -447,6 +486,18 @@ async function fetchPaginated(
|
|
|
447
486
|
metrics.increment('nvd_pagination_requests_total', totalPagesFetched);
|
|
448
487
|
metrics.observe('nvd_pagination_pages_fetched', totalPagesFetched);
|
|
449
488
|
|
|
489
|
+
// Newest first within the window. NVD's own order is ascending, so without
|
|
490
|
+
// this the caller's slice() would again favour the older end of whatever we
|
|
491
|
+
// gathered. Undated entries sort last rather than jumping to the front.
|
|
492
|
+
allVulnerabilities.sort((a, b) => {
|
|
493
|
+
const ta = a.published ? Date.parse(a.published) : NaN;
|
|
494
|
+
const tb = b.published ? Date.parse(b.published) : NaN;
|
|
495
|
+
if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
|
|
496
|
+
if (Number.isNaN(ta)) return 1;
|
|
497
|
+
if (Number.isNaN(tb)) return -1;
|
|
498
|
+
return tb - ta;
|
|
499
|
+
});
|
|
500
|
+
|
|
450
501
|
return allVulnerabilities.slice(0, maxResults);
|
|
451
502
|
}
|
|
452
503
|
|
|
@@ -230,7 +230,19 @@ export async function searchOSV(
|
|
|
230
230
|
}
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
-
|
|
233
|
+
// Newest first, so the slice(0, maxResults) below keeps the advisories most
|
|
234
|
+
// likely to matter. OSV returns a package's vulnerabilities in ID order,
|
|
235
|
+
// which is effectively alphabetical — truncating that dropped whichever
|
|
236
|
+
// advisories happened to sort late, recency playing no part. Undated entries
|
|
237
|
+
// sort last rather than displacing dated ones.
|
|
238
|
+
vulnerabilities.push(...Array.from(uniqueVulns.values()).sort((a, b) => {
|
|
239
|
+
const ta = a.published ? Date.parse(a.published) : NaN;
|
|
240
|
+
const tb = b.published ? Date.parse(b.published) : NaN;
|
|
241
|
+
if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
|
|
242
|
+
if (Number.isNaN(ta)) return 1;
|
|
243
|
+
if (Number.isNaN(tb)) return -1;
|
|
244
|
+
return tb - ta;
|
|
245
|
+
}));
|
|
234
246
|
|
|
235
247
|
if (failures.length > 0 && failures.length === terms.length) {
|
|
236
248
|
throw new Error(`All OSV lookups failed: ${failures.join('; ')}`);
|
|
@@ -114,6 +114,17 @@ export function formatQueriesBlock(queries: string[]): string {
|
|
|
114
114
|
/** Widget ID for the knowledge search TUI panel */
|
|
115
115
|
const KNOWLEDGE_WIDGET_ID = 'pi-research-knowledge-search';
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* How many knowledge searches are currently showing the widget.
|
|
119
|
+
*
|
|
120
|
+
* The widget ID is a single global slot and `setWidget` replaces rather than
|
|
121
|
+
* stacks, but this tool is `executionMode: 'parallel'` and can also be driven
|
|
122
|
+
* concurrently by the `/knowledge-store` slash command. Without a refcount the
|
|
123
|
+
* first call to finish tears the widget down while a sibling is still running,
|
|
124
|
+
* leaving an in-flight search with no indicator. Only the last one out removes it.
|
|
125
|
+
*/
|
|
126
|
+
let knowledgeWidgetRefCount = 0;
|
|
127
|
+
|
|
117
128
|
/**
|
|
118
129
|
* System string returned when the answer is NOT found in the research
|
|
119
130
|
* knowledge store. The exact phrasing is critical — the main pi agent
|
|
@@ -140,6 +151,7 @@ export const RESEARCH_KNOWLEDGE_MAYBE_STRING =
|
|
|
140
151
|
*/
|
|
141
152
|
function showKnowledgeSearchWidget(ctx: ExtensionContext): void {
|
|
142
153
|
if (ctx.mode !== 'tui' || !ctx.hasUI) return;
|
|
154
|
+
knowledgeWidgetRefCount++;
|
|
143
155
|
try {
|
|
144
156
|
const panelFactory = createKnowledgeSearchPanel();
|
|
145
157
|
ctx.ui.setWidget(KNOWLEDGE_WIDGET_ID, panelFactory as any, { placement: 'aboveEditor' });
|
|
@@ -149,10 +161,15 @@ function showKnowledgeSearchWidget(ctx: ExtensionContext): void {
|
|
|
149
161
|
}
|
|
150
162
|
|
|
151
163
|
/**
|
|
152
|
-
* Remove the knowledge search TUI widget.
|
|
164
|
+
* Remove the knowledge search TUI widget once the last concurrent search is done.
|
|
153
165
|
*/
|
|
154
166
|
function hideKnowledgeSearchWidget(ctx: ExtensionContext): void {
|
|
155
167
|
if (ctx.mode !== 'tui' || !ctx.hasUI) return;
|
|
168
|
+
// show() incremented under the identical guard, so the counter stays balanced.
|
|
169
|
+
// Clamp anyway: a stray hide must never drive it negative and wedge the
|
|
170
|
+
// widget on-screen for the rest of the session.
|
|
171
|
+
knowledgeWidgetRefCount = Math.max(0, knowledgeWidgetRefCount - 1);
|
|
172
|
+
if (knowledgeWidgetRefCount > 0) return;
|
|
156
173
|
try {
|
|
157
174
|
ctx.ui.setWidget(KNOWLEDGE_WIDGET_ID, undefined as any, { placement: 'aboveEditor' });
|
|
158
175
|
} catch (err) {
|
package/src/utils/log-utils.ts
CHANGED
|
@@ -133,7 +133,22 @@ const KNOWN_TOKEN_PATTERN = new RegExp(
|
|
|
133
133
|
'g',
|
|
134
134
|
);
|
|
135
135
|
// JSON Web Tokens — header.payload.signature, each a base64url segment.
|
|
136
|
-
|
|
136
|
+
//
|
|
137
|
+
// The segment lengths are bounded for the same load-bearing reason the URL
|
|
138
|
+
// scheme above is, and this pattern was missed when that one was fixed. `.` is
|
|
139
|
+
// NOT in the segment class, so on a run of base64url characters containing no
|
|
140
|
+
// dot, `[A-Za-z0-9_-]+` consumes to the end of the run and then backtracks one
|
|
141
|
+
// character at a time looking for one — and `_`/`-` count as non-alphanumeric,
|
|
142
|
+
// so B_LEFT admits a fresh start position every few characters inside that same
|
|
143
|
+
// run. `_eyJ` repeated across the 40_000-character scan window measured 393ms of
|
|
144
|
+
// blocked event loop per log message; bounding the segments brings the identical
|
|
145
|
+
// input to 41ms (~10x) with no loss of coverage. The bounds are far above any
|
|
146
|
+
// real token: a 1024-char header covers even an x5c certificate chain, and 8192
|
|
147
|
+
// covers payloads no bearer credential approaches.
|
|
148
|
+
const JWT_PATTERN = new RegExp(
|
|
149
|
+
`${B_LEFT}eyJ[A-Za-z0-9_-]{1,1024}\\.[A-Za-z0-9_-]{1,8192}\\.[A-Za-z0-9_-]{1,4096}`,
|
|
150
|
+
'g',
|
|
151
|
+
);
|
|
137
152
|
// HTTP Basic credentials: "Basic <base64>".
|
|
138
153
|
const BASIC_AUTH_PATTERN = new RegExp(`${B_LEFT}Basic\\s+[A-Za-z0-9+/]{16,}={0,2}`, 'gi');
|
|
139
154
|
// HTTP Bearer credentials: "Bearer <token>". The token is often opaque — many
|
|
@@ -277,10 +277,18 @@ export interface SsrfSafeFetcher {
|
|
|
277
277
|
dispatcher: unknown;
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
-
// Cached after first construction. `undefined` = not yet attempted
|
|
281
|
-
// undici unavailable (fall back to the global fetch
|
|
282
|
-
// validation only).
|
|
283
|
-
|
|
280
|
+
// Cached after first construction. `undefined` = not yet attempted; the promise
|
|
281
|
+
// resolves to `null` when undici is unavailable (fall back to the global fetch
|
|
282
|
+
// with request-time validation only).
|
|
283
|
+
//
|
|
284
|
+
// The PROMISE is cached, not the resolved value. Caching the value left a window
|
|
285
|
+
// between the `undefined` check and the assignment — an `await import('undici')`
|
|
286
|
+
// wide — during which every concurrent first caller built its OWN Agent. Only the
|
|
287
|
+
// last assignment survived in the cache, so the others were handed live Agents
|
|
288
|
+
// that nothing tracked: they opened keep-alive sockets and no shutdown path could
|
|
289
|
+
// ever close them, which is precisely the leak disposeSsrfSafeFetcher exists to
|
|
290
|
+
// prevent. Scrapes run concurrently by design, so the very first burst hit this.
|
|
291
|
+
let cachedSsrfFetcher: Promise<SsrfSafeFetcher | null> | undefined = undefined;
|
|
284
292
|
|
|
285
293
|
/**
|
|
286
294
|
* Build (once) an undici Agent whose connector resolves via ssrfSafeLookup, so
|
|
@@ -304,19 +312,52 @@ let cachedSsrfFetcher: SsrfSafeFetcher | null | undefined = undefined;
|
|
|
304
312
|
* Returns null if undici can't be loaded; callers then use the global fetch with
|
|
305
313
|
* no dispatcher (request-time validation still applies).
|
|
306
314
|
*/
|
|
307
|
-
export
|
|
308
|
-
|
|
315
|
+
export function getSsrfSafeFetcher(): Promise<SsrfSafeFetcher | null> {
|
|
316
|
+
// Assigned before the first await, so concurrent callers all join this one
|
|
317
|
+
// construction rather than each starting their own.
|
|
318
|
+
cachedSsrfFetcher ??= (async () => {
|
|
319
|
+
try {
|
|
320
|
+
const { Agent, fetch: undiciFetch } = await import('undici');
|
|
321
|
+
return {
|
|
322
|
+
fetch: undiciFetch as unknown as SsrfSafeFetcher['fetch'],
|
|
323
|
+
dispatcher: new Agent({ connect: { lookup: ssrfSafeLookup } }),
|
|
324
|
+
};
|
|
325
|
+
} catch (e) {
|
|
326
|
+
logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
})();
|
|
330
|
+
return cachedSsrfFetcher;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Close the cached SSRF Agent and drop the cache.
|
|
335
|
+
*
|
|
336
|
+
* The Agent is a process-lifetime singleton holding keep-alive sockets to every
|
|
337
|
+
* host scraped so far. The CLI hard-exits so it never notices, but an embedding
|
|
338
|
+
* host (the pi extension) keeps the process alive across SDK
|
|
339
|
+
* init/shutdown cycles, where those sockets would linger past a teardown that
|
|
340
|
+
* claims to have released everything.
|
|
341
|
+
*
|
|
342
|
+
* Clearing the cache is not optional: the SDK can be re-initialized in the same
|
|
343
|
+
* process, and a closed Agent left in the cache would fail every subsequent
|
|
344
|
+
* scrape. Resetting to `undefined` (not `null`) makes the next call rebuild it.
|
|
345
|
+
*/
|
|
346
|
+
export async function disposeSsrfSafeFetcher(): Promise<void> {
|
|
347
|
+
const pending = cachedSsrfFetcher;
|
|
348
|
+
cachedSsrfFetcher = undefined;
|
|
349
|
+
if (pending === undefined) return;
|
|
350
|
+
// Await the in-flight construction rather than skipping it: a dispose racing
|
|
351
|
+
// the first getSsrfSafeFetcher() would otherwise leave the Agent it is still
|
|
352
|
+
// building unreferenced and unclosed.
|
|
353
|
+
const cached = await pending.catch(() => null);
|
|
354
|
+
const closable = cached?.dispatcher as { close?: () => Promise<void> } | undefined;
|
|
355
|
+
if (!closable?.close) return;
|
|
309
356
|
try {
|
|
310
|
-
|
|
311
|
-
cachedSsrfFetcher = {
|
|
312
|
-
fetch: undiciFetch as unknown as SsrfSafeFetcher['fetch'],
|
|
313
|
-
dispatcher: new Agent({ connect: { lookup: ssrfSafeLookup } }),
|
|
314
|
-
};
|
|
357
|
+
await closable.close();
|
|
315
358
|
} catch (e) {
|
|
316
|
-
logger.
|
|
317
|
-
cachedSsrfFetcher = null;
|
|
359
|
+
logger.debug(`[Scrapers] Error closing SSRF dispatcher: ${e instanceof Error ? e.message : String(e)}`);
|
|
318
360
|
}
|
|
319
|
-
return cachedSsrfFetcher;
|
|
320
361
|
}
|
|
321
362
|
|
|
322
363
|
/**
|