@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.
- package/dist/cli.mjs +1334 -750
- package/dist/thread-worker.mjs +194 -136
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CHANGELOG.md +129 -16
- package/package.json +1 -1
- package/scripts/cleanup.cjs +64 -2
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cleanup/research-cleanup.ts +12 -4
- package/src/cli.ts +228 -70
- package/src/config.ts +78 -2
- package/src/core/interfaces/health-check-interfaces.ts +3 -39
- package/src/core/interfaces/planning-interfaces.ts +8 -0
- package/src/core/interfaces/service-names.ts +0 -1
- package/src/core/interfaces/state-manager-interfaces.ts +10 -2
- package/src/core/llm/agentic-repair.ts +14 -0
- package/src/core/planning-service.ts +43 -1
- package/src/core/service-initialization.ts +6 -14
- package/src/core/service-interfaces.ts +1 -1
- package/src/healthcheck/index.ts +7 -2
- package/src/infrastructure/browser/browser-client.ts +64 -9
- package/src/infrastructure/browser/browser-server.ts +24 -7
- package/src/infrastructure/browser/browser-task-scheduler.ts +58 -12
- package/src/infrastructure/browser/cleanup-utils.ts +50 -2
- package/src/infrastructure/browser/client-agent.ts +6 -6
- package/src/infrastructure/browser/config.ts +14 -3
- package/src/infrastructure/browser/scheduler-factory.ts +18 -3
- package/src/infrastructure/browser/thread-worker-messaging.ts +54 -2
- package/src/infrastructure/browser/thread-worker-types.ts +3 -1
- package/src/infrastructure/browser/thread-worker.mjs +194 -136
- package/src/infrastructure/browser/worker-pool-manager.ts +7 -2
- package/src/infrastructure/embedding/embedding-factory.ts +35 -6
- package/src/infrastructure/embedding/embedding-server.ts +40 -7
- package/src/infrastructure/file-lock-service.ts +72 -2
- package/src/infrastructure/knowledge-store-service.ts +48 -3
- package/src/infrastructure/process-lifecycle-service.ts +28 -5
- package/src/infrastructure/research-run-semaphore.ts +37 -3
- package/src/infrastructure/state/state-browser-api.ts +5 -2
- package/src/infrastructure/state/state-browser-manager.ts +15 -1
- package/src/infrastructure/state/state-manager-service.ts +4 -4
- package/src/infrastructure/state/state-manager.ts +15 -3
- package/src/knowledge/chunker.ts +88 -1
- package/src/knowledge/embedder.ts +136 -43
- package/src/knowledge/store.ts +66 -12
- package/src/logger.ts +5 -1
- package/src/observers/research-observer-impl.ts +31 -0
- package/src/orchestration/abort-utils.ts +60 -0
- package/src/orchestration/deep-research-orchestrator.ts +12 -2
- package/src/orchestration/headless-observer.ts +8 -0
- package/src/orchestration/research-orchestration-service.ts +63 -11
- package/src/orchestration/research-session-service.ts +37 -11
- package/src/orchestration/researcher-executor.ts +100 -20
- package/src/orchestration/researcher.ts +39 -20
- package/src/orchestration/session-state.ts +11 -0
- package/src/sdk.ts +125 -19
- package/src/security/index.ts +51 -30
- package/src/security/nvd-client.ts +7 -2
- package/src/skill-install/skill-installer.ts +18 -2
- package/src/stackexchange/index.ts +20 -5
- package/src/stackexchange/output/table.ts +7 -2
- package/src/stackexchange/rest-client.ts +35 -1
- package/src/tools/research-tool-definition.ts +42 -8
- package/src/tools/scrape.ts +48 -25
- package/src/tools/search.ts +10 -1
- package/src/tools/security.ts +68 -7
- package/src/tui/research-health.ts +18 -2
- package/src/utils/circuit-breaker.ts +9 -2
- package/src/utils/disk-space-checker.ts +11 -6
- package/src/utils/json-utils.ts +116 -76
- package/src/utils/llm-usage.ts +17 -1
- package/src/utils/log-rotation.ts +42 -10
- package/src/utils/log-utils.ts +40 -0
- package/src/utils/metrics-summary.ts +27 -9
- package/src/utils/stdio-capture.ts +63 -22
- package/src/utils/text-utils.ts +8 -1
- package/src/utils/tool-usage-tracker.ts +9 -2
- package/src/web-research/browser-search.ts +26 -7
- package/src/web-research/scraper-utils.ts +13 -0
- package/src/web-research/search.ts +6 -2
- package/src/web-research/types.ts +4 -2
- package/src/web-research/web-scraper.ts +65 -4
- package/src/core/health-check-service.ts +0 -265
package/src/tools/scrape.ts
CHANGED
|
@@ -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 +
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
},
|
package/src/tools/search.ts
CHANGED
|
@@ -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);
|
package/src/tools/security.ts
CHANGED
|
@@ -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
|
-
:
|
|
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
|
|
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
|
-
}
|
|
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
|
-
}
|
|
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
|
-
}
|
|
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
|
-
}
|
|
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
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
}
|
package/src/utils/json-utils.ts
CHANGED
|
@@ -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
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
*
|
|
184
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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: '
|
|
260
|
+
error: 'No matching closing bracket found and local repair failed',
|
|
239
261
|
};
|
|
240
262
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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:
|
|
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
|
/**
|
package/src/utils/llm-usage.ts
CHANGED
|
@@ -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
|
|
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
|
}
|