@lincoln504/pi-research 1.2.0 → 1.3.1

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 (81) hide show
  1. package/dist/cli.mjs +1334 -750
  2. package/dist/thread-worker.mjs +194 -136
  3. package/docs/ARCHITECTURE.md +1 -1
  4. package/docs/CHANGELOG.md +129 -16
  5. package/package.json +1 -1
  6. package/scripts/cleanup.cjs +64 -2
  7. package/skills/pi-research/SKILL.md +1 -1
  8. package/src/cleanup/research-cleanup.ts +12 -4
  9. package/src/cli.ts +228 -70
  10. package/src/config.ts +78 -2
  11. package/src/core/interfaces/health-check-interfaces.ts +3 -39
  12. package/src/core/interfaces/planning-interfaces.ts +8 -0
  13. package/src/core/interfaces/service-names.ts +0 -1
  14. package/src/core/interfaces/state-manager-interfaces.ts +10 -2
  15. package/src/core/llm/agentic-repair.ts +14 -0
  16. package/src/core/planning-service.ts +43 -1
  17. package/src/core/service-initialization.ts +6 -14
  18. package/src/core/service-interfaces.ts +1 -1
  19. package/src/healthcheck/index.ts +7 -2
  20. package/src/infrastructure/browser/browser-client.ts +64 -9
  21. package/src/infrastructure/browser/browser-server.ts +24 -7
  22. package/src/infrastructure/browser/browser-task-scheduler.ts +58 -12
  23. package/src/infrastructure/browser/cleanup-utils.ts +50 -2
  24. package/src/infrastructure/browser/client-agent.ts +6 -6
  25. package/src/infrastructure/browser/config.ts +14 -3
  26. package/src/infrastructure/browser/scheduler-factory.ts +18 -3
  27. package/src/infrastructure/browser/thread-worker-messaging.ts +54 -2
  28. package/src/infrastructure/browser/thread-worker-types.ts +3 -1
  29. package/src/infrastructure/browser/thread-worker.mjs +194 -136
  30. package/src/infrastructure/browser/worker-pool-manager.ts +7 -2
  31. package/src/infrastructure/embedding/embedding-factory.ts +35 -6
  32. package/src/infrastructure/embedding/embedding-server.ts +40 -7
  33. package/src/infrastructure/file-lock-service.ts +72 -2
  34. package/src/infrastructure/knowledge-store-service.ts +48 -3
  35. package/src/infrastructure/process-lifecycle-service.ts +28 -5
  36. package/src/infrastructure/research-run-semaphore.ts +37 -3
  37. package/src/infrastructure/state/state-browser-api.ts +5 -2
  38. package/src/infrastructure/state/state-browser-manager.ts +15 -1
  39. package/src/infrastructure/state/state-manager-service.ts +4 -4
  40. package/src/infrastructure/state/state-manager.ts +15 -3
  41. package/src/knowledge/chunker.ts +88 -1
  42. package/src/knowledge/embedder.ts +136 -43
  43. package/src/knowledge/store.ts +66 -12
  44. package/src/logger.ts +5 -1
  45. package/src/observers/research-observer-impl.ts +31 -0
  46. package/src/orchestration/abort-utils.ts +60 -0
  47. package/src/orchestration/deep-research-orchestrator.ts +12 -2
  48. package/src/orchestration/headless-observer.ts +8 -0
  49. package/src/orchestration/research-orchestration-service.ts +63 -11
  50. package/src/orchestration/research-session-service.ts +37 -11
  51. package/src/orchestration/researcher-executor.ts +100 -20
  52. package/src/orchestration/researcher.ts +39 -20
  53. package/src/orchestration/session-state.ts +11 -0
  54. package/src/sdk.ts +125 -19
  55. package/src/security/index.ts +51 -30
  56. package/src/security/nvd-client.ts +7 -2
  57. package/src/skill-install/skill-installer.ts +18 -2
  58. package/src/stackexchange/index.ts +20 -5
  59. package/src/stackexchange/output/table.ts +7 -2
  60. package/src/stackexchange/rest-client.ts +35 -1
  61. package/src/tools/research-tool-definition.ts +42 -8
  62. package/src/tools/scrape.ts +48 -25
  63. package/src/tools/search.ts +10 -1
  64. package/src/tools/security.ts +68 -7
  65. package/src/tui/research-health.ts +18 -2
  66. package/src/utils/circuit-breaker.ts +9 -2
  67. package/src/utils/disk-space-checker.ts +11 -6
  68. package/src/utils/json-utils.ts +116 -76
  69. package/src/utils/llm-usage.ts +17 -1
  70. package/src/utils/log-rotation.ts +42 -10
  71. package/src/utils/log-utils.ts +40 -0
  72. package/src/utils/metrics-summary.ts +27 -9
  73. package/src/utils/stdio-capture.ts +63 -22
  74. package/src/utils/text-utils.ts +8 -1
  75. package/src/utils/tool-usage-tracker.ts +9 -2
  76. package/src/web-research/browser-search.ts +26 -7
  77. package/src/web-research/scraper-utils.ts +13 -0
  78. package/src/web-research/search.ts +6 -2
  79. package/src/web-research/types.ts +4 -2
  80. package/src/web-research/web-scraper.ts +65 -4
  81. package/src/core/health-check-service.ts +0 -265
