@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.
Files changed (82) hide show
  1. package/README.md +4 -4
  2. package/dist/cli.mjs +1404 -600
  3. package/dist/thread-worker.mjs +40 -11
  4. package/docs/AGENT-SKILL.md +11 -4
  5. package/docs/ARCHITECTURE.md +19 -9
  6. package/docs/CHANGELOG.md +107 -1
  7. package/docs/CONFIGURATION.md +10 -8
  8. package/docs/KNOWLEDGE-STORE.md +5 -4
  9. package/docs/PI-EXTENSION.md +1 -1
  10. package/docs/SDK.md +63 -5
  11. package/docs/THIRD-PARTY-NOTICES.md +1 -1
  12. package/package.json +5 -4
  13. package/scripts/build.cjs +23 -5
  14. package/scripts/cleanup.cjs +50 -10
  15. package/scripts/prepare.cjs +15 -5
  16. package/scripts/setup.cjs +24 -4
  17. package/skills/pi-research/README.md +9 -2
  18. package/skills/pi-research/SKILL.md +8 -5
  19. package/skills/pi-research/references/configuration.md +7 -3
  20. package/skills/pi-research/scripts/run.mjs +24 -2
  21. package/src/cli.ts +161 -21
  22. package/src/config.ts +56 -15
  23. package/src/constants.ts +31 -0
  24. package/src/core/interfaces/knowledge-interfaces.ts +6 -1
  25. package/src/core/interfaces/state-manager-interfaces.ts +1 -1
  26. package/src/core/llm/model-registry-factory.ts +40 -3
  27. package/src/core/llm/research-model-resolver.ts +2 -2
  28. package/src/core/pi-version.ts +136 -0
  29. package/src/core/service-registry.ts +117 -8
  30. package/src/index.ts +11 -18
  31. package/src/infrastructure/browser/browser-client.ts +16 -4
  32. package/src/infrastructure/browser/browser-error-utils.ts +12 -1
  33. package/src/infrastructure/browser/browser-server.ts +17 -5
  34. package/src/infrastructure/browser/browser-task-scheduler.ts +101 -23
  35. package/src/infrastructure/browser/cleanup-utils.ts +45 -7
  36. package/src/infrastructure/browser/client-agent.ts +4 -3
  37. package/src/infrastructure/browser/priority-task-queue.ts +10 -3
  38. package/src/infrastructure/browser/scheduler-factory.ts +26 -1
  39. package/src/infrastructure/browser/task-execution-service.ts +92 -28
  40. package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
  41. package/src/infrastructure/browser/thread-worker.mjs +40 -11
  42. package/src/infrastructure/browser/worker-pool-manager.ts +11 -3
  43. package/src/infrastructure/embedding/embedding-client.ts +22 -8
  44. package/src/infrastructure/embedding/embedding-factory.ts +28 -11
  45. package/src/infrastructure/embedding/embedding-server.ts +65 -4
  46. package/src/infrastructure/file-lock-service.ts +223 -26
  47. package/src/infrastructure/process-lifecycle-service.ts +108 -31
  48. package/src/infrastructure/state/state-backup-manager.ts +5 -7
  49. package/src/infrastructure/state/state-manager-service.ts +1 -1
  50. package/src/infrastructure/state/state-manager.ts +19 -20
  51. package/src/infrastructure/types/state-types.ts +9 -0
  52. package/src/knowledge/embedder-init.ts +1 -1
  53. package/src/knowledge/embedder.ts +52 -3
  54. package/src/knowledge/index.ts +1 -1
  55. package/src/knowledge/store.ts +35 -9
  56. package/src/knowledge/webgpu-viability.ts +9 -4
  57. package/src/knowledge/writer-queue.ts +88 -46
  58. package/src/orchestration/deep-research-orchestrator.ts +13 -2
  59. package/src/orchestration/quick-research-orchestrator.ts +2 -2
  60. package/src/orchestration/research-orchestration-service.ts +2 -4
  61. package/src/orchestration/research-synthesis-service.ts +34 -92
  62. package/src/security/cisa-kev.ts +10 -3
  63. package/src/security/github-advisories.ts +18 -7
  64. package/src/security/nvd-client.ts +17 -6
  65. package/src/security/osv-client.ts +14 -4
  66. package/src/skill-install/skill-installer.ts +135 -9
  67. package/src/stackexchange/rest-client.ts +6 -2
  68. package/src/tools/research-knowledge-search.ts +23 -2
  69. package/src/tools/search.ts +6 -1
  70. package/src/utils/atomic-replace.ts +101 -0
  71. package/src/utils/circuit-breaker.ts +20 -0
  72. package/src/utils/citation-utils.ts +106 -8
  73. package/src/utils/http-body.ts +176 -0
  74. package/src/utils/llm-usage.ts +37 -0
  75. package/src/utils/metrics-summary.ts +25 -2
  76. package/src/utils/research-export.ts +9 -2
  77. package/src/web-research/browser-search.ts +50 -4
  78. package/src/web-research/retry-utils.ts +13 -1
  79. package/src/web-research/search.ts +20 -5
  80. package/src/web-research/types.ts +17 -4
  81. package/src/web-research/web-scraper.ts +7 -50
  82. 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
- const filteredQueries = queries.filter(q => q.trim());
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
- await abortableDelay(delay, opts.signal);
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
- const resultMap = await performSearch(queries, config, signal, onProgress, container);
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.error = {
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
- type: 'empty_results' | 'service_unavailable' | 'timeout' | 'network_error' | 'unknown';
17
- message: string;
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
- /** Thrown by readBodyCapped when a streamed body crosses its byte cap. */
148
- class BodyTooLargeError extends Error {
149
- constructor(public readonly bytesRead: number, public readonly maxBytes: number) {
150
- super(`Response body exceeded ${maxBytes} byte cap`);
151
- this.name = 'BodyTooLargeError';
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
- const body = await response.text();
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.