@lincoln504/pi-research 1.1.2 → 1.3.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 (119) hide show
  1. package/README.md +3 -3
  2. package/dist/cli.mjs +2599 -1268
  3. package/dist/thread-worker.mjs +224 -147
  4. package/docs/AGENT-SKILL.md +11 -4
  5. package/docs/ARCHITECTURE.md +20 -10
  6. package/docs/CHANGELOG.md +195 -17
  7. package/docs/CONFIGURATION.md +9 -7
  8. package/docs/KNOWLEDGE-STORE.md +4 -3
  9. package/docs/PI-EXTENSION.md +1 -1
  10. package/docs/SDK.md +55 -0
  11. package/docs/THIRD-PARTY-NOTICES.md +1 -1
  12. package/package.json +5 -4
  13. package/scripts/cleanup.cjs +114 -12
  14. package/scripts/prepare.cjs +15 -5
  15. package/scripts/setup.cjs +24 -4
  16. package/skills/pi-research/README.md +9 -2
  17. package/skills/pi-research/SKILL.md +6 -3
  18. package/skills/pi-research/references/configuration.md +6 -2
  19. package/skills/pi-research/scripts/run.mjs +24 -2
  20. package/src/cleanup/research-cleanup.ts +12 -4
  21. package/src/cli.ts +372 -86
  22. package/src/config.ts +134 -17
  23. package/src/constants.ts +31 -0
  24. package/src/core/interfaces/health-check-interfaces.ts +3 -39
  25. package/src/core/interfaces/knowledge-interfaces.ts +6 -1
  26. package/src/core/interfaces/planning-interfaces.ts +8 -0
  27. package/src/core/interfaces/service-names.ts +0 -1
  28. package/src/core/interfaces/state-manager-interfaces.ts +11 -3
  29. package/src/core/llm/agentic-repair.ts +14 -0
  30. package/src/core/pi-version.ts +136 -0
  31. package/src/core/planning-service.ts +43 -1
  32. package/src/core/service-initialization.ts +6 -14
  33. package/src/core/service-interfaces.ts +1 -1
  34. package/src/core/service-registry.ts +117 -8
  35. package/src/healthcheck/index.ts +7 -2
  36. package/src/index.ts +11 -18
  37. package/src/infrastructure/browser/browser-client.ts +80 -13
  38. package/src/infrastructure/browser/browser-error-utils.ts +6 -1
  39. package/src/infrastructure/browser/browser-server.ts +41 -12
  40. package/src/infrastructure/browser/browser-task-scheduler.ts +159 -35
  41. package/src/infrastructure/browser/cleanup-utils.ts +95 -9
  42. package/src/infrastructure/browser/client-agent.ts +6 -5
  43. package/src/infrastructure/browser/config.ts +14 -3
  44. package/src/infrastructure/browser/priority-task-queue.ts +10 -3
  45. package/src/infrastructure/browser/scheduler-factory.ts +44 -4
  46. package/src/infrastructure/browser/task-execution-service.ts +92 -28
  47. package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
  48. package/src/infrastructure/browser/thread-worker-messaging.ts +54 -2
  49. package/src/infrastructure/browser/thread-worker-types.ts +3 -1
  50. package/src/infrastructure/browser/thread-worker.mjs +224 -147
  51. package/src/infrastructure/browser/worker-pool-manager.ts +18 -5
  52. package/src/infrastructure/embedding/embedding-client.ts +22 -8
  53. package/src/infrastructure/embedding/embedding-factory.ts +61 -15
  54. package/src/infrastructure/embedding/embedding-server.ts +105 -11
  55. package/src/infrastructure/file-lock-service.ts +295 -28
  56. package/src/infrastructure/knowledge-store-service.ts +48 -3
  57. package/src/infrastructure/process-lifecycle-service.ts +134 -34
  58. package/src/infrastructure/research-run-semaphore.ts +37 -3
  59. package/src/infrastructure/state/state-backup-manager.ts +5 -7
  60. package/src/infrastructure/state/state-browser-api.ts +5 -2
  61. package/src/infrastructure/state/state-browser-manager.ts +15 -1
  62. package/src/infrastructure/state/state-manager-service.ts +5 -5
  63. package/src/infrastructure/state/state-manager.ts +34 -23
  64. package/src/infrastructure/types/state-types.ts +9 -0
  65. package/src/knowledge/chunker.ts +88 -1
  66. package/src/knowledge/embedder-init.ts +1 -1
  67. package/src/knowledge/embedder.ts +188 -46
  68. package/src/knowledge/index.ts +1 -1
  69. package/src/knowledge/store.ts +101 -21
  70. package/src/knowledge/webgpu-viability.ts +9 -4
  71. package/src/knowledge/writer-queue.ts +88 -46
  72. package/src/logger.ts +5 -1
  73. package/src/observers/research-observer-impl.ts +31 -0
  74. package/src/orchestration/abort-utils.ts +60 -0
  75. package/src/orchestration/deep-research-orchestrator.ts +25 -4
  76. package/src/orchestration/headless-observer.ts +8 -0
  77. package/src/orchestration/quick-research-orchestrator.ts +2 -2
  78. package/src/orchestration/research-orchestration-service.ts +65 -15
  79. package/src/orchestration/research-session-service.ts +37 -11
  80. package/src/orchestration/research-synthesis-service.ts +33 -11
  81. package/src/orchestration/researcher-executor.ts +100 -20
  82. package/src/orchestration/researcher.ts +39 -20
  83. package/src/orchestration/session-state.ts +11 -0
  84. package/src/sdk.ts +125 -19
  85. package/src/security/cisa-kev.ts +10 -3
  86. package/src/security/github-advisories.ts +18 -7
  87. package/src/security/index.ts +51 -30
  88. package/src/security/nvd-client.ts +24 -8
  89. package/src/security/osv-client.ts +14 -4
  90. package/src/skill-install/skill-installer.ts +153 -11
  91. package/src/stackexchange/index.ts +20 -5
  92. package/src/stackexchange/output/table.ts +7 -2
  93. package/src/stackexchange/rest-client.ts +41 -3
  94. package/src/tools/research-knowledge-search.ts +15 -0
  95. package/src/tools/research-tool-definition.ts +8 -2
  96. package/src/tools/scrape.ts +48 -25
  97. package/src/tools/search.ts +16 -2
  98. package/src/tools/security.ts +68 -7
  99. package/src/tui/research-health.ts +18 -2
  100. package/src/utils/atomic-replace.ts +101 -0
  101. package/src/utils/circuit-breaker.ts +9 -2
  102. package/src/utils/citation-utils.ts +3 -1
  103. package/src/utils/disk-space-checker.ts +11 -6
  104. package/src/utils/http-body.ts +176 -0
  105. package/src/utils/json-utils.ts +116 -76
  106. package/src/utils/llm-usage.ts +17 -1
  107. package/src/utils/log-rotation.ts +42 -10
  108. package/src/utils/log-utils.ts +40 -0
  109. package/src/utils/metrics-summary.ts +27 -9
  110. package/src/utils/stdio-capture.ts +63 -22
  111. package/src/utils/text-utils.ts +8 -1
  112. package/src/utils/tool-usage-tracker.ts +9 -2
  113. package/src/web-research/browser-search.ts +70 -5
  114. package/src/web-research/scraper-utils.ts +13 -0
  115. package/src/web-research/search.ts +25 -6
  116. package/src/web-research/types.ts +19 -4
  117. package/src/web-research/web-scraper.ts +72 -54
  118. package/src/youtube/transcript-client.ts +5 -1
  119. package/src/core/health-check-service.ts +0 -265