@@ -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);
@@ -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
  }
@@ -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');
@@ -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
  }
@@ -96,6 +96,15 @@ function preRepairJson(jsonStr: string): string {
96
96
  .replace(/,\s*([}\]])/g, '$1');
97
97
  }
98
98
 
99
+ /**
100
+ * Upper bound on the forward candidate scan in extractJsonObject/extractJsonArray.
101
+ * Prose braces ("Fill {placeholder} …") or citation brackets ("[see note] …") can
102
+ * precede the real payload, so a closed-but-unparseable candidate must not end the
103
+ * search — but brace-dense non-JSON text must not turn extraction into a quadratic
104
+ * walk either, hence the cap.
105
+ */
106
+ const MAX_JSON_CANDIDATES = 8;
107
+
99
108
  /**
100
109
  * Parse a JSON slice, trying it verbatim first and only running preRepairJson on
101
110
  * failure. preRepairJson rewrites smart quotes and trailing commas even inside
@@ -114,9 +123,12 @@ function parseRawThenRepair(jsonStr: string): unknown {
114
123
  /**
115
124
  * Extract JSON object from raw text
116
125
  *
117
- * Finds the first `{` then walks forward with depth-tracking (respecting string
118
- * literals) to locate its matching `}`. This correctly handles text that contains
119
- * multiple JSON objects or has trailing content after the object.
126
+ * Walks forward with depth-tracking (respecting string literals) from each `{`
127
+ * candidate to its matching `}`. A candidate that closes but fails to parse is a
128
+ * prose brace (e.g. "Fill {placeholder} like this: {…}"), so the scan advances to
129
+ * the next `{` — bounded by MAX_JSON_CANDIDATES. Truncated-repair applies at the
130
+ * first UNCLOSED candidate: past that point there is no further text to scan, and
131
+ * an unclosed opener is what genuinely cut-off LLM output looks like.
120
132
  *
121
133
  * @param text - Text to search for JSON object
122
134
  * @returns Extraction result with parsed value or error
@@ -124,7 +136,7 @@ function parseRawThenRepair(jsonStr: string): unknown {
124
136
  export function extractJsonObject<T = unknown>(
125
137
  text: string
126
138
  ): JsonExtractionResult<T> {
127
- const objStart = text.indexOf('{');
139
+ let objStart = text.indexOf('{');
128
140
 
129
141
  if (objStart === -1) {
130
142
  return {
@@ -134,54 +146,67 @@ export function extractJsonObject<T = unknown>(
134
146
  };
135
147
  }
136
148
 
137
- const { index: objEnd, inString } = findMatchingBracket(text, objStart);
149
+ let lastParseError = 'unknown parse error';
150
+ for (
151
+ let attempt = 0;
152
+ attempt < MAX_JSON_CANDIDATES && objStart !== -1;
153
+ attempt++, objStart = text.indexOf('{', objStart + 1)
154
+ ) {
155
+ const { index: objEnd, inString } = findMatchingBracket(text, objStart);
138
156
 
139
- if (objEnd === -1) {
140
- // Attempt local repair for truncated JSON
141
- logger.debug('[json-utils] JSON object truncated; attempting local repair');
142
- let partialText = text.slice(objStart);
143
-
144
- // If we were inside a string, we MUST close it first
145
- if (inString) {
146
- partialText += '"';
147
- }
157
+ if (objEnd === -1) {
158
+ // Attempt local repair for truncated JSON
159
+ logger.debug('[json-utils] JSON object truncated; attempting local repair');
160
+ let partialText = text.slice(objStart);
148
161
 
149
- for (let i = 1; i <= 15; i++) {
150
- try {
151
- const candidate = preRepairJson(partialText + '}'.repeat(i));
152
- const parsed = JSON.parse(candidate);
153
- logger.debug(`[json-utils] JSON object truncated; salvaged by appending ${inString ? 'quote and ' : ''}${i} closing braces`);
154
- return { success: true, value: parsed as T, method: 'raw-object' };
155
- } catch {
156
- continue;
162
+ // If we were inside a string, we MUST close it first
163
+ if (inString) {
164
+ partialText += '"';
165
+ }
166
+
167
+ for (let i = 1; i <= 15; i++) {
168
+ try {
169
+ const candidate = preRepairJson(partialText + '}'.repeat(i));
170
+ const parsed = JSON.parse(candidate);
171
+ logger.debug(`[json-utils] JSON object truncated; salvaged by appending ${inString ? 'quote and ' : ''}${i} closing braces`);
172
+ return { success: true, value: parsed as T, method: 'raw-object' };
173
+ } catch {
174
+ continue;
175
+ }
157
176
  }
177
+
178
+ return {
179
+ success: false,
180
+ value: undefined,
181
+ error: 'No matching closing brace found and local repair failed',
182
+ };
158
183
  }
159
-
160
- return {
161
- success: false,
162
- value: undefined,
163
- error: 'No matching closing brace found and local repair failed',
164
- };
165
- }
166
184
 
167
- try {
168
- const parsed = parseRawThenRepair(text.slice(objStart, objEnd + 1));
169
- return { success: true, value: parsed as T, method: 'raw-object' };
170
- } catch (err) {
171
- const errorMsg = err instanceof Error ? err.message : String(err);
172
- return {
173
- success: false,
174
- value: undefined,
175
- error: `Failed to parse JSON object: ${errorMsg}`,
176
- };
185
+ try {
186
+ const parsed = parseRawThenRepair(text.slice(objStart, objEnd + 1));
187
+ return { success: true, value: parsed as T, method: 'raw-object' };
188
+ } catch (err) {
189
+ // Closed but unparseable — a prose brace, not the payload. Try the next `{`.
190
+ lastParseError = err instanceof Error ? err.message : String(err);
191
+ }
177
192
  }
193
+
194
+ return {
195
+ success: false,
196
+ value: undefined,
197
+ error: `Failed to parse JSON object: ${lastParseError}`,
198
+ };
178
199
  }
179
200
 
180
201
  /**
181
202
  * Extract JSON array from raw text
182
203
  *
183
- * Looks for a JSON array spanning from the first `[` to the last `]`.
184
- * Useful for extracting arrays of strings or objects.
204
+ * Walks forward with depth-tracking (respecting string literals) from each `[`
205
+ * candidate to its matching `]`. A candidate that closes but fails to parse is a
206
+ * citation-style bracket (e.g. "[see note] … [\"a\",\"b\"]"), so the scan advances
207
+ * to the next `[` — bounded by MAX_JSON_CANDIDATES. Truncated-repair applies at
208
+ * the first UNCLOSED candidate: past that point there is no further text to scan,
209
+ * and an unclosed opener is what genuinely cut-off LLM output looks like.
185
210
  *
186
211
  * @param text - Text to search for JSON array
187
212
  * @returns Extraction result with parsed value or error
@@ -189,7 +214,7 @@ export function extractJsonObject<T = unknown>(
189
214
  export function extractJsonArray<T = unknown>(
190
215
  text: string
191
216
  ): JsonExtractionResult<T[]> {
192
- const arrStart = text.indexOf('[');
217
+ let arrStart = text.indexOf('[');
193
218
 
194
219
  if (arrStart === -1) {
195
220
  return {
@@ -199,54 +224,69 @@ export function extractJsonArray<T = unknown>(
199
224
  };
200
225
  }
201
226
 
202
- const { index: arrEnd, inString } = findMatchingBracket(text, arrStart);
203
- if (arrEnd === -1) {
204
- // Attempt local repair for truncated JSON
205
- logger.debug('[json-utils] JSON array truncated; attempting local repair');
206
- let partialText = text.slice(arrStart);
207
-
208
- if (inString) {
209
- partialText += '"';
210
- }
227
+ let lastError = 'unknown parse error';
228
+ let lastErrorShape: 'not-array' | 'parse' = 'parse';
229
+ for (
230
+ let attempt = 0;
231
+ attempt < MAX_JSON_CANDIDATES && arrStart !== -1;
232
+ attempt++, arrStart = text.indexOf('[', arrStart + 1)
233
+ ) {
234
+ const { index: arrEnd, inString } = findMatchingBracket(text, arrStart);
235
+ if (arrEnd === -1) {
236
+ // Attempt local repair for truncated JSON
237
+ logger.debug('[json-utils] JSON array truncated; attempting local repair');
238
+ let partialText = text.slice(arrStart);
211
239
 
212
- for (let i = 1; i <= 15; i++) {
213
- try {
214
- const candidate = preRepairJson(partialText + ']'.repeat(i));
215
- const parsed = JSON.parse(candidate);
216
- if (Array.isArray(parsed)) {
217
- logger.debug(`[json-utils] JSON array truncated; salvaged by appending ${inString ? 'quote and ' : ''}${i} closing brackets`);
218
- return { success: true, value: parsed as T[], method: 'raw-array' };
219
- }
220
- } catch {
221
- continue;
240
+ if (inString) {
241
+ partialText += '"';
222
242
  }
223
- }
224
243
 
225
- return {
226
- success: false,
227
- value: undefined,
228
- error: 'No matching closing bracket found and local repair failed',
229
- };
230
- }
244
+ for (let i = 1; i <= 15; i++) {
245
+ try {
246
+ const candidate = preRepairJson(partialText + ']'.repeat(i));
247
+ const parsed = JSON.parse(candidate);
248
+ if (Array.isArray(parsed)) {
249
+ logger.debug(`[json-utils] JSON array truncated; salvaged by appending ${inString ? 'quote and ' : ''}${i} closing brackets`);
250
+ return { success: true, value: parsed as T[], method: 'raw-array' };
251
+ }
252
+ } catch {
253
+ continue;
254
+ }
255
+ }
231
256
 
232
- try {
233
- const parsed = parseRawThenRepair(text.slice(arrStart, arrEnd + 1));
234
- if (!Array.isArray(parsed)) {
235
257
  return {
236
258
  success: false,
237
259
  value: undefined,
238
- error: 'Parsed value is not an array',
260
+ error: 'No matching closing bracket found and local repair failed',
239
261
  };
240
262
  }
241
- return { success: true, value: parsed as T[], method: 'raw-array' };
242
- } catch (err) {
243
- const errorMsg = err instanceof Error ? err.message : String(err);
263
+
264
+ try {
265
+ const parsed = parseRawThenRepair(text.slice(arrStart, arrEnd + 1));
266
+ if (!Array.isArray(parsed)) {
267
+ lastErrorShape = 'not-array';
268
+ continue;
269
+ }
270
+ return { success: true, value: parsed as T[], method: 'raw-array' };
271
+ } catch (err) {
272
+ // Closed but unparseable — a citation bracket, not the payload. Try the next `[`.
273
+ lastErrorShape = 'parse';
274
+ lastError = err instanceof Error ? err.message : String(err);
275
+ }
276
+ }
277
+
278
+ if (lastErrorShape === 'not-array') {
244
279
  return {
245
280
  success: false,
246
281
  value: undefined,
247
- error: `Failed to parse JSON array: ${errorMsg}`,
282
+ error: 'Parsed value is not an array',
248
283
  };
249
284
  }
285
+ return {
286
+ success: false,
287
+ value: undefined,
288
+ error: `Failed to parse JSON array: ${lastError}`,
289
+ };
250
290
  }
251
291
 
252
292
  /**
@@ -51,11 +51,16 @@ function warnIfUnpriced(model: Model<any>, tokens: number, cost: number): void {
51
51
  );
52
52
  }
53
53
 
54
- /** Structural subset of the run observer — just the token sink. Kept structural (rather
54
+ /** Structural subset of the run observer — just the token sinks. Kept structural (rather
55
55
  * than importing ResearchObserver from core/) so this foundation-layer helper does not
56
56
  * depend on an upper layer; the full ResearchObserver satisfies it. */
