@lincoln504/pi-research 1.2.0 → 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.
- package/dist/cli.mjs +1334 -750
- package/dist/thread-worker.mjs +194 -136
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CHANGELOG.md +114 -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 +8 -2
- 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
|
@@ -27,8 +27,19 @@ export class LogRotation {
|
|
|
27
27
|
try {
|
|
28
28
|
if (fs.existsSync(logFile)) {
|
|
29
29
|
fs.unlinkSync(logFile);
|
|
30
|
+
// Same rationale as the post-rotation recreate in rotateLogFile: the
|
|
31
|
+
// unlink discarded the 0o600 file, and a umask-default recreate by the
|
|
32
|
+
// next append would drop the owner-only posture.
|
|
33
|
+
try {
|
|
34
|
+
fs.closeSync(fs.openSync(logFile, 'a', 0o600));
|
|
35
|
+
} catch { /* best-effort — the next append recreates the file */ }
|
|
36
|
+
// And, as there, re-stamp the mode: a concurrent append landing between
|
|
37
|
+
// the unlink and the open makes the creation-time 0o600 a no-op.
|
|
38
|
+
try {
|
|
39
|
+
fs.chmodSync(logFile, 0o600);
|
|
40
|
+
} catch { /* best-effort */ }
|
|
30
41
|
}
|
|
31
|
-
|
|
42
|
+
|
|
32
43
|
const files = fs.readdirSync(logDir);
|
|
33
44
|
const baseName = path.basename(logFile);
|
|
34
45
|
const archives = files.filter(f => f.startsWith(baseName) && f !== baseName);
|
|
@@ -63,19 +74,40 @@ export class LogRotation {
|
|
|
63
74
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
64
75
|
const archivePath = `${logFile}.${timestamp}`;
|
|
65
76
|
|
|
66
|
-
// Rename current log file to archive
|
|
77
|
+
// Rename current log file to archive. The archive name is timestamped, so
|
|
78
|
+
// the target never pre-exists — a rename failure means the SOURCE is held
|
|
79
|
+
// open (Windows sharing semantics). A copy+unlink fallback here would
|
|
80
|
+
// destroy appends the OTHER processes writing this consolidated log (main,
|
|
81
|
+
// embedding server, browser workers) land between the copy and the unlink.
|
|
82
|
+
// Skipping the rotation is the data-preserving choice: the file transiently
|
|
83
|
+
// exceeds the cap until a later check succeeds (accepted by the throttling
|
|
84
|
+
// contract in checkAndRotate).
|
|
67
85
|
try {
|
|
68
86
|
fs.renameSync(logFile, archivePath);
|
|
69
87
|
} catch (renameErr) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
fs.copyFileSync(logFile, archivePath);
|
|
73
|
-
fs.unlinkSync(logFile);
|
|
74
|
-
} else {
|
|
75
|
-
throw renameErr;
|
|
76
|
-
}
|
|
88
|
+
this.logger.log('[Logger] Log rotation skipped (rename failed; file likely in use):', renameErr);
|
|
89
|
+
return;
|
|
77
90
|
}
|
|
78
|
-
|
|
91
|
+
|
|
92
|
+
// Rotation moved the 0o600 file aside; without an explicit recreate the next
|
|
93
|
+
// append materializes it at the umask default (typically 0644) in a
|
|
94
|
+
// world-traversable tmpdir, silently dropping the owner-only posture Logger
|
|
95
|
+
// establishes at construction. Mode applies only at creation, so this is
|
|
96
|
+
// harmless where a file already exists or mode semantics differ (win32).
|
|
97
|
+
try {
|
|
98
|
+
fs.closeSync(fs.openSync(logFile, 'a', 0o600));
|
|
99
|
+
} catch { /* best-effort — the next append recreates the file */ }
|
|
100
|
+
// The mode above applies only if THIS open created the file. Several
|
|
101
|
+
// processes append to this consolidated log, so a concurrent async append
|
|
102
|
+
// can recreate it at umask perms between the rename and the open, turning
|
|
103
|
+
// the 0o600 into a no-op — stamp the mode unconditionally to close that
|
|
104
|
+
// window for good. ENOENT (nothing recreated it and the open failed) is
|
|
105
|
+
// tolerable: best-effort posture, same as the recreate itself.
|
|
106
|
+
try {
|
|
107
|
+
fs.chmodSync(logFile, 0o600);
|
|
108
|
+
} catch { /* best-effort */ }
|
|
109
|
+
|
|
110
|
+
|
|
79
111
|
// Clean up old archives
|
|
80
112
|
try {
|
|
81
113
|
const files = fs.readdirSync(logDir);
|
package/src/utils/log-utils.ts
CHANGED
|
@@ -86,6 +86,11 @@ const MAX_LOG_MESSAGE_LENGTH = 10_000;
|
|
|
86
86
|
const ANSI_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g;
|
|
87
87
|
// Credentials embedded in a URL userinfo component: scheme://user:pass@host
|
|
88
88
|
const URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]*:\/\/)[^/\s:@]+:[^/\s:@]+@/gi;
|
|
89
|
+
// Cookie / Set-Cookie headers. The KV value class below stops at whitespace, so
|
|
90
|
+
// "Cookie: lang=en; SID=secret123" would mask only the first pair and leave every
|
|
91
|
+
// later one exposed. A cookie string is a ;-and-space separated list of secrets,
|
|
92
|
+
// not a single token — mask the ENTIRE header value to end-of-line.
|
|
93
|
+
const COOKIE_HEADER_PATTERN = /\b(set[_-]?cookie|cookie)\b(["']?\s*[:=]\s*["']?)([^\r\n]+)/gi;
|
|
89
94
|
// key=value / key: "value" pairs whose key names a secret.
|
|
90
95
|
const SENSITIVE_KV_PATTERN =
|
|
91
96
|
/\b(api[_-]?key|apikey|access[_-]?token|auth[_-]?token|refresh[_-]?token|secret|client[_-]?secret|password|passwd|pwd|authorization|bearer|set[_-]?cookie|cookie|session[_-]?id|session|csrf[_-]?token|xsrf[_-]?token|private[_-]?key)\b(["']?\s*[:=]\s*["']?)([^\s"',&)]+)/gi;
|
|
@@ -137,6 +142,9 @@ export function redactSecrets(message: string): string {
|
|
|
137
142
|
// Before the KV pass, for the same reason as Basic above: the KV value class
|
|
138
143
|
// stops at the space after "Bearer", leaving an opaque token exposed.
|
|
139
144
|
out = out.replace(BEARER_AUTH_PATTERN, 'Bearer [REDACTED]');
|
|
145
|
+
// Whole-header cookie masking before the KV pass, which would otherwise stop
|
|
146
|
+
// at the first whitespace and leave every subsequent pair in the clear.
|
|
147
|
+
out = out.replace(COOKIE_HEADER_PATTERN, (_m, key: string, sep: string) => `${key}${sep}[REDACTED]`);
|
|
140
148
|
out = out.replace(SENSITIVE_KV_PATTERN, (_m, key: string, sep: string) => `${key}${sep}[REDACTED]`);
|
|
141
149
|
out = out.replace(KNOWN_TOKEN_PATTERN, '[REDACTED]');
|
|
142
150
|
out = out.replace(PROVIDER_TOKEN_PATTERN, '[REDACTED]');
|
|
@@ -144,6 +152,38 @@ export function redactSecrets(message: string): string {
|
|
|
144
152
|
return out;
|
|
145
153
|
}
|
|
146
154
|
|
|
155
|
+
// Every ESC-initiated terminal sequence — not just the colour/style CSI subset
|
|
156
|
+
// ANSI_PATTERN strips. Alternatives, tried in order at each ESC:
|
|
157
|
+
// 1. CSI with parameter (incl. private-mode `?`), intermediate, and final
|
|
158
|
+
// bytes per ECMA-48 — catches \x1b[?25l (hide cursor) and kin.
|
|
159
|
+
// 2. OSC to BEL or ST (or truncated) — catches \x1b]0;title\x07 retitling.
|
|
160
|
+
// 3. DCS/SOS/PM/APC to ST (or truncated).
|
|
161
|
+
// 4. Bare ESC plus at most one byte — the fallback that also swallows a
|
|
162
|
+
// trailing/truncated ESC rather than leaking it.
|
|
163
|
+
// (Built from strings, so no-control-regex has nothing to flag here.)
|
|
164
|
+
const TERMINAL_ESCAPE_PATTERN = new RegExp(
|
|
165
|
+
'\\x1b\\[[0-?]*[ -/]*[@-~]?' +
|
|
166
|
+
'|\\x1b\\][^\\x07\\x1b]*(?:\\x07|\\x1b\\\\)?' +
|
|
167
|
+
'|\\x1b[PX^_][^\\x1b]*(?:\\x1b\\\\)?' +
|
|
168
|
+
'|\\x1b.?',
|
|
169
|
+
'g',
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Strip terminal escape sequences and residual control bytes from a line bound
|
|
174
|
+
* for the CONSOLE sink. redactSecrets' ANSI_PATTERN removes only plain CSI
|
|
175
|
+
* colour/style sequences, so private-mode CSI (\x1b[?25l), OSC window-retitling
|
|
176
|
+
* (\x1b]0;…\x07), and DCS/SOS/PM/APC embedded in untrusted content would
|
|
177
|
+
* otherwise reach the user's terminal verbatim under PI_RESEARCH_CONSOLE_LOG.
|
|
178
|
+
* The console sink keeps its own explicit CR/LF→"" replace (the form CodeQL
|
|
179
|
+
* recognizes as the log-injection sanitizer); this handles everything else,
|
|
180
|
+
* spacing out any leftover C0/DEL byte (BEL, backspace, …) like
|
|
181
|
+
* neutralizeControlChars does.
|
|
182
|
+
*/
|
|
183
|
+
export function stripTerminalEscapes(message: string): string {
|
|
184
|
+
return message.replace(TERMINAL_ESCAPE_PATTERN, '').replace(CONTROL_CHARS_PATTERN, ' ');
|
|
185
|
+
}
|
|
186
|
+
|
|
147
187
|
/**
|
|
148
188
|
* Neutralize control characters (newlines, carriage returns, etc.) so that
|
|
149
189
|
* untrusted content cannot forge or corrupt a raw console log line
|
|
@@ -137,9 +137,17 @@ export function extractRunStats(snapshot: IMetricsSnapshot): ResearchStats | nul
|
|
|
137
137
|
const browserSearchQueries = sumCounter(counters, 'browser_search_queries_total');
|
|
138
138
|
const searchQueries = sumCounter(counters, 'search_queries_total') + browserSearchQueries;
|
|
139
139
|
|
|
140
|
-
// URLs discovered via search
|
|
141
|
-
|
|
142
|
-
|
|
140
|
+
// URLs discovered via search. browser_search_unique_urls_total is the
|
|
141
|
+
// cross-query deduped subset of the SAME batch counted by
|
|
142
|
+
// browser_search_results_total (raw per-query results), so summing both
|
|
143
|
+
// near-doubles the figure. The unique counter IS the discovered count; the
|
|
144
|
+
// raw counter is only a fallback for a search that threw before completing
|
|
145
|
+
// the batch (the unique count is emitted after cross-query dedup, so such a
|
|
146
|
+
// run never emits it).
|
|
147
|
+
const uniqueUrlsDiscovered = sumCounter(counters, 'browser_search_unique_urls_total');
|
|
148
|
+
const urlsDiscovered = uniqueUrlsDiscovered > 0
|
|
149
|
+
? uniqueUrlsDiscovered
|
|
150
|
+
: sumCounter(counters, 'browser_search_results_total');
|
|
143
151
|
|
|
144
152
|
// URLs scraped — outcomes from scrape_results_total
|
|
145
153
|
const fetchSuccess = getLabeledCounter(counters, 'scrape_results_total', { outcome: 'fetch_success' });
|
|
@@ -178,12 +186,22 @@ export function extractRunStats(snapshot: IMetricsSnapshot): ResearchStats | nul
|
|
|
178
186
|
// Cost — sum all label variants
|
|
179
187
|
const cost = sumCounter(counters, 'llm_cost_total');
|
|
180
188
|
|
|
181
|
-
// Duration from histogram
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
189
|
+
// Duration from histogram. The registry serializes label keys in ALPHABETICAL
|
|
190
|
+
// order (MetricsRegistry.serializeLabels), so the real key is
|
|
191
|
+
// `{complexity="…",mode="…",status="…"}` — a lookup that hardcodes any other
|
|
192
|
+
// label order can never match. Complexity is also emitted as String(complexity)
|
|
193
|
+
// with no fixed value set. Scan by metric-name prefix + success status instead,
|
|
194
|
+
// taking the largest observed max across matching label variants.
|
|
195
|
+
let durationMs = 0;
|
|
196
|
+
for (const key of Object.keys(histograms)) {
|
|
197
|
+
if (
|
|
198
|
+
key.startsWith('research_session_duration_ms{') &&
|
|
199
|
+
key.includes('status="success"')
|
|
200
|
+
) {
|
|
201
|
+
const max = histograms[key]?.max ?? 0;
|
|
202
|
+
if (max > durationMs) durationMs = max;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
187
205
|
|
|
188
206
|
// Rounds completed — evaluator runs give us the round count
|
|
189
207
|
const roundsCompleted = sumCounter(counters, 'evaluator_runs_total');
|
|
@@ -33,6 +33,26 @@ const sessionCaptureStates = new Map<string, boolean>();
|
|
|
33
33
|
let unkeyedCaptureDepth = 0;
|
|
34
34
|
let isAnyLoggerCapturingOutput = false;
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Active captures in creation order. Restoration MUST be LIFO: each capture saves
|
|
38
|
+
* the currently-installed writers/console/fs.writeSync (and the current FD-2
|
|
39
|
+
* target) as its "originals", so a frame that finishes while a later frame is
|
|
40
|
+
* still active must NOT restore — it would re-install the still-active frame's
|
|
41
|
+
* view of the world later, when that frame finally exits, re-pointing stderr,
|
|
42
|
+
* stdout, console.* and OS-level FD 2 at a dead capture's log-file plumbing for
|
|
43
|
+
* the remainder of the process. Keyed captures nest by design (a run's capture
|
|
44
|
+
* around the embedding server's), and nothing orders their exits — a cancelled
|
|
45
|
+
* run's outer capture routinely ends while the embedder-init capture is mid
|
|
46
|
+
* flight. So: an out-of-order finisher only marks its frame retired; the frame
|
|
47
|
+
* on top of the stack performs the actual restores, unwinding every already-
|
|
48
|
+
* retired frame beneath it in strict reverse order.
|
|
49
|
+
*/
|
|
50
|
+
interface CaptureFrame {
|
|
51
|
+
retired: boolean;
|
|
52
|
+
restore: () => void;
|
|
53
|
+
}
|
|
54
|
+
const captureStack: CaptureFrame[] = [];
|
|
55
|
+
|
|
36
56
|
/**
|
|
37
57
|
* Recompute the global "is anything capturing" flag from BOTH the keyed sessions and
|
|
38
58
|
* the unkeyed-capture depth, so neither scheme can clobber the other's state.
|
|
@@ -220,7 +240,9 @@ export async function captureStdio<T>(
|
|
|
220
240
|
if (shouldRedirectStderr(stat)) {
|
|
221
241
|
savedFd2 = fs.openSync('/dev/fd/2', 'a');
|
|
222
242
|
fs.closeSync(2);
|
|
223
|
-
|
|
243
|
+
// 0o600 applies only when this open CREATES the file — keeps the log
|
|
244
|
+
// owner-only in a world-traversable tmpdir if rotation removed it.
|
|
245
|
+
const newFd = fs.openSync(logFile, 'a', 0o600);
|
|
224
246
|
if (newFd === 2) {
|
|
225
247
|
fd2Redirected = true;
|
|
226
248
|
} else {
|
|
@@ -371,20 +393,41 @@ export async function captureStdio<T>(
|
|
|
371
393
|
}
|
|
372
394
|
} catch (_e) { /* ignore */ }
|
|
373
395
|
|
|
396
|
+
const frame: CaptureFrame = {
|
|
397
|
+
retired: false,
|
|
398
|
+
restore: () => {
|
|
399
|
+
process.stderr.write = originalStderrWrite;
|
|
400
|
+
process.stdout.write = originalStdoutWrite;
|
|
401
|
+
try {
|
|
402
|
+
fs.writeSync = originalFsWriteSync;
|
|
403
|
+
} catch { /* ignore */ }
|
|
404
|
+
console.log = originalConsole.log;
|
|
405
|
+
console.info = originalConsole.info;
|
|
406
|
+
console.warn = originalConsole.warn;
|
|
407
|
+
console.error = originalConsole.error;
|
|
408
|
+
console.debug = originalConsole.debug;
|
|
409
|
+
|
|
410
|
+
// Restore FD 2 to what it was when THIS frame started (for a nested frame
|
|
411
|
+
// that is the enclosing frame's log-file redirect; the enclosing frame's
|
|
412
|
+
// own restore, which always runs after this one in the unwind, then puts
|
|
413
|
+
// the true terminal FD back).
|
|
414
|
+
if (fd2Redirected && savedFd2 >= 0) {
|
|
415
|
+
try {
|
|
416
|
+
fs.closeSync(2);
|
|
417
|
+
const r = fs.openSync(`/dev/fd/${savedFd2}`, 'a');
|
|
418
|
+
if (r !== 2 && r >= 0) {
|
|
419
|
+
// Didn't recover FD 2 — leave restored FD open
|
|
420
|
+
}
|
|
421
|
+
try { fs.closeSync(savedFd2); } catch { /* ignore */ }
|
|
422
|
+
} catch { /* ignore restore errors */ }
|
|
423
|
+
}
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
captureStack.push(frame);
|
|
427
|
+
|
|
374
428
|
try {
|
|
375
429
|
return await task();
|
|
376
430
|
} finally {
|
|
377
|
-
// Restore everything
|
|
378
|
-
process.stderr.write = originalStderrWrite;
|
|
379
|
-
process.stdout.write = originalStdoutWrite;
|
|
380
|
-
try {
|
|
381
|
-
fs.writeSync = originalFsWriteSync;
|
|
382
|
-
} catch { /* ignore */ }
|
|
383
|
-
console.log = originalConsole.log;
|
|
384
|
-
console.info = originalConsole.info;
|
|
385
|
-
console.warn = originalConsole.warn;
|
|
386
|
-
console.error = originalConsole.error;
|
|
387
|
-
console.debug = originalConsole.debug;
|
|
388
431
|
if (sessionId) {
|
|
389
432
|
setSessionCapturing(sessionId, false);
|
|
390
433
|
} else {
|
|
@@ -392,16 +435,14 @@ export async function captureStdio<T>(
|
|
|
392
435
|
recomputeGlobalCapturing();
|
|
393
436
|
}
|
|
394
437
|
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
try { fs.closeSync(savedFd2); } catch { /* ignore */ }
|
|
404
|
-
} catch { /* ignore restore errors */ }
|
|
438
|
+
// LIFO unwind (see captureStack): only the top frame restores; an
|
|
439
|
+
// out-of-order finisher parks as retired and is unwound — in reverse
|
|
440
|
+
// order — when everything above it has finished. Until then its patches
|
|
441
|
+
// stay installed, which merely extends capture a little; restoring early
|
|
442
|
+
// would divert stdio permanently.
|
|
443
|
+
frame.retired = true;
|
|
444
|
+
while (captureStack.length > 0 && captureStack[captureStack.length - 1]!.retired) {
|
|
445
|
+
captureStack.pop()!.restore();
|
|
405
446
|
}
|
|
406
447
|
}
|
|
407
448
|
}
|
package/src/utils/text-utils.ts
CHANGED
|
@@ -76,7 +76,14 @@ export function truncateWithMarker(content: string, maxChars: number): string {
|
|
|
76
76
|
const makeMarker = (kept: number) => `\n\n[content truncated: showing ${kept} of ${total} chars]`;
|
|
77
77
|
// Reserve marker room using the longest possible marker (kept ≤ total, so the
|
|
78
78
|
// digit count of `kept` never exceeds that of `total`).
|
|
79
|
-
|
|
79
|
+
let keep = Math.max(0, maxChars - makeMarker(total).length);
|
|
80
|
+
// slice() counts UTF-16 code units, so the cut can land between the halves of a
|
|
81
|
+
// surrogate pair — a lone high surrogate in the body is invalid text that some
|
|
82
|
+
// providers reject (400) when it reaches a JSON payload. Backing off one unit
|
|
83
|
+
// restores well-formed text. The whitespace-boundary path below cannot split a
|
|
84
|
+
// pair: it cuts immediately before a BMP whitespace character.
|
|
85
|
+
const lastKeptUnit = content.charCodeAt(keep - 1);
|
|
86
|
+
if (lastKeptUnit >= 0xd800 && lastKeptUnit <= 0xdbff) keep -= 1;
|
|
80
87
|
let body = content.slice(0, keep);
|
|
81
88
|
// Prefer a clean boundary near the cut point.
|
|
82
89
|
const boundary = Math.max(body.lastIndexOf('\n'), body.lastIndexOf(' '));
|
|
@@ -125,13 +125,20 @@ export class ToolUsageTracker {
|
|
|
125
125
|
|
|
126
126
|
const toolLimit = this.limits[toolName as keyof ToolLimits];
|
|
127
127
|
const catLimit = usage.limit;
|
|
128
|
+
// Decide by which limit actually tripped, computed exactly as recordCall
|
|
129
|
+
// computes it: a first-ever `search` blocked by the shared gathering cap must
|
|
130
|
+
// NOT claim "you have already used your one search call" — the agent would
|
|
131
|
+
// wrongly believe it had searched. The per-tool wording is only correct when
|
|
132
|
+
// the per-tool counter itself is at its limit.
|
|
133
|
+
const toolCount = usage.toolCounts.get(toolName) || 0;
|
|
134
|
+
const toolLimitTripped = toolLimit !== undefined && toolCount >= toolLimit;
|
|
128
135
|
|
|
129
|
-
if (toolName === 'search' && toolLimit === 1) {
|
|
136
|
+
if (toolName === 'search' && toolLimit === 1 && toolLimitTripped) {
|
|
130
137
|
return `SEARCH LIMIT REACHED: You have already used your one search call. ` +
|
|
131
138
|
`Proceed to scraping: use the scrape tool for full deep-dives into your best search results.`;
|
|
132
139
|
}
|
|
133
140
|
|
|
134
|
-
if (toolName === 'youtube_transcript' && toolLimit === 1) {
|
|
141
|
+
if (toolName === 'youtube_transcript' && toolLimit === 1 && toolLimitTripped) {
|
|
135
142
|
return `YOUTUBE TRANSCRIPT LIMIT REACHED: You have already used your one youtube_transcript call. ` +
|
|
136
143
|
`Continue with your other gathering tools (search, scrape) and proceed to synthesis.`;
|
|
137
144
|
}
|
|
@@ -27,14 +27,22 @@ export async function performSearch(
|
|
|
27
27
|
onProgress?: (links: number) => void,
|
|
28
28
|
container: ServiceContainer = getServiceContainer(),
|
|
29
29
|
/**
|
|
30
|
-
* Optional sink for per-query FAILURES (timeout / worker error
|
|
31
|
-
* an out-param rather than folded into the return value
|
|
32
|
-
* map's `query -> results` shape is what every caller
|
|
33
|
-
* from this map produced zero results with no
|
|
34
|
-
* did come back empty. Without this the
|
|
35
|
-
* reports every empty query as a
|
|
30
|
+
* Optional sink for per-query FAILURES (timeout / worker error / cross-query
|
|
31
|
+
* dedup). Populated as an out-param rather than folded into the return value
|
|
32
|
+
* because the returned map's `query -> results` shape is what every caller
|
|
33
|
+
* consumes; a query absent from this map produced zero results with no
|
|
34
|
+
* failure, i.e. the search really did come back empty. Without this the
|
|
35
|
+
* caller cannot tell those apart and reports every empty query as a
|
|
36
|
+
* too-narrow query.
|
|
36
37
|
*/
|
|
37
38
|
failures?: Map<string, QueryFailure>,
|
|
39
|
+
/**
|
|
40
|
+
* Session/research id keying the per-session circuit breaker inside
|
|
41
|
+
* runWorkerSearch — mirroring the scrape path, which threads it via
|
|
42
|
+
* BrowserTask.sessionId. Callers without a session (undefined) share the
|
|
43
|
+
* global breaker, as before.
|
|
44
|
+
*/
|
|
45
|
+
sessionId?: string,
|
|
38
46
|
): Promise<Map<string, SearchResult[]>> {
|
|
39
47
|
const startTime = Date.now();
|
|
40
48
|
const resultMap = new Map<string, SearchResult[]>();
|
|
@@ -116,7 +124,7 @@ export async function performSearch(
|
|
|
116
124
|
? AbortSignal.any([signal, timeoutController.signal])
|
|
117
125
|
: timeoutController.signal;
|
|
118
126
|
|
|
119
|
-
const results = await runWorkerSearch(query, config, querySignal, 1,
|
|
127
|
+
const results = await runWorkerSearch(query, config, querySignal, 1, sessionId, container);
|
|
120
128
|
const queryDuration = Date.now() - queryStartTime;
|
|
121
129
|
metrics.observe('browser_search_query_duration_ms', queryDuration);
|
|
122
130
|
|
|
@@ -142,6 +150,17 @@ export async function performSearch(
|
|
|
142
150
|
uniqueResults.push(r);
|
|
143
151
|
}
|
|
144
152
|
}
|
|
153
|
+
if (uniqueResults.length === 0) {
|
|
154
|
+
// Every raw result deduplicated away against URLs earlier
|
|
155
|
+
// queries in this batch already returned. Without an entry
|
|
156
|
+
// here the query fell through to the empty_results default —
|
|
157
|
+
// "the query may be too narrow" — sending the researcher off
|
|
158
|
+
// rewriting a query that actually worked.
|
|
159
|
+
failures?.set(query, {
|
|
160
|
+
type: 'all_duplicates',
|
|
161
|
+
message: `All ${results.length} results for this query duplicated URLs already returned by earlier queries in this batch. The query succeeded but surfaced nothing new — do not rewrite it; try a different angle only if you still need additional sources.`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
145
164
|
resultMap.set(query, uniqueResults);
|
|
146
165
|
} else {
|
|
147
166
|
metrics.increment('browser_search_queries_total', 1, { status: 'no_results' });
|
|
@@ -343,6 +343,11 @@ function isPrivateIpv6(ip: string): boolean {
|
|
|
343
343
|
// Link-local (fe80::/10)
|
|
344
344
|
if (normalized.startsWith('fe8') || normalized.startsWith('fe9') ||
|
|
345
345
|
normalized.startsWith('fea') || normalized.startsWith('feb')) return true;
|
|
346
|
+
// Deprecated site-local (fec0::/10, RFC 3879, deprecated 2004). No OS
|
|
347
|
+
// auto-routes to it today, but it's still squarely "private/reserved"
|
|
348
|
+
// address space — the sibling range immediately above this one.
|
|
349
|
+
if (normalized.startsWith('fec') || normalized.startsWith('fed') ||
|
|
350
|
+
normalized.startsWith('fee') || normalized.startsWith('fef')) return true;
|
|
346
351
|
// IPv4-mapped ::ffff:x.x.x.x — check the embedded IPv4 part. The mapped tail
|
|
347
352
|
// can arrive dotted-decimal (::ffff:127.0.0.1) OR hex (::ffff:7f00:1) — Node's
|
|
348
353
|
// URL parser normalizes literals to the hex form — and the prefix can be
|
|
@@ -357,6 +362,14 @@ function isPrivateIpv6(ip: string): boolean {
|
|
|
357
362
|
// legitimate scrape target, so block the whole ranges rather than decode them.
|
|
358
363
|
if (normalized.startsWith('2002:')) return true;
|
|
359
364
|
if (/^2001:0{0,4}:/.test(normalized) || normalized.startsWith('2001::')) return true;
|
|
365
|
+
// NAT64 well-known prefix (64:ff9b::/96, RFC 6052) also tunnels an IPv4
|
|
366
|
+
// address, and unlike the transitional ranges above it's actively deployed
|
|
367
|
+
// today (464XLAT on IPv6-only mobile/enterprise networks). A host whose
|
|
368
|
+
// outbound path traverses a NAT64 gateway gets this transparently
|
|
369
|
+
// translated to a direct connection to the embedded IPv4 (e.g. the cloud
|
|
370
|
+
// metadata service) — block the whole prefix regardless of what it wraps,
|
|
371
|
+
// same rationale as the 6to4/Teredo ranges just above.
|
|
372
|
+
if (normalized.startsWith('64:ff9b:')) return true;
|
|
360
373
|
// All-zeros (unspecified address)
|
|
361
374
|
if (normalized === '::' || normalized === '0:0:0:0:0:0:0:0') return true;
|
|
362
375
|
return false;
|
|
@@ -23,6 +23,9 @@ import type { ServiceContainer } from '../core/service-registry.ts';
|
|
|
23
23
|
* @param config - Optional configuration override
|
|
24
24
|
* @param signal - Optional AbortSignal
|
|
25
25
|
* @param onProgress - Optional callback with cumulative link count across completed queries
|
|
26
|
+
* @param container - Service container for scheduler resolution
|
|
27
|
+
* @param sessionId - Research/session id keying the per-session circuit breaker
|
|
28
|
+
* (mirrors the scrape path); undefined callers share the global breaker
|
|
26
29
|
* @returns Promise<QueryResultWithError[]> - Array of search results with error information
|
|
27
30
|
*/
|
|
28
31
|
export async function search(
|
|
@@ -30,7 +33,8 @@ export async function search(
|
|
|
30
33
|
config?: Config,
|
|
31
34
|
signal?: AbortSignal,
|
|
32
35
|
onProgress?: (links: number) => void,
|
|
33
|
-
container: ServiceContainer = getServiceContainer()
|
|
36
|
+
container: ServiceContainer = getServiceContainer(),
|
|
37
|
+
sessionId?: string
|
|
34
38
|
): Promise<QueryResultWithError[]> {
|
|
35
39
|
if (queries.length === 0) return [];
|
|
36
40
|
|
|
@@ -42,7 +46,7 @@ export async function search(
|
|
|
42
46
|
// Per-query failures (timeout / dead worker) come back separately from the
|
|
43
47
|
// results so an empty result set can be attributed correctly below.
|
|
44
48
|
const failures = new Map<string, QueryFailure>();
|
|
45
|
-
const resultMap = await performSearch(queries, config, signal, onProgress, container, failures);
|
|
49
|
+
const resultMap = await performSearch(queries, config, signal, onProgress, container, failures, sessionId);
|
|
46
50
|
const searchDuration = Date.now() - searchStart;
|
|
47
51
|
metrics.observe('search_latency_ms', searchDuration);
|
|
48
52
|
|
|
@@ -24,10 +24,12 @@ export interface QueryResultWithError {
|
|
|
24
24
|
* at all about the query and should prompt a retry instead. Collapsing the two —
|
|
25
25
|
* reporting every zero-result query as "the query may be too narrow" — sent the
|
|
26
26
|
* agent off rewriting perfectly good queries whenever the browser pool was the
|
|
27
|
-
* thing that failed.
|
|
27
|
+
* thing that failed. `all_duplicates` is the third case: the query SUCCEEDED,
|
|
28
|
+
* but every result deduplicated away against URLs earlier queries in the batch
|
|
29
|
+
* already returned — coverage feedback, not query-quality feedback.
|
|
28
30
|
*/
|
|
29
31
|
export interface QueryFailure {
|
|
30
|
-
type: 'empty_results' | 'service_unavailable' | 'timeout' | 'network_error' | 'unknown';
|
|
32
|
+
type: 'empty_results' | 'all_duplicates' | 'service_unavailable' | 'timeout' | 'network_error' | 'unknown';
|
|
31
33
|
message: string;
|
|
32
34
|
}
|
|
33
35
|
|
|
@@ -80,7 +80,11 @@ async function extractPdfToMarkdown(bytes: Uint8Array): Promise<string> {
|
|
|
80
80
|
const sizeMB = Math.round(bytes.length / 1024 / 1024);
|
|
81
81
|
logger.warn(`[Scrapers] PDF too large (${sizeMB}MB, max 100MB), skipping extraction`);
|
|
82
82
|
metrics.increment('scrape_pdf_errors_total', 1, { error_type: 'size_exceeded' });
|
|
83
|
-
|
|
83
|
+
// THROW, never an in-band "*Error: ...*" string: a returned banner flowed
|
|
84
|
+
// into validateContent as if it were page content, and a verbose one
|
|
85
|
+
// (≥50 words / ≥200 chars) cleared the stub gate — the failure banner was
|
|
86
|
+
// then cached and cited as a successful scrape.
|
|
87
|
+
throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
const pdfExtractionStart = Date.now();
|
|
@@ -114,7 +118,9 @@ async function extractPdfToMarkdown(bytes: Uint8Array): Promise<string> {
|
|
|
114
118
|
contentType: 'pdf',
|
|
115
119
|
errorType: 'extraction_failed',
|
|
116
120
|
});
|
|
117
|
-
|
|
121
|
+
// See the size branch above: an extraction failure must FAIL the scrape,
|
|
122
|
+
// not travel in-band as pseudo-content.
|
|
123
|
+
throw new Error(`Could not extract content from PDF (${msg})`, { cause: e });
|
|
118
124
|
}
|
|
119
125
|
}
|
|
120
126
|
|
|
@@ -354,8 +360,30 @@ async function scrapeWithStealthBrowser(_url: string, config?: Config, signal?:
|
|
|
354
360
|
const result = await runBrowserTask<any>({ url: _url, sessionId }, 'scrape', config, signal, 1, container);
|
|
355
361
|
const browserDuration = Date.now() - browserStart;
|
|
356
362
|
|
|
357
|
-
|
|
358
|
-
|
|
363
|
+
// PDF bytes travel base64-encoded (bufferB64): the worker result crosses the
|
|
364
|
+
// cluster-IPC JSON hop (and, for followers, a second JSON hop through the
|
|
365
|
+
// leader's browser-server), which turns a raw Buffer into
|
|
366
|
+
// {type:'Buffer',data:[...]} — new Uint8Array(that) is ZERO bytes, so the
|
|
367
|
+
// extraction ran on nothing and its error string was cached as a "success".
|
|
368
|
+
// The JSON-roundtripped legacy shape is still decoded so a mixed-version
|
|
369
|
+
// leader/follower pair keeps working.
|
|
370
|
+
const legacyBuffer = result.buffer;
|
|
371
|
+
const pdfBytes: Buffer | null =
|
|
372
|
+
typeof result.bufferB64 === 'string'
|
|
373
|
+
? Buffer.from(result.bufferB64, 'base64')
|
|
374
|
+
: Array.isArray(legacyBuffer?.data)
|
|
375
|
+
? Buffer.from(legacyBuffer.data)
|
|
376
|
+
: legacyBuffer instanceof Uint8Array
|
|
377
|
+
? Buffer.from(legacyBuffer)
|
|
378
|
+
: null;
|
|
379
|
+
|
|
380
|
+
if (pdfBytes !== null) {
|
|
381
|
+
const markdown = await extractPdfToMarkdown(pdfBytes);
|
|
382
|
+
// Mirror the fetch layer's PDF branch: extraction failures THROW from
|
|
383
|
+
// extractPdfToMarkdown, and validateContent still fails a nominally
|
|
384
|
+
// successful extraction that produced no real content — neither may be
|
|
385
|
+
// recorded (and cached) as a success.
|
|
386
|
+
validateContent('', markdown, _url);
|
|
359
387
|
metrics.increment('scrape_operations_total', 1, { layer: 'playwright', content_type: 'pdf', status: 'success' });
|
|
360
388
|
metrics.observe('scrape_latency_ms', browserDuration, { layer: 'playwright', content_type: 'pdf', status: 'success' });
|
|
361
389
|
return { source: 'playwright', layer: 'playwright+camoufox', markdown };
|
|
@@ -386,6 +414,10 @@ async function scrapeWithStealthBrowser(_url: string, config?: Config, signal?:
|
|
|
386
414
|
metrics.observe('scrape_latency_ms', browserDuration, { layer: 'playwright', content_type: 'html', status: 'success' });
|
|
387
415
|
return { source: 'playwright', layer: 'playwright+camoufox', markdown };
|
|
388
416
|
} catch (error) {
|
|
417
|
+
// A caller cancellation is not a scrape failure: no layer error metrics and
|
|
418
|
+
// no tracker entry — the search path applies the same 'Aborted' special case
|
|
419
|
+
// end-to-end (see browser-search.ts). scrapeSingle classifies it above us.
|
|
420
|
+
if (signal?.aborted || (error instanceof Error && error.message === 'Aborted')) throw error;
|
|
389
421
|
const browserDuration = Date.now() - browserStart;
|
|
390
422
|
metrics.increment('scrape_operations_total', 1, { layer: 'playwright', status: 'error' });
|
|
391
423
|
metrics.observe('scrape_latency_ms', browserDuration, { layer: 'playwright', status: 'error' });
|
|
@@ -401,6 +433,15 @@ async function scrapeWithStealthBrowser(_url: string, config?: Config, signal?:
|
|
|
401
433
|
}
|
|
402
434
|
|
|
403
435
|
export async function scrapeSingle(url: string, signal?: AbortSignal, config?: Config, sessionId?: string, container: ServiceContainer = getServiceContainer()): Promise<ScrapeResult> {
|
|
436
|
+
// Cancellation short-circuit: after a user abort each remaining URL would
|
|
437
|
+
// otherwise still pay for SSRF DNS validation, a doomed fetch, and a browser
|
|
438
|
+
// fallback — all logged/counted as failures the user never had. 'Aborted' is
|
|
439
|
+
// the codebase's recognized cancellation signature (see browser-search.ts);
|
|
440
|
+
// deliberately no total_failure tally, no tracker entry, no ERROR log.
|
|
441
|
+
if (signal?.aborted) {
|
|
442
|
+
return { url, success: false, error: 'Aborted', markdown: '' };
|
|
443
|
+
}
|
|
444
|
+
|
|
404
445
|
// Guard against an array accidentally stringified and passed as a single url (JSON arrays start
|
|
405
446
|
// with '['). Do NOT reject brackets ANYWHERE: valid URLs legitimately contain them in query
|
|
406
447
|
// strings (e.g. ?ids[]=1) and IPv6 literals (http://[::1]/) — the old includes('[') check dropped
|
|
@@ -477,6 +518,13 @@ export async function scrapeSingle(url: string, signal?: AbortSignal, config?: C
|
|
|
477
518
|
|
|
478
519
|
return { ...res, url, success: true };
|
|
479
520
|
} catch (e1) {
|
|
521
|
+
// Cancelled mid-fetch: not a fetch-layer failure, and no reason to pay for
|
|
522
|
+
// the browser fallback — classify as the recognized cancellation (no ERROR
|
|
523
|
+
// log, no tracker entry, no total_failure tally).
|
|
524
|
+
if (signal?.aborted || (e1 instanceof Error && e1.message === 'Aborted')) {
|
|
525
|
+
logger.debug(`[Scrapers] Scrape cancelled for ${url}`);
|
|
526
|
+
return { url, success: false, error: 'Aborted', markdown: '' };
|
|
527
|
+
}
|
|
480
528
|
const fetchDuration = Date.now() - start;
|
|
481
529
|
logger.debug(`[Scrapers] fetch failed for ${url} in ${fetchDuration}ms: ${formatErrorWithCause(e1)}`);
|
|
482
530
|
// Tracked here rather than inside scrapeWithFetch, which is retried above —
|
|
@@ -499,6 +547,14 @@ export async function scrapeSingle(url: string, signal?: AbortSignal, config?: C
|
|
|
499
547
|
|
|
500
548
|
return { ...res, url, success: true };
|
|
501
549
|
} catch (e2) {
|
|
550
|
+
// See the fetch-layer catch above: a cancellation surfacing from the
|
|
551
|
+
// browser layer (runBrowserTask throws exactly 'Aborted' on a caller
|
|
552
|
+
// abort) is not a failure — it must not reach the ERROR log, the error
|
|
553
|
+
// tracker, or the total_failure tally.
|
|
554
|
+
if (signal?.aborted || (e2 instanceof Error && e2.message === 'Aborted')) {
|
|
555
|
+
logger.debug(`[Scrapers] Browser scrape cancelled for ${url}`);
|
|
556
|
+
return { url, success: false, error: 'Aborted', markdown: '' };
|
|
557
|
+
}
|
|
502
558
|
const totalDuration = Date.now() - start;
|
|
503
559
|
// A blocked/stubbed/HTTP-errored/slow/pool-draining URL is an expected
|
|
504
560
|
// scraping outcome, already surfaced to the user as "not scraped" (the
|
|
@@ -530,6 +586,11 @@ export async function scrape(urls: string[], maxConcurrency = 5, signal?: AbortS
|
|
|
530
586
|
|
|
531
587
|
const results: ScrapeResult[] = [];
|
|
532
588
|
for (let i = 0; i < urls.length; i += maxConcurrency) {
|
|
589
|
+
// Stop dispatching once the caller has cancelled; URLs already in flight
|
|
590
|
+
// settle via scrapeSingle's own cancellation classification. Without this,
|
|
591
|
+
// every remaining batch still ran its full fetch + browser-fallback cycle
|
|
592
|
+
// after a user cancel.
|
|
593
|
+
if (signal?.aborted) break;
|
|
533
594
|
const batch = urls.slice(i, i + maxConcurrency);
|
|
534
595
|
const batchRes = await Promise.all(
|
|
535
596
|
batch.map(async url => {
|