@lincoln504/pi-research 1.1.0 → 1.2.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 +4 -4
- package/dist/cli.mjs +1404 -600
- package/dist/thread-worker.mjs +40 -11
- package/docs/AGENT-SKILL.md +11 -4
- package/docs/ARCHITECTURE.md +19 -9
- package/docs/CHANGELOG.md +107 -1
- package/docs/CONFIGURATION.md +10 -8
- package/docs/KNOWLEDGE-STORE.md +5 -4
- package/docs/PI-EXTENSION.md +1 -1
- package/docs/SDK.md +63 -5
- package/docs/THIRD-PARTY-NOTICES.md +1 -1
- package/package.json +5 -4
- package/scripts/build.cjs +23 -5
- package/scripts/cleanup.cjs +50 -10
- package/scripts/prepare.cjs +15 -5
- package/scripts/setup.cjs +24 -4
- package/skills/pi-research/README.md +9 -2
- package/skills/pi-research/SKILL.md +8 -5
- package/skills/pi-research/references/configuration.md +7 -3
- package/skills/pi-research/scripts/run.mjs +24 -2
- package/src/cli.ts +161 -21
- package/src/config.ts +56 -15
- package/src/constants.ts +31 -0
- package/src/core/interfaces/knowledge-interfaces.ts +6 -1
- package/src/core/interfaces/state-manager-interfaces.ts +1 -1
- package/src/core/llm/model-registry-factory.ts +40 -3
- package/src/core/llm/research-model-resolver.ts +2 -2
- package/src/core/pi-version.ts +136 -0
- package/src/core/service-registry.ts +117 -8
- package/src/index.ts +11 -18
- package/src/infrastructure/browser/browser-client.ts +16 -4
- package/src/infrastructure/browser/browser-error-utils.ts +12 -1
- package/src/infrastructure/browser/browser-server.ts +17 -5
- package/src/infrastructure/browser/browser-task-scheduler.ts +101 -23
- package/src/infrastructure/browser/cleanup-utils.ts +45 -7
- package/src/infrastructure/browser/client-agent.ts +4 -3
- package/src/infrastructure/browser/priority-task-queue.ts +10 -3
- package/src/infrastructure/browser/scheduler-factory.ts +26 -1
- package/src/infrastructure/browser/task-execution-service.ts +92 -28
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
- package/src/infrastructure/browser/thread-worker.mjs +40 -11
- package/src/infrastructure/browser/worker-pool-manager.ts +11 -3
- package/src/infrastructure/embedding/embedding-client.ts +22 -8
- package/src/infrastructure/embedding/embedding-factory.ts +28 -11
- package/src/infrastructure/embedding/embedding-server.ts +65 -4
- package/src/infrastructure/file-lock-service.ts +223 -26
- package/src/infrastructure/process-lifecycle-service.ts +108 -31
- package/src/infrastructure/state/state-backup-manager.ts +5 -7
- package/src/infrastructure/state/state-manager-service.ts +1 -1
- package/src/infrastructure/state/state-manager.ts +19 -20
- package/src/infrastructure/types/state-types.ts +9 -0
- package/src/knowledge/embedder-init.ts +1 -1
- package/src/knowledge/embedder.ts +52 -3
- package/src/knowledge/index.ts +1 -1
- package/src/knowledge/store.ts +35 -9
- package/src/knowledge/webgpu-viability.ts +9 -4
- package/src/knowledge/writer-queue.ts +88 -46
- package/src/orchestration/deep-research-orchestrator.ts +13 -2
- package/src/orchestration/quick-research-orchestrator.ts +2 -2
- package/src/orchestration/research-orchestration-service.ts +2 -4
- package/src/orchestration/research-synthesis-service.ts +34 -92
- package/src/security/cisa-kev.ts +10 -3
- package/src/security/github-advisories.ts +18 -7
- package/src/security/nvd-client.ts +17 -6
- package/src/security/osv-client.ts +14 -4
- package/src/skill-install/skill-installer.ts +135 -9
- package/src/stackexchange/rest-client.ts +6 -2
- package/src/tools/research-knowledge-search.ts +23 -2
- package/src/tools/search.ts +6 -1
- package/src/utils/atomic-replace.ts +101 -0
- package/src/utils/circuit-breaker.ts +20 -0
- package/src/utils/citation-utils.ts +106 -8
- package/src/utils/http-body.ts +176 -0
- package/src/utils/llm-usage.ts +37 -0
- package/src/utils/metrics-summary.ts +25 -2
- package/src/utils/research-export.ts +9 -2
- package/src/web-research/browser-search.ts +50 -4
- package/src/web-research/retry-utils.ts +13 -1
- package/src/web-research/search.ts +20 -5
- package/src/web-research/types.ts +17 -4
- package/src/web-research/web-scraper.ts +7 -50
- package/src/youtube/transcript-client.ts +5 -1
|
@@ -9,7 +9,7 @@ import { getMaxWorkers } from '../infrastructure/browser/config.ts';
|
|
|
9
9
|
import { logger } from '../logger.ts';
|
|
10
10
|
import { safeUnref } from '../utils/safe-unref.ts';
|
|
11
11
|
import { normalizeUrl } from '../utils/url-utils.ts';
|
|
12
|
-
import type { SearchResult } from './types.ts';
|
|
12
|
+
import type { SearchResult, QueryFailure } from './types.ts';
|
|
13
13
|
import type { Config } from '../config.ts';
|
|
14
14
|
import { metrics } from '../utils/metrics.ts';
|
|
15
15
|
import { getServiceContainer } from '../core/service-registry.ts';
|
|
@@ -21,11 +21,20 @@ import type { ServiceContainer } from '../core/service-registry.ts';
|
|
|
21
21
|
* Each worker maintains its own "warm" browser process.
|
|
22
22
|
*/
|
|
23
23
|
export async function performSearch(
|
|
24
|
-
queries: string[],
|
|
24
|
+
queries: string[],
|
|
25
25
|
config?: Config,
|
|
26
26
|
signal?: AbortSignal,
|
|
27
27
|
onProgress?: (links: number) => void,
|
|
28
|
-
container: ServiceContainer = getServiceContainer()
|
|
28
|
+
container: ServiceContainer = getServiceContainer(),
|
|
29
|
+
/**
|
|
30
|
+
* Optional sink for per-query FAILURES (timeout / worker error). Populated as
|
|
31
|
+
* an out-param rather than folded into the return value because the returned
|
|
32
|
+
* map's `query -> results` shape is what every caller consumes; a query absent
|
|
33
|
+
* from this map produced zero results with no failure, i.e. the search really
|
|
34
|
+
* did come back empty. Without this the caller cannot tell those apart and
|
|
35
|
+
* reports every empty query as a too-narrow query.
|
|
36
|
+
*/
|
|
37
|
+
failures?: Map<string, QueryFailure>,
|
|
29
38
|
): Promise<Map<string, SearchResult[]>> {
|
|
30
39
|
const startTime = Date.now();
|
|
31
40
|
const resultMap = new Map<string, SearchResult[]>();
|
|
@@ -78,7 +87,12 @@ export async function performSearch(
|
|
|
78
87
|
// (post-dequeue) deadline.
|
|
79
88
|
const QUERY_TIMEOUT_MS = (config?.SEARCH_TIMEOUT_MS ?? 45_000) + (config?.BROWSER_TASK_TIMEOUT_MS ?? 10_000);
|
|
80
89
|
|
|
81
|
-
|
|
90
|
+
// Deduplicated: resultMap and the failures map are both keyed by the query
|
|
91
|
+
// string, so two tasks for the same string race last-writer-wins — instance A
|
|
92
|
+
// delivering results, then instance B timing out, discarded A's results and
|
|
93
|
+
// marked the query failed. One task per distinct string closes that (and stops
|
|
94
|
+
// paying for the duplicate browser work).
|
|
95
|
+
const filteredQueries = [...new Set(queries.filter(q => q.trim()))];
|
|
82
96
|
let timeoutCount = 0;
|
|
83
97
|
let errorCount = 0;
|
|
84
98
|
// Keep one representative worker error so total-failure surfaces the real
|
|
@@ -146,11 +160,31 @@ export async function performSearch(
|
|
|
146
160
|
|
|
147
161
|
if (isTimeout) {
|
|
148
162
|
logger.warn(`[Search] Query timed out after ${QUERY_TIMEOUT_MS}ms — likely starved waiting for a free browser worker under concurrent load, or a slow/blocked search provider: "${query}"`);
|
|
163
|
+
failures?.set(query, {
|
|
164
|
+
type: 'timeout',
|
|
165
|
+
message: `Search timed out after ${QUERY_TIMEOUT_MS}ms before returning anything. This says nothing about the query — the browser pool was saturated or the search provider was slow/blocked. Retrying the same query later is reasonable.`,
|
|
166
|
+
});
|
|
149
167
|
} else {
|
|
150
168
|
const msg = error instanceof Error ? error.message : String(error);
|
|
151
169
|
if (msg !== 'Aborted') {
|
|
152
170
|
if (!sampleWorkerError) sampleWorkerError = msg;
|
|
153
171
|
logger.error(`[Search] Worker failed for "${query}": ${msg}`);
|
|
172
|
+
failures?.set(query, {
|
|
173
|
+
type: 'service_unavailable',
|
|
174
|
+
message: `The search backend failed for this query (${msg}). This is an infrastructure failure, not a signal about the query itself — do not rewrite the query in response to it.`,
|
|
175
|
+
});
|
|
176
|
+
} else if (!signal?.aborted) {
|
|
177
|
+
// A worker-side 'Aborted' with no caller abort (a pool drain or
|
|
178
|
+
// leader handover cancelling the task) is an infrastructure
|
|
179
|
+
// interruption. Without an entry here the query fell through to
|
|
180
|
+
// the empty_results default — "the query may be too narrow" —
|
|
181
|
+
// the exact misattribution this map exists to prevent. The
|
|
182
|
+
// caller-abort case stays unrecorded: the post-loop signal
|
|
183
|
+
// check reports the cancellation itself.
|
|
184
|
+
failures?.set(query, {
|
|
185
|
+
type: 'service_unavailable',
|
|
186
|
+
message: 'The search task was cancelled on the worker side (pool drain or leader handover) before completing. This says nothing about the query — do not rewrite it; retrying the same query later is reasonable.',
|
|
187
|
+
});
|
|
154
188
|
}
|
|
155
189
|
}
|
|
156
190
|
resultMap.set(query, []);
|
|
@@ -162,6 +196,18 @@ export async function performSearch(
|
|
|
162
196
|
|
|
163
197
|
await Promise.all(searchTasks);
|
|
164
198
|
|
|
199
|
+
// A cancelled run is not a broken one. Every query aborts with zero results,
|
|
200
|
+
// which walks straight into the total-failure branch below and reports
|
|
201
|
+
// "Browser workers may be unavailable, DuckDuckGo is unreachable, or the
|
|
202
|
+
// system is under extreme load" — an infrastructure outage the user never had.
|
|
203
|
+
// They pressed Ctrl-C. Check this BEFORE the outage heuristic, and surface the
|
|
204
|
+
// codebase's standard abort signature so the callers that already special-case
|
|
205
|
+
// it (task-execution-service, the orchestrator, the CLI classifier) see a
|
|
206
|
+
// cancellation rather than a failure.
|
|
207
|
+
if (signal?.aborted) {
|
|
208
|
+
throw new Error('Aborted');
|
|
209
|
+
}
|
|
210
|
+
|
|
165
211
|
// Detect total failure: if every valid query returned empty, the worker pool is likely dead.
|
|
166
212
|
const totalResults = Array.from(resultMap.values()).reduce((sum, r) => sum + r.length, 0);
|
|
167
213
|
if (totalResults === 0 && filteredQueries.length > 0) {
|
|
@@ -141,6 +141,11 @@ export function withTimeout<T>(
|
|
|
141
141
|
// aborted before we start means an external (user) cancellation, not a timeout —
|
|
142
142
|
// a clean stop, so log at debug, not error.
|
|
143
143
|
if (combinedSignal.aborted) {
|
|
144
|
+
// `promise` is a parameter, so the caller already started it. Returning here
|
|
145
|
+
// skips the promise.then(...) attachment below, leaving its eventual rejection
|
|
146
|
+
// with no handler — and Node 22 defaults to --unhandled-rejections=throw, so
|
|
147
|
+
// that kills the process. Adopt the rejection before bailing out.
|
|
148
|
+
void promise.catch(() => undefined);
|
|
144
149
|
logger.debug(`[withTimeout] ${label} already aborted at start (external cancellation)`);
|
|
145
150
|
return reject(new Error(`${label} cancelled or timed out`));
|
|
146
151
|
}
|
|
@@ -319,7 +324,14 @@ export async function retryWithBackoff<T>(
|
|
|
319
324
|
// the full backoff. If the wait is aborted, surface the original transient
|
|
320
325
|
// error (not the synthetic AbortError) so callers see the real cause.
|
|
321
326
|
try {
|
|
322
|
-
|
|
327
|
+
// keepAlive=true: this sleep IS the operation. The retry has already been
|
|
328
|
+
// decided and the next attempt must run, so the timer must hold the event
|
|
329
|
+
// loop open. Without it, a transient network failure — which by its nature
|
|
330
|
+
// leaves no referenced socket handle — can make the backoff the sole pending
|
|
331
|
+
// handle, and Node drains the loop and exits mid-retry. The attempt never
|
|
332
|
+
// runs, and no error surfaces: in the CLI neither branch of the top-level
|
|
333
|
+
// handler fires, so the user gets a bare exit with no report.
|
|
334
|
+
await abortableDelay(delay, opts.signal, true);
|
|
323
335
|
} catch {
|
|
324
336
|
metrics.increment('retry_aborted_total', 1, { label: opts.label });
|
|
325
337
|
throw lastError;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { logger } from '../logger.ts';
|
|
8
8
|
import { performSearch } from './browser-search.ts';
|
|
9
|
-
import type { QueryResultWithError } from './types.ts';
|
|
9
|
+
import type { QueryResultWithError, QueryFailure } from './types.ts';
|
|
10
10
|
import type { Config } from '../config.ts';
|
|
11
11
|
import { metrics } from '../utils/metrics.ts';
|
|
12
12
|
import { getServiceContainer } from '../core/service-registry.ts';
|
|
@@ -39,7 +39,10 @@ export async function search(
|
|
|
39
39
|
const searchStart = Date.now();
|
|
40
40
|
|
|
41
41
|
try {
|
|
42
|
-
|
|
42
|
+
// Per-query failures (timeout / dead worker) come back separately from the
|
|
43
|
+
// results so an empty result set can be attributed correctly below.
|
|
44
|
+
const failures = new Map<string, QueryFailure>();
|
|
45
|
+
const resultMap = await performSearch(queries, config, signal, onProgress, container, failures);
|
|
43
46
|
const searchDuration = Date.now() - searchStart;
|
|
44
47
|
metrics.observe('search_latency_ms', searchDuration);
|
|
45
48
|
|
|
@@ -56,7 +59,12 @@ export async function search(
|
|
|
56
59
|
successfulQueries++;
|
|
57
60
|
metrics.observe('search_results_per_query', qResults.length);
|
|
58
61
|
} else {
|
|
59
|
-
result
|
|
62
|
+
// Only call it an empty result when the search actually completed and
|
|
63
|
+
// returned nothing. A recorded failure (timeout, dead worker, breaker
|
|
64
|
+
// fast-fail) carries its own message — telling the researcher "the query
|
|
65
|
+
// may be too narrow" for an infrastructure fault makes it rewrite a
|
|
66
|
+
// perfectly good query instead of retrying.
|
|
67
|
+
result.error = failures.get(q) ?? {
|
|
60
68
|
type: 'empty_results',
|
|
61
69
|
message: 'Browser-based search returned no relevant results for this query (the query may be too narrow, or the site returned nothing usable).'
|
|
62
70
|
};
|
|
@@ -73,11 +81,18 @@ export async function search(
|
|
|
73
81
|
return results;
|
|
74
82
|
} catch (error) {
|
|
75
83
|
const searchDuration = Date.now() - searchStart;
|
|
84
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
85
|
+
// A cancelled run must propagate as a cancellation. Swallowing it into
|
|
86
|
+
// per-query `unknown` errors would let the orchestrator carry on and
|
|
87
|
+
// synthesize from nothing, then report a failure the user never had. Checked
|
|
88
|
+
// BEFORE the error metrics: a user cancelling runs is not a failing search
|
|
89
|
+
// backend, and counting each Esc as queries.length errors painted one.
|
|
90
|
+
if (signal?.aborted || message === 'Aborted') {
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
76
93
|
metrics.observe('search_latency_ms', searchDuration, { status: 'error' });
|
|
77
94
|
metrics.increment('search_queries_total', queries.length, { status: 'error' });
|
|
78
95
|
metrics.increment('search_errors_total', 1, { error_type: 'search_failed' });
|
|
79
|
-
|
|
80
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
81
96
|
// Re-throw total search failure so the orchestrator can surface a clear error
|
|
82
97
|
// rather than silently producing an empty synthesis with zero links.
|
|
83
98
|
if (message.includes('Search completely failed')) {
|
|
@@ -12,10 +12,23 @@ export type { SearchResult, ScrapeResult };
|
|
|
12
12
|
export interface QueryResultWithError {
|
|
13
13
|
query: string;
|
|
14
14
|
results: SearchResult[];
|
|
15
|
-
error?:
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
error?: QueryFailure;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Why one query yielded nothing.
|
|
20
|
+
*
|
|
21
|
+
* The distinction matters to the *researcher agent*, which reads these messages
|
|
22
|
+
* and decides what to do next: `empty_results` is genuine feedback about the
|
|
23
|
+
* query (rewrite it, broaden it), while a timeout or a dead worker says nothing
|
|
24
|
+
* at all about the query and should prompt a retry instead. Collapsing the two —
|
|
25
|
+
* reporting every zero-result query as "the query may be too narrow" — sent the
|
|
26
|
+
* agent off rewriting perfectly good queries whenever the browser pool was the
|
|
27
|
+
* thing that failed.
|
|
28
|
+
*/
|
|
29
|
+
export interface QueryFailure {
|
|
30
|
+
type: 'empty_results' | 'service_unavailable' | 'timeout' | 'network_error' | 'unknown';
|
|
31
|
+
message: string;
|
|
19
32
|
}
|
|
20
33
|
|
|
21
34
|
// Scraper types
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
import { getRandomUserAgent, extractDomain, validateUrlForSSRF, validateContent, createNativeMarkdownConverter, createJsMarkdownConverter, getSsrfSafeDispatcher, formatErrorWithCause, isBenignScrapeFailure, } from './scraper-utils.ts';
|
|
27
27
|
import { isTransientError, abortableDelay } from './retry-utils.ts';
|
|
28
28
|
import { safeUnref } from '../utils/safe-unref.ts';
|
|
29
|
+
import { readBodyCapped, BodyTooLargeError } from '../utils/http-body.ts';
|
|
29
30
|
import { getServiceContainer } from '../core/service-registry.ts';
|
|
30
31
|
import type { ServiceContainer } from '../core/service-registry.ts';
|
|
31
32
|
import { cacheScrapedContent } from '../utils/shared-links.ts';
|
|
@@ -144,56 +145,12 @@ function decodeHtmlBody(bytes: Uint8Array, contentTypeHeader: string): string {
|
|
|
144
145
|
}
|
|
145
146
|
}
|
|
146
147
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Read a response body with a HARD byte cap, streaming chunk-by-chunk so an
|
|
157
|
-
* over-limit body aborts the moment the cap is crossed. The Content-Length
|
|
158
|
-
* pre-screen above is advisory only: a chunked (or lying) response carries no
|
|
159
|
-
* usable length header, and `response.arrayBuffer()` would otherwise buffer the
|
|
160
|
-
* entire body — potentially hundreds of MB from a hostile server — into the
|
|
161
|
-
* main process before any size check ran.
|
|
162
|
-
*
|
|
163
|
-
* A body-less response (test doubles, some mocks) falls back to arrayBuffer();
|
|
164
|
-
* callers keep their post-read size checks as the belt for that path.
|
|
165
|
-
*/
|
|
166
|
-
async function readBodyCapped(response: Response, maxBytes: number): Promise<Uint8Array> {
|
|
167
|
-
const body = response.body;
|
|
168
|
-
// Also tolerate non-standard doubles that expose a body without getReader.
|
|
169
|
-
if (!body || typeof body.getReader !== 'function') {
|
|
170
|
-
return new Uint8Array(await response.arrayBuffer());
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const reader = body.getReader();
|
|
174
|
-
const chunks: Uint8Array[] = [];
|
|
175
|
-
let total = 0;
|
|
176
|
-
try {
|
|
177
|
-
for (;;) {
|
|
178
|
-
const { done, value } = await reader.read();
|
|
179
|
-
if (done) break;
|
|
180
|
-
if (value) {
|
|
181
|
-
total += value.byteLength;
|
|
182
|
-
if (total > maxBytes) throw new BodyTooLargeError(total, maxBytes);
|
|
183
|
-
chunks.push(value);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
} finally {
|
|
187
|
-
// Over-cap or downstream throw: release the connection instead of leaving
|
|
188
|
-
// the socket open (and the server still sending) until GC. No-op after a
|
|
189
|
-
// clean done=true read.
|
|
190
|
-
try { await reader.cancel(); } catch { /* already closed */ }
|
|
191
|
-
}
|
|
192
|
-
const out = new Uint8Array(total);
|
|
193
|
-
let offset = 0;
|
|
194
|
-
for (const c of chunks) { out.set(c, offset); offset += c.byteLength; }
|
|
195
|
-
return out;
|
|
196
|
-
}
|
|
148
|
+
// readBodyCapped / BodyTooLargeError live in utils/http-body.ts — the scraper
|
|
149
|
+
// pioneered the streaming cap, and the JSON API clients now share that one
|
|
150
|
+
// implementation instead of each carrying a near-copy. The Content-Length
|
|
151
|
+
// pre-screen above stays advisory only: chunked responses carry no usable length
|
|
152
|
+
// header and the header is freely falsifiable, so the streamed cap is the check
|
|
153
|
+
// that actually bounds the allocation.
|
|
197
154
|
|
|
198
155
|
/**
|
|
199
156
|
* Record a fetch-layer failure exactly once per URL.
|
|
@@ -21,6 +21,7 @@ import { withTimeout, retryWithBackoff, createTimeoutSignal } from '../web-resea
|
|
|
21
21
|
import { CircuitBreaker } from '../utils/circuit-breaker.ts';
|
|
22
22
|
import { logger } from '../logger.ts';
|
|
23
23
|
import { metrics } from '../utils/metrics.ts';
|
|
24
|
+
import { readTextCapped } from '../utils/http-body.ts';
|
|
24
25
|
import { mintPoTokens } from './potoken.ts';
|
|
25
26
|
import { watchUrl } from './video-id.ts';
|
|
26
27
|
|
|
@@ -255,7 +256,10 @@ async function fetchOne(
|
|
|
255
256
|
void response.body?.cancel()?.catch(() => { /* best-effort */ });
|
|
256
257
|
throw new Error(`HTTP ${response.status} from timedtext endpoint`);
|
|
257
258
|
}
|
|
258
|
-
|
|
259
|
+
// A timedtext document is kilobytes; the cap only bounds a hostile or
|
|
260
|
+
// malfunctioning response, which this path is directly exposed to because
|
|
261
|
+
// base_url comes out of a parsed getInfo() payload.
|
|
262
|
+
const body = await readTextCapped(response);
|
|
259
263
|
const text = parseJson3(body);
|
|
260
264
|
|
|
261
265
|
// Empty-body guard: HTTP 200 + empty body == silent PoToken failure.
|