@@ -7,9 +7,20 @@ import type { StackExchangeWrapper } from './types.ts';
7
7
  import { logger } from '../logger.ts';
8
8
  import { CircuitBreaker } from '../utils/circuit-breaker.ts';
9
9
  import { metrics } from '../utils/metrics.ts';
10
+ import { readJsonCapped, BodyTooLargeError } from '../utils/http-body.ts';
10
11
 
11
12
  const API_BASE = 'https://api.stackexchange.com/2.3';
12
13
 
14
+ // How long quota-exhaustion evidence keeps the request gate armed. quotaRemaining
15
+ // is only refreshed from a successful response, and the tool-level gate
16
+ // (stackexchange/index.ts) blocks every request while isQuotaExhausted() — so a
17
+ // plain `quotaRemaining <= 0` check could never observe SE's real daily reset and
18
+ // wedged the process-wide client until restart or an API-key change. After the
19
+ // cooldown one probe request is allowed: it either refreshes the quota (clearing
20
+ // the stamp) or shows 0 / throttles again, re-arming the gate — at most one
21
+ // wasted request per cooldown.
22
+ const QUOTA_PROBE_COOLDOWN_MS = 10 * 60 * 1000;
23
+
13
24
  export interface RequestOptions {
14
25
  method: string;
15
26
  endpoint: string;
@@ -21,6 +32,9 @@ export class StackExchangeClient {
21
32
  private readonly _timeout: number;
22
33
  private quotaRemaining = 300;
23
34
  private quotaMax = 300;
35
+ // Timestamp of the last quota-exhaustion evidence (a response reporting
36
+ // quota_remaining <= 0, or a throttle_violation API error). null = no evidence.
37
+ private quotaExhaustedAt: number | null = null;
24
38
  private requestCount = 0;
25
39
  private lastBackoff: number | null = null;
26
40
  private circuitBreaker: CircuitBreaker;
@@ -99,7 +113,7 @@ export class StackExchangeClient {
99
113
 
100
114
  // NB: the timeout is cleared in `finally`, not here. Clearing it now —
101
115
  // when only the headers have arrived — would leave the body read
102
- // (response.json() below) with no timeout, so a stalled body could hang
116
+ // (readJsonCapped below) with no timeout, so a stalled body could hang
103
117
  // for the whole process lifetime.
104
118
 
105
119
  // The SE API returns a structured JSON wrapper (with error_id) even for
@@ -109,8 +123,11 @@ export class StackExchangeClient {
109
123
  // "Unexpected token <" SyntaxError that hides the real cause.
110
124
  let data: StackExchangeWrapper<T>;
111
125
  try {
112
- data = await response.json() as StackExchangeWrapper<T>;
126
+ data = await readJsonCapped<StackExchangeWrapper<T>>(response);
113
127
  } catch (parseErr) {
128
+ // An over-cap body is a size failure, not a malformed-JSON one: rethrow
129
+ // it unwrapped so it can't be relabelled as an HTTP-status error below.
130
+ if (parseErr instanceof BodyTooLargeError) throw parseErr;
114
131
  if (!response.ok) throw new Error(`HTTP ${response.status} from Stack Exchange API`, { cause: parseErr });
115
132
  throw parseErr;
116
133
  }
@@ -118,6 +135,12 @@ export class StackExchangeClient {
118
135
  // Handle API errors
119
136
  if (data.error_id) {
120
137
  const errorName = data.error_name ?? 'unknown';
138
+ // A throttle_violation is quota-exhaustion evidence even though no
139
+ // quota_remaining arrives with it (this throw happens before the quota
140
+ // update below) — stamp it so the gate arms/re-arms.
141
+ if (errorName === 'throttle_violation') {
142
+ this.quotaExhaustedAt = Date.now();
143
+ }
121
144
  metrics.increment('stackexchange_errors_total', 1, {
122
145
  endpoint: options.endpoint,
123
146
  error_id: data.error_id.toString(),
@@ -132,6 +155,15 @@ export class StackExchangeClient {
132
155
  this.quotaRemaining = data.quota_remaining;
133
156
  this.quotaMax = data.quota_max;
134
157
  this.requestCount++;
158
+ // Stamp (or clear) exhaustion evidence: isQuotaExhausted() reads the
159
+ // stamp, not quotaRemaining — which can never refresh while the gate
160
+ // blocks requests — so a probe after the cooldown self-corrects once
161
+ // SE's daily reset has happened.
162
+ if (data.quota_remaining <= 0) {
163
+ this.quotaExhaustedAt = Date.now();
164
+ } else {
165
+ this.quotaExhaustedAt = null;
166
+ }
135
167
 
136
168
  // Track quota metrics
137
169
  metrics.setGauge('stackexchange_quota_remaining', this.quotaRemaining);
@@ -193,7 +225,13 @@ export class StackExchangeClient {
193
225
  }
194
226
 
195
227
  isQuotaExhausted(): boolean {
196
- return this.quotaRemaining <= 0;
228
+ // True only within the probe cooldown of the last exhaustion evidence. After
229
+ // it elapses, one probe request is allowed through the tool-level gate; the
230
+ // probe's outcome refreshes or clears the stamp (see request()).
231
+ return (
232
+ this.quotaExhaustedAt !== null &&
233
+ Date.now() - this.quotaExhaustedAt < QUOTA_PROBE_COOLDOWN_MS
234
+ );
197
235
  }
198
236
 
199
237
  isQuotaLow(): boolean {
@@ -962,6 +962,21 @@ export function createResearchKnowledgeSearchTool(iface?: ConfigInterface): Tool
962
962
  return buildSteeringResult(result, urls);
963
963
  } catch (error) {
964
964
  const durationMs = Date.now() - startTime;
965
+ const msg = error instanceof Error ? error.message : String(error);
966
+
967
+ // A CANCELLED search is not an empty store. Reporting the miss string here
968
+ // told the calling agent, definitively, that the knowledge store has
969
+ // nothing on this topic — so it would go straight to full live research,
970
+ // the very work the user just interrupted. Propagate the abort instead;
971
+ // the run is ending either way. (Same misclassification as the search
972
+ // layer's "browser workers may be unavailable" on Ctrl-C.)
973
+ if (signal?.aborted || (error instanceof Error && error.name === 'AbortError') || /^(aborted|research (aborted|cancelled))$/i.test(msg)) {
974
+ metrics.observe('research_knowledge_search_duration_ms', durationMs, { status: 'cancelled' });
975
+ metrics.increment('research_knowledge_search_total', 1, { status: 'cancelled' });
976
+ logger.debug('[research-knowledge-search] Cancelled mid-search — propagating rather than reporting an empty store.');
977
+ throw error;
978
+ }
979
+
965
980
  metrics.observe('research_knowledge_search_duration_ms', durationMs, { status: 'error' });
966
981
  metrics.increment('research_knowledge_search_total', 1, { status: 'error' });
967
982
 
@@ -466,8 +466,14 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
466
466
 
467
467
  const errMsg = String(error).toLowerCase();
468
468
 
469
- // Handle rate limits gracefully
470
- if (errMsg.includes('429') || errMsg.includes('rate limit') || errMsg.includes('too many requests') || errMsg.includes('quota')) {
469
+ // Handle rate limits gracefully. "429" is matched on a word boundary
470
+ // (mirroring messageIsTransient in web-research/retry-utils.ts): a substring
471
+ // test also matched digits embedded in a larger number — e.g. a context-
472
+ // overflow error quoting "you requested 142935 tokens" — misreporting the
473
+ // run as rate-limited. 'quota' stays unanchored, same trade-off as
474
+ // retry-utils: provider quota errors ("insufficient_quota") carry no
475
+ // rate/limit wording to anchor on.
476
+ if (/\b429\b/.test(errMsg) || errMsg.includes('rate limit') || errMsg.includes('too many requests') || errMsg.includes('quota')) {
471
477
  logger.warn('[research] Run halted gracefully due to rate limit:', error);
472
478
  if (ctx.hasUI) {
473
479
  ctx.ui.notify('Research halted: API rate limit reached', 'warning');
@@ -139,11 +139,38 @@ export function createScrapeTool(options: {
139
139
  // almost nothing and must not be blocked as if every URL were a full scrape.
140
140
  const { kept: dedupedUrls, duplicates } = deduplicateUrls(rawUrls, getGlobalState().researchId);
141
141
 
142
+ // Partition duplicates by whether their content still survives in the session
143
+ // cache. The content cache is FIFO-capped, so a pool member's content may have
144
+ // been evicted; those URLs re-enter the fetch list — otherwise they would be
145
+ // reported as "retrieved from session memory" while appearing nowhere in the
146
+ // output, and a re-request would just dedup them away again.
147
+ const duplicateResults: { url: string; markdown: string; success: true }[] = [];
148
+ const evictedDuplicates: string[] = [];
149
+ for (const url of duplicates) {
150
+ const cachedContent = getCachedScrapedContent(getGlobalState().researchId, url);
151
+ if (cachedContent) {
152
+ duplicateResults.push({ url, markdown: cachedContent, success: true });
153
+ } else {
154
+ evictedDuplicates.push(url);
155
+ }
156
+ }
157
+
158
+ // Only the first MAX_SCRAPE_URLS candidates are ever fetched. Slice BEFORE the
159
+ // context gate so the projection bills what will actually be scraped — billing
160
+ // the full list blocked an 8-URL first batch on a 128k model at zero tokens
161
+ // used, while the URLs actually fetchable would have passed. Over-cap URLs are
162
+ // reported explicitly below instead of silently vanishing (they appear neither
163
+ // in results nor in Failed-to-Scrape, and the session-pool footer excludes the
164
+ // current batch).
165
+ const fetchCandidates = [...dedupedUrls, ...evictedDuplicates];
166
+ const finalUrls = fetchCandidates.slice(0, MAX_SCRAPE_URLS);
167
+ const skippedUrls = fetchCandidates.slice(MAX_SCRAPE_URLS);
168
+
142
169
  // Context gate: block if projected token usage would exceed threshold
143
170
  if (options.getTokensUsed) {
144
171
  const ctxWindow = options.contextWindowSize ?? DEFAULT_MODEL_CONTEXT_WINDOW;
145
172
  const tokensUsed = options.getTokensUsed();
146
- const projected = (tokensUsed + dedupedUrls.length * config.AVG_TOKENS_PER_SCRAPE) / ctxWindow;
173
+ const projected = (tokensUsed + finalUrls.length * config.AVG_TOKENS_PER_SCRAPE) / ctxWindow;
147
174
  if (projected >= config.MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING) {
148
175
  const pct = Math.round(config.MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING * 100);
149
176
  metrics.increment('tool_scrape_calls_total', 1, { status: 'context_limit' });
@@ -160,36 +187,22 @@ export function createScrapeTool(options: {
160
187
  }
161
188
 
162
189
  const callCount = options.tracker?.getToolCallCount('scrape') ?? 0;
163
- const batchLabel = `Batch ${callCount + 1}`;
164
190
  options.tracker?.recordCall('scrape');
165
191
  const scrapeStartTime = Date.now();
166
- const dedupNote = duplicates.length > 0
167
- ? `**Global Cache Hit**: ${duplicates.length} URL(s) retrieved from session memory (already scraped).\n\n`
192
+ // Count only content actually re-served. Evicted pool members are in the fetch
193
+ // list instead, so they surface as fresh results (or in the not-fetched note),
194
+ // never as a "retrieved" claim with no content attached.
195
+ let dedupNote = duplicateResults.length > 0
196
+ ? `**Global Cache Hit**: ${duplicateResults.length} URL(s) retrieved from session memory (already scraped).\n\n`
168
197
  : '';
169
- metrics.increment('tool_scrape_duplicates_total', duplicates.length);
170
-
171
- // Even for duplicates, we want to return the content if we have it in the session cache
172
- const duplicateResults: { url: string; markdown: string; success: true }[] = [];
173
- for (const url of duplicates) {
174
- const cachedContent = getCachedScrapedContent(getGlobalState().researchId, url);
175
- if (cachedContent) {
176
- duplicateResults.push({ url, markdown: cachedContent, success: true });
177
- options.onUrlScrapeResult?.(url, true);
178
- }
198
+ if (evictedDuplicates.length > 0) {
199
+ dedupNote += `**Session Cache Evicted**: ${evictedDuplicates.length} previously-scraped URL(s) no longer in session memory — queued for a fresh fetch:\n${evictedDuplicates.map(u => `- ${u}`).join('\n')}\n\n`;
179
200
  }
180
-
181
- if (dedupedUrls.length === 0 && duplicateResults.length === 0) {
182
- metrics.increment('tool_scrape_calls_total', 1, { status: 'all_duplicates_no_content' });
183
- // Add footer with alternative URLs for discovery
184
- const earlyFooter = buildSessionPoolFooter(getGlobalState().researchId, rawUrls, options.researcherId);
185
- return {
186
- content: [{ type: 'text', text: `# ${batchLabel} Skipped\n\nAll URLs were already in the global pool, but no cached content was available.${earlyFooter}` }],
187
- details: { all_duplicates: true },
188
- };
201
+ metrics.increment('tool_scrape_duplicates_total', duplicates.length);
202
+ for (const r of duplicateResults) {
203
+ options.onUrlScrapeResult?.(r.url, true);
189
204
  }
190
205
 
191
- const finalUrls = dedupedUrls.slice(0, MAX_SCRAPE_URLS);
192
-
193
206
  // Batch 1 uses the configured scrape concurrency; batch 2+ may go wider
194
207
  // once the context budget allows — but never wider than the real browser
195
208
  // pool (WORKER_THREADS × WORKER_CONCURRENCY), or the extra requests just
@@ -331,6 +344,15 @@ export function createScrapeTool(options: {
331
344
  markdown += '\n';
332
345
  }
333
346
 
347
+ if (skippedUrls.length > 0) {
348
+ markdown += `## Not Fetched — Over Batch Cap (${skippedUrls.length})\n\n`;
349
+ markdown += `Only the first ${MAX_SCRAPE_URLS} URLs of a batch are fetched. The following URLs were NOT fetched — no content for them appears above. Request them in a later scrape batch if still needed:\n`;
350
+ for (const url of skippedUrls) {
351
+ markdown += `- ${url}\n`;
352
+ }
353
+ markdown += '\n';
354
+ }
355
+
334
356
  // Append Session URL Pool footer
335
357
  markdown += buildSessionPoolFooter(getGlobalState().researchId, rawUrls, options.researcherId);
336
358
 
@@ -342,6 +364,7 @@ export function createScrapeTool(options: {
342
364
  failed: failedFresh.length,
343
365
  cached: cachedResults.length,
344
366
  fresh: successfulFresh.length,
367
+ skipped: skippedUrls.length,
345
368
  },
346
369
  };
347
370
  },
@@ -9,6 +9,7 @@ import { Type, type Static } from 'typebox';
9
9
  import { Value } from 'typebox/value';
10
10
  import { search } from '../web-research/search.ts';
11
11
  import type { ToolUsageTracker } from '../utils/tool-usage-tracker.ts';
12
+ import type { SystemResearchState } from '../orchestration/deep-research-types.ts';
12
13
  import { logger } from '../logger.ts';
13
14
  import { type Config, getConfig } from '../config.ts';
14
15
  import { metrics } from '../utils/metrics.ts';
@@ -18,6 +19,8 @@ export function createSearchTool(options: {
18
19
  ctx: ExtensionContext;
19
20
  tracker: ToolUsageTracker;
20
21
  onProgress?: (links: number) => void;
22
+ /** Research state accessor; its researchId keys the per-session circuit breaker (mirrors scrape). */
23
+ getGlobalState?: () => SystemResearchState;
21
24
  config?: Config;
22
25
  }): ToolDefinition {
23
26
  const youtubeEveryN = (options.config ?? getConfig(options.ctx.cwd)).YOUTUBE_QUERY_EVERY_N;
@@ -84,9 +87,15 @@ export function createSearchTool(options: {
84
87
 
85
88
  try {
86
89
  const container = tryGetServiceContainerFromCtx(ctx);
90
+ // Per-session circuit-breaker scoping: thread the researchId exactly as
91
+ // the scrape path does (tools/scrape.ts passes getGlobalState().researchId
92
+ // into scrape() → runBrowserTask). Search previously hard-coded
93
+ // sessionId=undefined at the runWorkerSearch hop, so its failures always
94
+ // hit the GLOBAL breaker while scrape's hit the per-run one.
95
+ const sessionId = options.getGlobalState?.().researchId;
87
96
  const results = await search(queries, options.config, signal, (links) => {
88
97
  if (options.onProgress) options.onProgress(links);
89
- }, container);
98
+ }, container, sessionId);
90
99
  const elapsed = Date.now() - startTime;
91
100
 
92
101
  const totalResults = results.reduce((sum, r) => sum + r.results.length, 0);
@@ -106,7 +115,12 @@ export function createSearchTool(options: {
106
115
  results.forEach((r, i) => {
107
116
  markdown += `## Query ${i + 1}: ${r.query}\n`;
108
117
  if (r.results.length === 0) {
109
- markdown += `*No results found.*\n\n`;
118
+ // Render the attributed failure, not a bare "no results": a timeout or
119
+ // dead worker says nothing about the query, and presenting it as an
120
+ // empty result sent the model off rewriting perfectly good queries —
121
+ // the misdirection QueryFailure exists to prevent. This is the only
122
+ // surface the researcher agent actually reads.
123
+ markdown += r.error ? `*${r.error.message}*\n\n` : `*No results found.*\n\n`;
110
124
  } else {
111
125
  r.results.forEach((item, j) => {
112
126
  markdown += `[${j + 1}] **${item.title}**\n${item.url}\n${item.content}\n\n`;
@@ -97,9 +97,48 @@ export function createSecuritySearchTool(options: {
97
97
  throw new Error('At least one search term is required');
98
98
  }
99
99
 
100
+ // Normalize database names (schema accepts free text): "NVD" must reach the
101
+ // dispatcher as "nvd". Unknown names are still passed through — the searcher
102
+ // reports each one in `errors`, which the note below surfaces — but when EVERY
103
+ // requested name is unknown the call must fail loudly here: running zero
104
+ // databases and reporting "0 vulnerabilities found" would read as an
105
+ // authoritative all-clear.
106
+ const VALID_DATABASES = ['nvd', 'cisa_kev', 'github', 'osv'] as const;
100
107
  const databases: readonly ('nvd' | 'cisa_kev' | 'github' | 'osv')[] = p.databases !== undefined && p.databases.length > 0
101
- ? p.databases as readonly ('nvd' | 'cisa_kev' | 'github' | 'osv')[]
102
- : ['nvd', 'cisa_kev', 'github', 'osv'];
108
+ ? p.databases.map(db => db.trim().toLowerCase()) as readonly ('nvd' | 'cisa_kev' | 'github' | 'osv')[]
109
+ : VALID_DATABASES;
110
+ if (!databases.some(db => (VALID_DATABASES as readonly string[]).includes(db))) {
111
+ metrics.increment('tool_security_search_calls_total', 1, { status: 'unknown_databases' });
112
+ return {
113
+ content: [{
114
+ type: 'text',
115
+ text: `# Security Vulnerability Search Failed\n\n**Error:** No recognized databases in request — nothing was searched.\n\n**Requested:** ${p.databases!.join(', ')}\n\n**Valid databases (case-insensitive):** ${VALID_DATABASES.join(', ')}`,
116
+ }],
117
+ details: { error: 'unknown_databases', requested: p.databases, validDatabases: VALID_DATABASES },
118
+ };
119
+ }
120
+ // Normalize the severity filter at the single dispatch chokepoint (schema
121
+ // accepts free text). Only NVD's path normalized (security/index.ts
122
+ // getSeverityParam): GitHub/OSV received the raw string, so "med" or
123
+ // "HIGH,CRITICAL" ran NVD unfiltered while their strict filters silently
124
+ // returned zero. An unrecognized value must fail loudly BEFORE any database
125
+ // is queried rather than let the databases silently diverge.
126
+ const VALID_SEVERITIES = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] as const;
127
+ let severity: string | undefined;
128
+ if (p.severity !== undefined) {
129
+ const upper = p.severity.trim().toUpperCase();
130
+ severity = upper === 'MODERATE' ? 'MEDIUM' : upper;
131
+ if (!(VALID_SEVERITIES as readonly string[]).includes(severity)) {
132
+ metrics.increment('tool_security_search_calls_total', 1, { status: 'invalid_severity' });
133
+ return {
134
+ content: [{
135
+ type: 'text',
136
+ text: `[Error] Unknown severity "${p.severity}" — valid: LOW, MEDIUM, HIGH, CRITICAL (MODERATE accepted as MEDIUM). No databases were queried.`,
137
+ }],
138
+ details: { error: 'invalid_severity', requested: p.severity, validSeverities: VALID_SEVERITIES },
139
+ };
140
+ }
141
+ }
103
142
  const maxResults = p.maxResults ?? 20;
104
143
 
105
144
  metrics.increment('tool_security_search_terms_total', terms.length);
@@ -110,7 +149,7 @@ export function createSecuritySearchTool(options: {
110
149
  const searchParams: SecuritySearchParams = {
111
150
  terms,
112
151
  databases,
113
- severity: p.severity,
152
+ severity,
114
153
  maxResults,
115
154
  includeExploited: p.includeExploited ?? false,
116
155
  ecosystem: p.ecosystem,
@@ -172,7 +211,13 @@ export function createSecuritySearchTool(options: {
172
211
  markdown += '## NIST NVD\n\n';
173
212
  if (results.results.nvd.error !== undefined) {
174
213
  markdown += `[Error] ${results.results.nvd.error}\n\n`;
175
- } else {
214
+ }
215
+ // Partial failures: the clients deliberately return whatever they fetched
216
+ // PLUS an error annotation (e.g. "NVD lookup failed for 1/2 term(s)"), so
217
+ // the data must render alongside the [Error] line — the header's totals
218
+ // already count it. Only a total failure (error with zero rows) stays
219
+ // error-only, so it can't read as an authoritative "found nothing".
220
+ if (results.results.nvd.error === undefined || results.results.nvd.vulnerabilities.length > 0) {
176
221
  markdown += `Found: ${results.results.nvd.count} vulnerabilities\n\n`;
177
222
  for (const vuln of results.results.nvd.vulnerabilities.slice(0, 20)) {
178
223
  markdown += `### ${vuln.id}\n`;
@@ -212,7 +257,9 @@ export function createSecuritySearchTool(options: {
212
257
  markdown += '## CISA Known Exploited Vulnerabilities\n\n';
213
258
  if (results.results.cisa_kev.error !== undefined) {
214
259
  markdown += `[Error] ${results.results.cisa_kev.error}\n\n`;
215
- } else {
260
+ }
261
+ // Partial failure: render the data alongside the error (see NVD section).
262
+ if (results.results.cisa_kev.error === undefined || results.results.cisa_kev.vulnerabilities.length > 0) {
216
263
  markdown += `Found: ${results.results.cisa_kev.count} actively exploited vulnerabilities\n\n`;
217
264
  for (const vuln of results.results.cisa_kev.vulnerabilities.slice(0, 20)) {
218
265
  markdown += `### ${vuln.id}\n`;
@@ -245,8 +292,16 @@ export function createSecuritySearchTool(options: {
245
292
  markdown += '## GitHub Security Advisories\n\n';
246
293
  if (results.results.github.error !== undefined) {
247
294
  markdown += `[Error] ${results.results.github.error}\n\n`;
248
- } else {
295
+ }
296
+ // Partial failure: render the data alongside the error (see NVD section).
297
+ if (results.results.github.error === undefined || results.results.github.advisories.length > 0) {
249
298
  markdown += `Found: ${results.results.github.count} advisories\n\n`;
299
+ if (p.includeExploited === true) {
300
+ // includeExploited reaches only NVD (hasKev) and CISA KEV (inherently
301
+ // exploited-only); GitHub carries no exploitation signal, so these
302
+ // results are NOT filtered by it — say so rather than imply they were.
303
+ markdown += '*Note: GitHub Advisories are not filtered by known-exploitation — that signal exists only in NVD and CISA KEV.*\n\n';
304
+ }
250
305
  for (const adv of results.results.github.advisories.slice(0, 20)) {
251
306
  markdown += `### ${adv.id}\n`;
252
307
  markdown += `- **Severity:** ${adv.severity}\n`;
@@ -277,8 +332,14 @@ export function createSecuritySearchTool(options: {
277
332
  markdown += '## Open Source Vulnerabilities (OSV)\n\n';
278
333
  if (results.results.osv.error !== undefined) {
279
334
  markdown += `[Error] ${results.results.osv.error}\n\n`;
280
- } else {
335
+ }
336
+ // Partial failure: render the data alongside the error (see NVD section).
337
+ if (results.results.osv.error === undefined || results.results.osv.vulnerabilities.length > 0) {
281
338
  markdown += `Found: ${results.results.osv.count} vulnerabilities\n\n`;
339
+ if (p.includeExploited === true) {
340
+ // Same caveat as GitHub: OSV carries no exploitation signal.
341
+ markdown += '*Note: OSV results are not filtered by known-exploitation — that signal exists only in NVD and CISA KEV.*\n\n';
342
+ }
282
343
  for (const vuln of results.results.osv.vulnerabilities.slice(0, 20)) {
283
344
  markdown += `### ${vuln.id}\n`;
284
345
  markdown += `- **Severity:** ${vuln.severity}\n`;
@@ -95,17 +95,33 @@ export interface HealthMonitor {
95
95
 
96
96
  export function createHealthMonitor(): HealthMonitor {
97
97
  let timer: NodeJS.Timeout | null = null;
98
+ // Critical components already warned this run, so a persistently failing check
99
+ // logs once rather than on every 30s tick.
100
+ const warnedCritical = new Set<string>();
98
101
 
99
102
  const start = () => {
100
103
  if (timer) return;
101
-
104
+ warnedCritical.clear();
105
+
106
+ // Note: the registry API (runAll) only supports { force } — there is no surface
107
+ // for selecting/skipping individual checks, so each tick runs the full set
108
+ // (including the real BrowserRuntime page-load probe when the pool is active).
102
109
  timer = setInterval(async () => {
103
110
  try {
104
111
  const health = await healthRegistry.runAll();
105
- const failedNonCritical = health.components.filter(c => !c.healthy && !healthRegistry.isCritical(c.component));
112
+ const failed = health.components.filter(c => !c.healthy);
113
+ const failedNonCritical = failed.filter(c => !healthRegistry.isCritical(c.component));
106
114
  if (failedNonCritical.length > 0) {
107
115
  logger.warn(`[research] Periodic health check: non-critical components degraded: ${failedNonCritical.map(c => c.component).join(', ')}`);
108
116
  }
117
+ // Critical failures are worth surfacing too — the run itself keeps going
118
+ // (per-task timeouts bound the real work), but silently burning probes while
119
+ // a critical component is down left the monitor with no output at all.
120
+ for (const c of failed.filter(f => healthRegistry.isCritical(f.component))) {
121
+ if (warnedCritical.has(c.component)) continue;
122
+ warnedCritical.add(c.component);
123
+ logger.warn(`[research] Periodic health check: critical component unhealthy mid-run: ${c.component}${c.error ? ` (${c.error})` : ''}`);
124
+ }
109
125
  } catch (error) {
110
126
  logger.debug('[research] Periodic health check failed (non-blocking):', error);
111
127
  }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Atomic file replacement, with the Windows caveat handled honestly.
3
+ *
4
+ * The temp-file + fsync + rename dance exists so a reader never observes a partial
5
+ * file and a crash never leaves one: rename is atomic, so the destination is either
6
+ * entirely the old content or entirely the new.
7
+ *
8
+ * On Windows that guarantee is easy to lose. The long-standing comment at these call
9
+ * sites — "fs.rename fails on Windows if target exists (NTFS)" — is not true: Node
10
+ * calls `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING`, so replacing an existing file
11
+ * is fine. What actually fails is a rename whose TARGET is held open by another
12
+ * process without delete sharing (an AV scanner mid-scan, a backup agent), which
13
+ * surfaces as EPERM/EBUSY/EACCES.
14
+ *
15
+ * The old remedy — fall straight to `copyFile` — traded a transient, retryable error
16
+ * for a permanent hazard: `copyFile` truncates the destination and rewrites it in
17
+ * place, so a concurrent reader or a crash mid-copy sees a torn or zero-length file.
18
+ * For `state.json` (leader election, server auth secrets) and `config.env` (API keys)
19
+ * that is a much worse outcome than the error it was avoiding.
20
+ *
21
+ * So: retry the rename first. An AV lock clears in tens of milliseconds, and a retried
22
+ * rename stays atomic. Only a lock that outlives every retry falls back to copying,
23
+ * which is reported to the caller so it can be logged as the degraded path it is.
24
+ */
25
+
26
+ import * as fs from 'node:fs/promises';
27
+ import * as fsSync from 'node:fs';
28
+
29
+ /** Transient Windows sharing/locking failures — worth retrying, unlike ENOENT/ENOSPC. */
30
+ const RETRYABLE_CODES = new Set(['EPERM', 'EBUSY', 'EACCES']);
31
+
32
+ const MAX_RENAME_ATTEMPTS = 5;
33
+ const RENAME_RETRY_DELAY_MS = 25;
34
+
35
+ export type ReplaceOutcome = 'renamed' | 'copied';
36
+
37
+ function isRetryable(err: unknown): boolean {
38
+ const code = (err as NodeJS.ErrnoException | undefined)?.code;
39
+ return code !== undefined && RETRYABLE_CODES.has(code);
40
+ }
41
+
42
+ /**
43
+ * Replace `destPath` with `tmpPath` atomically where the platform allows it.
44
+ *
45
+ * Returns `'renamed'` for the atomic path and `'copied'` for the degraded Windows
46
+ * fallback. Rethrows on every non-Windows failure, and on Windows failures that are
47
+ * not lock-related.
48
+ */
49
+ export async function replaceFile(tmpPath: string, destPath: string): Promise<ReplaceOutcome> {
50
+ let lastError: unknown;
51
+ for (let attempt = 0; attempt < MAX_RENAME_ATTEMPTS; attempt++) {
52
+ try {
53
+ await fs.rename(tmpPath, destPath);
54
+ return 'renamed';
55
+ } catch (err) {
56
+ lastError = err;
57
+ // Only a Windows sharing conflict is worth another try; anything else (and any
58
+ // failure on a POSIX platform, where rename-over-existing always works) is real.
59
+ if (process.platform !== 'win32' || !isRetryable(err)) throw err;
60
+ if (attempt < MAX_RENAME_ATTEMPTS - 1) {
61
+ await new Promise((r) => setTimeout(r, RENAME_RETRY_DELAY_MS * (attempt + 1)));
62
+ }
63
+ }
64
+ }
65
+
66
+ // Every retry lost to the same lock. Copying is not atomic — a reader can catch the
67
+ // destination mid-write — but it is the only remaining way to persist the data.
68
+ try {
69
+ await fs.copyFile(tmpPath, destPath);
70
+ return 'copied';
71
+ } catch {
72
+ throw lastError;
73
+ }
74
+ }
75
+
76
+ /** Synchronous {@link replaceFile}, for the config writer, which is sync throughout. */
77
+ export function replaceFileSync(tmpPath: string, destPath: string): ReplaceOutcome {
78
+ let lastError: unknown;
79
+ for (let attempt = 0; attempt < MAX_RENAME_ATTEMPTS; attempt++) {
80
+ try {
81
+ fsSync.renameSync(tmpPath, destPath);
82
+ return 'renamed';
83
+ } catch (err) {
84
+ lastError = err;
85
+ if (process.platform !== 'win32' || !isRetryable(err)) throw err;
86
+ if (attempt < MAX_RENAME_ATTEMPTS - 1) {
87
+ // Sync busy-wait: this path is rare, bounded (~250ms worst case) and the
88
+ // caller is already blocking on a synchronous write.
89
+ const until = Date.now() + RENAME_RETRY_DELAY_MS * (attempt + 1);
90
+ while (Date.now() < until) { /* spin */ }
91
+ }
92
+ }
93
+ }
94
+
95
+ try {
96
+ fsSync.copyFileSync(tmpPath, destPath);
97
+ return 'copied';
98
+ } catch {
99
+ throw lastError;
100
+ }
101
+ }
@@ -71,7 +71,7 @@ export class CircuitBreaker {
71
71
  const result = await action();
72
72
  const duration = Date.now() - startTime;
73
73
  metrics.observe('circuit_breaker_call_duration_ms', duration, { breaker: this.options.name, status: 'success' });
74
- this.onSuccess();
74
+ this.onSuccess(admittedAsProbe);
75
75
  return result;
76
76
  } catch (error) {
77
77
  const duration = Date.now() - startTime;
@@ -105,9 +105,16 @@ export class CircuitBreaker {
105
105
  }
106
106
  }
107
107
 
108
- private onSuccess() {
108
+ private onSuccess(admittedAsProbe: boolean) {
109
109
  metrics.increment('circuit_breaker_success_total', 1, { breaker: this.options.name, state: this.state });
110
110
  if (this.state === 'HALF_OPEN') {
111
+ // Only a call admitted through the HALF_OPEN gate is evidence of recovery.
112
+ // A call admitted while CLOSED that merely settles after the breaker tripped
113
+ // and re-half-opened carries PRE-outage evidence — counting it would close
114
+ // the circuit while the real probe is still in flight against a dependency
115
+ // that may still be down. Its success is simply not counted; the in-flight
116
+ // probe's own outcome decides the transition.
117
+ if (!admittedAsProbe) return;
111
118
  this.successCount++;
112
119
  if (this.successCount >= this.options.halfOpenMaxCalls) {
113
120
  this.transitionTo('CLOSED');
@@ -16,7 +16,9 @@ export interface GlobalCitation extends Citation {
16
16
  * @param reports - Map of researcher ID to report content
17
17
  * @returns Object containing normalized reports and the global citation list
18
18
  */
19
- export function normalizeCitations(reports: Map<string, string>): {
19
+ // ReadonlyMap: this only reads `reports` (it returns a NEW normalizedReports map),
20
+ // and callers now pass a read-only view to avoid create-on-read side effects.
21
+ export function normalizeCitations(reports: ReadonlyMap<string, string>): {
20
22
  normalizedReports: Map<string, string>;
21
23
  globalCitations: GlobalCitation[];
22
24
  } {
@@ -36,12 +36,17 @@ export class DiskSpaceChecker {
36
36
  if (availableBytes < this.MIN_DISK_SPACE_BYTES) {
37
37
  this.hasDiskSpace = false;
38
38
  // Write directly to stderr — this check runs inside the logger itself,
39
- // so using the logger here would create a circular dependency.
40
- process.stderr.write(
41
- `[pi-research] Insufficient disk space for logging: ` +
42
- `${Math.round(availableBytes / 1024 / 1024)}MB available, ` +
43
- `minimum ${this.MIN_DISK_SPACE_BYTES / 1024 / 1024}MB required\n`
44
- );
39
+ // so using the logger here would create a circular dependency. Guarded:
40
+ // when the parent has already closed the pipe, a bare write throws
41
+ // EPIPE/ERR_STREAM_DESTROYED and (with no global uncaughtException
42
+ // handler in the main process) would crash the run over a diagnostic.
43
+ try {
44
+ process.stderr.write(
45
+ `[pi-research] Insufficient disk space for logging: ` +
46
+ `${Math.round(availableBytes / 1024 / 1024)}MB available, ` +
47
+ `minimum ${this.MIN_DISK_SPACE_BYTES / 1024 / 1024}MB required\n`
48
+ );
49
+ } catch { /* stderr gone — the flag above already halts log writes */ }
45
50
  } else {
46
51
  this.hasDiskSpace = true;
47
52
  }