57
57
  export interface TokenSink {
58
58
  onTokensConsumed?: (tokens: number, cost: number) => void;
59
+ /** Phase-scoped sinks, fed from the `component` label ('coordinator'/'evaluator');
60
+ * they drive the TUI coord/eval cost rows and the SDK planning_tokens /
61
+ * evaluation_tokens events. */
62
+ onPlanningTokens?: (tokens: number, cost: number) => void;
63
+ onEvaluationTokens?: (tokens: number, cost: number) => void;
59
64
  }
60
65
 
61
66
  export interface RecordUsageOptions {
@@ -86,6 +91,17 @@ export function recordLlmUsage(
86
91
  metrics.increment('llm_tokens_total', tokens, labels);
87
92
  metrics.increment('llm_cost_total', cost, labels);
88
93
  opts.observer?.onTokensConsumed?.(tokens, cost);
94
+ // Phase-scoped events: coordinator/evaluator call sites label their usage
95
+ // 'coordinator'/'evaluator' (see planning-service.ts); route those to the
96
+ // dedicated observer hooks so the TUI coord/eval cost rows and the SDK
97
+ // planning_tokens/evaluation_tokens events fire. Before this mapping the hooks
98
+ // had zero emit sites (regression in c90d7f37) — coordinator/evaluator usage
99
+ // reached only onTokensConsumed, which the TUI observer does not implement.
100
+ if (opts.component === 'coordinator') {
101
+ opts.observer?.onPlanningTokens?.(tokens, cost);
102
+ } else if (opts.component === 'evaluator') {
103
+ opts.observer?.onEvaluationTokens?.(tokens, cost);
104
+ }
89
105
  }
90
106
  return { tokens, cost };
91
107
  }