@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
|
@@ -129,6 +129,19 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
|
|
|
129
129
|
});
|
|
130
130
|
logger.info(`[ResearchOrchestrationService] Acquired research run slot ${runSlot.slotIndex} (cap ${semaphore.getMaxSlots()}).`);
|
|
131
131
|
} catch (err) {
|
|
132
|
+
const isTerminal =
|
|
133
|
+
err instanceof ResearchRunCapacityError || (err instanceof Error && err.name === 'AbortError');
|
|
134
|
+
if (isTerminal) {
|
|
135
|
+
// The queue notice above may already have announced this run to the
|
|
136
|
+
// observer (onRunQueued); rethrowing with no terminal event breaks the
|
|
137
|
+
// "exactly one of onComplete/onError" contract both orchestrators keep —
|
|
138
|
+
// an observer-driven consumer saw run_queued and then silence. No
|
|
139
|
+
// orchestrator exists yet, so this cannot double-fire. Observer throws
|
|
140
|
+
// stay isolated, as everywhere else.
|
|
141
|
+
try {
|
|
142
|
+
(observer as { onError?: (e: Error) => void } | undefined)?.onError?.(err as Error);
|
|
143
|
+
} catch { /* observer isolation */ }
|
|
144
|
+
}
|
|
132
145
|
if (err instanceof ResearchRunCapacityError) throw err; // capacity exhausted → fail fast
|
|
133
146
|
// A cancel while queueing for a slot must stay cancelled. Fail-open here would
|
|
134
147
|
// start the very run the user just aborted.
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { parseCitations } from '../utils/text-utils.ts';
|
|
13
13
|
import { normalizeCitations, formatCitedLinks, rewriteCitationMarkers, type GlobalCitation } from '../utils/citation-utils.ts';
|
|
14
|
-
import { lastCitedLinksHeaderIndex } from '../utils/text-utils.ts';
|
|
14
|
+
import { lastCitedLinksHeaderIndex, lastCitedLinksHeader } from '../utils/text-utils.ts';
|
|
15
15
|
import { getScrapedLinks, isTranscribedLink, normalizeUrl } from '../utils/shared-links.ts';
|
|
16
16
|
import { stripTrailingLlmPunctuation } from '../utils/url-utils.ts';
|
|
17
17
|
import { logger } from '../logger.ts';
|
|
@@ -321,7 +321,9 @@ export class ResearchSynthesisService implements IService {
|
|
|
321
321
|
if (globalId !== undefined) localToGlobal.set(localKey, globalId);
|
|
322
322
|
else droppedNumbers.add(localKey);
|
|
323
323
|
});
|
|
324
|
-
|
|
324
|
+
// lineStart (not the 'C' offset): slicing at textStart left the tolerated
|
|
325
|
+
// `##`/`**`/`>` marker dangling as a stray line at the end of the body.
|
|
326
|
+
let body = synthesis.slice(0, lastCitedLinksHeader(synthesis).lineStart).trimEnd();
|
|
325
327
|
if (localToGlobal.size > 0 || droppedNumbers.size > 0) {
|
|
326
328
|
body = rewriteCitationMarkers(body, localToGlobal, (n) => droppedNumbers.has(n));
|
|
327
329
|
}
|
|
@@ -9,7 +9,7 @@ import type { SystemResearchState } from './deep-research-types.ts';
|
|
|
9
9
|
import { createResearcherSession } from './researcher.ts';
|
|
10
10
|
import { registerScrapedLinks } from '../utils/shared-links.ts';
|
|
11
11
|
import { ensureAssistantResponse } from '../utils/text-utils.ts';
|
|
12
|
-
import {
|
|
12
|
+
import { recordLlmUsage } from '../utils/llm-usage.ts';
|
|
13
13
|
import { logger } from '../logger.ts';
|
|
14
14
|
import { metrics } from '../utils/metrics.ts';
|
|
15
15
|
import { ServiceNames,
|
|
@@ -347,13 +347,15 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
|
|
|
347
347
|
|
|
348
348
|
const rawUsage = msg['usage'] as any;
|
|
349
349
|
if (rawUsage) {
|
|
350
|
-
|
|
351
|
-
|
|
350
|
+
// Observer exceptions must not kill the researcher, so hand recordLlmUsage
|
|
351
|
+
// a safeObserve-wrapped sink rather than the raw observer.
|
|
352
|
+
const { tokens, cost } = recordLlmUsage(resolvedModel, rawUsage, {
|
|
353
|
+
component: 'researcher',
|
|
354
|
+
complexity,
|
|
355
|
+
observer: { onTokensConsumed: (t, c) => safeObserve(() => observer?.onTokensConsumed?.(t, c)) },
|
|
356
|
+
});
|
|
352
357
|
if (tokens > 0 || cost > 0) {
|
|
353
|
-
metrics.increment('llm_tokens_total', tokens, { component: 'researcher', complexity: String(complexity) });
|
|
354
|
-
metrics.increment('llm_cost_total', cost, { component: 'researcher', complexity: String(complexity) });
|
|
355
358
|
safeObserve(() => observer?.onResearcherProgress?.(id, undefined, tokens, cost));
|
|
356
|
-
safeObserve(() => observer?.onTokensConsumed?.(tokens, cost));
|
|
357
359
|
}
|
|
358
360
|
}
|
|
359
361
|
} else if (event.type === 'tool_execution_start') {
|
|
@@ -28,7 +28,7 @@ import { redactSecrets } from '../utils/log-utils.ts';
|
|
|
28
28
|
import { exportResearchReport, appendExportMessage } from '../utils/research-export.ts';
|
|
29
29
|
import { validateAndSanitizeQuery } from '../utils/input-validation.ts';
|
|
30
30
|
import { validateInitialLinks, MAX_INITIAL_LINKS, MAX_INITIAL_LINK_CHARS } from '../utils/url-utils.ts';
|
|
31
|
-
import { startResearchSession, registerSessionAbort, clearSteeringMessages, getPiActivePanels } from '../orchestration/session-state.ts';
|
|
31
|
+
import { startResearchSession, endResearchSession, registerSessionAbort, clearSteeringMessages, getPiActivePanels } from '../orchestration/session-state.ts';
|
|
32
32
|
import { createResearchTuiManager, hideWorkingIndicator } from '../tui/research-tui-manager.ts';
|
|
33
33
|
import { createCleanupFunction } from '../cleanup/research-cleanup.ts';
|
|
34
34
|
import { createResearchObserver, createObserverState, stopObserverWaveAnimation } from '../observers/research-observer-impl.ts';
|
|
@@ -339,6 +339,13 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
|
|
|
339
339
|
registerSessionAbort(piSessionId, sessionResearchId, internalAbort);
|
|
340
340
|
|
|
341
341
|
cleanup = async () => {
|
|
342
|
+
// Free what startResearchSession/registerSessionAbort created — the
|
|
343
|
+
// failures/failureReasons entries and the AbortController live in the
|
|
344
|
+
// module-level piSessions map keyed by the STABLE pi session id, so
|
|
345
|
+
// without this every headless run leaked them for the process
|
|
346
|
+
// lifetime (the TUI branch frees them via createCleanupFunction, the
|
|
347
|
+
// SDK in its own finally; this branch freed only steering).
|
|
348
|
+
endResearchSession(piSessionId, sessionResearchId);
|
|
342
349
|
clearSteeringMessages(piSessionId);
|
|
343
350
|
};
|
|
344
351
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseCitations,
|
|
1
|
+
import { parseCitations, lastCitedLinksHeader, type Citation } from './text-utils.ts';
|
|
2
2
|
import { normalizeUrl } from './shared-links.ts';
|
|
3
3
|
|
|
4
4
|
export interface GlobalCitation extends Citation {
|
|
@@ -66,7 +66,9 @@ export function normalizeCitations(reports: ReadonlyMap<string, string>): {
|
|
|
66
66
|
// line-leading, all-caps header, never an incidental "cited links" mention in
|
|
67
67
|
// prose (an unanchored split there would drop real findings before the
|
|
68
68
|
// evaluator ever sees them).
|
|
69
|
-
|
|
69
|
+
// lineStart (not textStart): slicing at the 'C' left the tolerated `##`/`**`/`>`
|
|
70
|
+
// marker dangling at the end of the kept body.
|
|
71
|
+
const headerIdx = lastCitedLinksHeader(report).lineStart;
|
|
70
72
|
let content = headerIdx >= 0 ? report.slice(0, headerIdx) : report;
|
|
71
73
|
|
|
72
74
|
// Renumber local [N] markers to their global ids, and DELETE the ones that could
|
package/src/utils/text-utils.ts
CHANGED
|
@@ -253,17 +253,31 @@ function isPlausibleCitationUrl(url: string): boolean {
|
|
|
253
253
|
* against the `CITED LINKS\n...` block that formatCitedLinks() emits.
|
|
254
254
|
*/
|
|
255
255
|
export function lastCitedLinksHeaderIndex(text: string): number {
|
|
256
|
+
return lastCitedLinksHeader(text).textStart;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Both offsets of the last CITED LINKS header: `textStart` points at the 'C'
|
|
261
|
+
* (what parseCitations slices FROM), `lineStart` points at the start of the
|
|
262
|
+
* header line BEFORE any `#`/`>`/`**` markers (what body-replacement slices TO).
|
|
263
|
+
* Slicing the body at textStart left the tolerated markdown marker dangling at
|
|
264
|
+
* the end of the body — the rebuilt report then carried a stray `##` or `**`
|
|
265
|
+
* line whenever the model wrote `## CITED LINKS` instead of the plain form.
|
|
266
|
+
*/
|
|
267
|
+
export function lastCitedLinksHeader(text: string): { lineStart: number; textStart: number } {
|
|
256
268
|
// Case-insensitive but LINE-LEADING: a mid-prose "...the cited links below..." has
|
|
257
269
|
// non-whitespace before it on the line, so it cannot match — only a header at the
|
|
258
270
|
// start of a line (after optional markdown markers) does.
|
|
259
271
|
const re = /(^|\n)([ \t]*(?:#{1,6}[ \t]*|>[ \t]*|\*\*)?)CITED LINKS\b/gi;
|
|
260
|
-
let
|
|
272
|
+
let lineStart = -1;
|
|
273
|
+
let textStart = -1;
|
|
261
274
|
let m: RegExpExecArray | null;
|
|
262
275
|
while ((m = re.exec(text)) !== null) {
|
|
276
|
+
lineStart = m.index + m[1]!.length;
|
|
263
277
|
// Offset of the 'C' in CITED LINKS = match start + leading newline + markers.
|
|
264
|
-
|
|
278
|
+
textStart = m.index + m[1]!.length + m[2]!.length;
|
|
265
279
|
}
|
|
266
|
-
return
|
|
280
|
+
return { lineStart, textStart };
|
|
267
281
|
}
|
|
268
282
|
|
|
269
283
|
/**
|
|
@@ -250,27 +250,73 @@ export function ssrfSafeLookup(
|
|
|
250
250
|
});
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
253
|
+
/**
|
|
254
|
+
* True when the error is one of our SSRF blocks — request-time validation
|
|
255
|
+
* (message contains "not allowed") or the connect-time DNS pin (`ESSRFBLOCKED`).
|
|
256
|
+
*
|
|
257
|
+
* The connect-time error never surfaces directly: undici wraps it in
|
|
258
|
+
* `TypeError: fetch failed` with the real error down the `cause` chain, so a
|
|
259
|
+
* message check on the outer error alone files a connect-time block as a generic
|
|
260
|
+
* fetch error. Walk the chain (bounded — causes can be cyclic) instead.
|
|
261
|
+
*/
|
|
262
|
+
export function isSsrfBlockError(error: unknown): boolean {
|
|
263
|
+
let e: unknown = error;
|
|
264
|
+
for (let depth = 0; depth < 5 && e instanceof Error; depth++) {
|
|
265
|
+
if ((e as NodeJS.ErrnoException).code === 'ESSRFBLOCKED' || e.message.includes('not allowed')) {
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
e = (e as { cause?: unknown }).cause;
|
|
269
|
+
}
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** A fetch-shaped callable plus the dispatcher it must be invoked with. */
|
|
274
|
+
export interface SsrfSafeFetcher {
|
|
275
|
+
/** undici's own fetch — NOT the global one. See getSsrfSafeFetcher. */
|
|
276
|
+
fetch: (url: string, init: Record<string, unknown>) => Promise<Response>;
|
|
277
|
+
dispatcher: unknown;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Cached after first construction. `undefined` = not yet attempted, `null` =
|
|
281
|
+
// undici unavailable (fall back to the global fetch with request-time
|
|
282
|
+
// validation only).
|
|
283
|
+
let cachedSsrfFetcher: SsrfSafeFetcher | null | undefined = undefined;
|
|
257
284
|
|
|
258
285
|
/**
|
|
259
286
|
* Build (once) an undici Agent whose connector resolves via ssrfSafeLookup, so
|
|
260
287
|
* every scrape fetch — including each manually-followed redirect hop — connects
|
|
261
|
-
* only to a validated public address
|
|
262
|
-
*
|
|
288
|
+
* only to a validated public address, PAIRED with the fetch implementation that
|
|
289
|
+
* can actually accept it.
|
|
290
|
+
*
|
|
291
|
+
* The pairing is the whole point. A dispatcher is only honoured by the undici
|
|
292
|
+
* that created it: Node's global `fetch` is backed by the undici BUILT INTO the
|
|
293
|
+
* runtime (6.21.0 on Node 22, 7.24.4 on Node 25), while this package depends on
|
|
294
|
+
* undici 8.x. Handing a v8 `Agent` to the global fetch throws
|
|
295
|
+
* `UND_ERR_INVALID_ARG: invalid onRequestStart method` on every request, on
|
|
296
|
+
* every supported Node — which is exactly what it did: the entire fetch scrape
|
|
297
|
+
* path failed instantly, every URL was retried once and then fell back to the
|
|
298
|
+
* stealth browser (~4.5s per page against ~250ms), and the connect-time SSRF pin
|
|
299
|
+
* this dispatcher exists for never engaged even once. The browser fallback made
|
|
300
|
+
* it invisible: scrapes still succeeded, just 15-20x slower.
|
|
301
|
+
*
|
|
302
|
+
* Returning the two together makes the invalid combination unrepresentable —
|
|
303
|
+
* a caller cannot end up holding a dispatcher without the fetch that honours it.
|
|
304
|
+
* Returns null if undici can't be loaded; callers then use the global fetch with
|
|
305
|
+
* no dispatcher (request-time validation still applies).
|
|
263
306
|
*/
|
|
264
|
-
export async function
|
|
265
|
-
if (
|
|
307
|
+
export async function getSsrfSafeFetcher(): Promise<SsrfSafeFetcher | null> {
|
|
308
|
+
if (cachedSsrfFetcher !== undefined) return cachedSsrfFetcher;
|
|
266
309
|
try {
|
|
267
|
-
const { Agent } = await import('undici');
|
|
268
|
-
|
|
310
|
+
const { Agent, fetch: undiciFetch } = await import('undici');
|
|
311
|
+
cachedSsrfFetcher = {
|
|
312
|
+
fetch: undiciFetch as unknown as SsrfSafeFetcher['fetch'],
|
|
313
|
+
dispatcher: new Agent({ connect: { lookup: ssrfSafeLookup } }),
|
|
314
|
+
};
|
|
269
315
|
} catch (e) {
|
|
270
316
|
logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
|
|
271
|
-
|
|
317
|
+
cachedSsrfFetcher = null;
|
|
272
318
|
}
|
|
273
|
-
return
|
|
319
|
+
return cachedSsrfFetcher;
|
|
274
320
|
}
|
|
275
321
|
|
|
276
322
|
/**
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
import {
|
|
24
24
|
FETCH_LAYER_TIMEOUT,
|
|
25
25
|
} from './types.ts';
|
|
26
|
-
import { getRandomUserAgent, extractDomain, validateUrlForSSRF, validateContent, createNativeMarkdownConverter, createJsMarkdownConverter,
|
|
26
|
+
import { getRandomUserAgent, extractDomain, validateUrlForSSRF, validateContent, createNativeMarkdownConverter, createJsMarkdownConverter, getSsrfSafeFetcher, formatErrorWithCause, isBenignScrapeFailure, isSsrfBlockError, } from './scraper-utils.ts';
|
|
27
27
|
import { isTransientError, abortableDelay } from './retry-utils.ts';
|
|
28
28
|
import { safeUnref } from '../utils/safe-unref.ts';
|
|
29
29
|
import { readBodyCapped, BodyTooLargeError } from '../utils/http-body.ts';
|
|
@@ -208,7 +208,7 @@ function decodeHtmlBody(bytes: Uint8Array, contentTypeHeader: string): string {
|
|
|
208
208
|
* counts are precisely what production incidents are diagnosed from.
|
|
209
209
|
*/
|
|
210
210
|
function trackFetchLayerFailure(url: string, error: unknown): void {
|
|
211
|
-
const isSsrf = error
|
|
211
|
+
const isSsrf = isSsrfBlockError(error);
|
|
212
212
|
errorTracker.trackError(error instanceof Error ? error : String(error), {
|
|
213
213
|
component: 'scrapers',
|
|
214
214
|
operation: 'fetch',
|
|
@@ -226,7 +226,12 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
|
|
|
226
226
|
// closing the DNS-rebinding TOCTOU gap between validateUrlForSSRF (request
|
|
227
227
|
// time) and fetch's own re-resolution (connect time). Null when undici is
|
|
228
228
|
// unavailable — request-time validation above still applies.
|
|
229
|
-
|
|
229
|
+
//
|
|
230
|
+
// The fetch implementation comes WITH the dispatcher and must be used with it:
|
|
231
|
+
// a dispatcher is only honoured by the undici that built it, and the global
|
|
232
|
+
// fetch is backed by Node's bundled undici, not ours. See getSsrfSafeFetcher.
|
|
233
|
+
const fetcher = await getSsrfSafeFetcher();
|
|
234
|
+
const doFetch = fetcher?.fetch ?? ((u: string, init: Record<string, unknown>) => fetch(u, init as RequestInit));
|
|
230
235
|
|
|
231
236
|
const controller = new AbortController();
|
|
232
237
|
const timeoutId = setTimeout(() => controller.abort(), FETCH_LAYER_TIMEOUT);
|
|
@@ -257,16 +262,16 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
|
|
|
257
262
|
const userAgent = getRandomUserAgent();
|
|
258
263
|
|
|
259
264
|
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
260
|
-
response = await
|
|
265
|
+
response = await doFetch(currentUrl, {
|
|
261
266
|
signal: controller.signal,
|
|
262
267
|
redirect: 'manual',
|
|
263
268
|
headers: {
|
|
264
269
|
'User-Agent': userAgent,
|
|
265
270
|
'Accept': 'text/html,application/xhtml+xml,application/pdf,*/*;q=0.8',
|
|
266
271
|
},
|
|
267
|
-
//
|
|
268
|
-
...(
|
|
269
|
-
}
|
|
272
|
+
// Only ever attached alongside the fetch that honours it (see above).
|
|
273
|
+
...(fetcher ? { dispatcher: fetcher.dispatcher } : {}),
|
|
274
|
+
});
|
|
270
275
|
|
|
271
276
|
if (response.status >= 300 && response.status < 400) {
|
|
272
277
|
const location = response.headers.get('location');
|
|
@@ -380,7 +385,7 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
|
|
|
380
385
|
// call in a retry, and tracking per attempt would record one failure twice —
|
|
381
386
|
// inflating exactly the error counts these numbers are read to diagnose.
|
|
382
387
|
// scrapeSingle calls trackFetchLayerFailure once with the final error.
|
|
383
|
-
if (error
|
|
388
|
+
if (isSsrfBlockError(error)) {
|
|
384
389
|
metrics.increment('scrape_operations_total', 1, { layer: 'fetch', status: 'ssrf_blocked' });
|
|
385
390
|
metrics.observe('scrape_latency_ms', Date.now() - fetchStart, { layer: 'fetch', status: 'ssrf_blocked' });
|
|
386
391
|
throw error;
|