@lincoln504/pi-research 1.1.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/cli.mjs +2599 -1268
- package/dist/thread-worker.mjs +224 -147
- package/docs/AGENT-SKILL.md +11 -4
- package/docs/ARCHITECTURE.md +20 -10
- package/docs/CHANGELOG.md +195 -17
- package/docs/CONFIGURATION.md +9 -7
- package/docs/KNOWLEDGE-STORE.md +4 -3
- package/docs/PI-EXTENSION.md +1 -1
- package/docs/SDK.md +55 -0
- package/docs/THIRD-PARTY-NOTICES.md +1 -1
- package/package.json +5 -4
- package/scripts/cleanup.cjs +114 -12
- package/scripts/prepare.cjs +15 -5
- package/scripts/setup.cjs +24 -4
- package/skills/pi-research/README.md +9 -2
- package/skills/pi-research/SKILL.md +6 -3
- package/skills/pi-research/references/configuration.md +6 -2
- package/skills/pi-research/scripts/run.mjs +24 -2
- package/src/cleanup/research-cleanup.ts +12 -4
- package/src/cli.ts +372 -86
- package/src/config.ts +134 -17
- package/src/constants.ts +31 -0
- package/src/core/interfaces/health-check-interfaces.ts +3 -39
- package/src/core/interfaces/knowledge-interfaces.ts +6 -1
- 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 +11 -3
- package/src/core/llm/agentic-repair.ts +14 -0
- package/src/core/pi-version.ts +136 -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/core/service-registry.ts +117 -8
- package/src/healthcheck/index.ts +7 -2
- package/src/index.ts +11 -18
- package/src/infrastructure/browser/browser-client.ts +80 -13
- package/src/infrastructure/browser/browser-error-utils.ts +6 -1
- package/src/infrastructure/browser/browser-server.ts +41 -12
- package/src/infrastructure/browser/browser-task-scheduler.ts +159 -35
- package/src/infrastructure/browser/cleanup-utils.ts +95 -9
- package/src/infrastructure/browser/client-agent.ts +6 -5
- package/src/infrastructure/browser/config.ts +14 -3
- package/src/infrastructure/browser/priority-task-queue.ts +10 -3
- package/src/infrastructure/browser/scheduler-factory.ts +44 -4
- package/src/infrastructure/browser/task-execution-service.ts +92 -28
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
- 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 +224 -147
- package/src/infrastructure/browser/worker-pool-manager.ts +18 -5
- package/src/infrastructure/embedding/embedding-client.ts +22 -8
- package/src/infrastructure/embedding/embedding-factory.ts +61 -15
- package/src/infrastructure/embedding/embedding-server.ts +105 -11
- package/src/infrastructure/file-lock-service.ts +295 -28
- package/src/infrastructure/knowledge-store-service.ts +48 -3
- package/src/infrastructure/process-lifecycle-service.ts +134 -34
- package/src/infrastructure/research-run-semaphore.ts +37 -3
- package/src/infrastructure/state/state-backup-manager.ts +5 -7
- 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 +5 -5
- package/src/infrastructure/state/state-manager.ts +34 -23
- package/src/infrastructure/types/state-types.ts +9 -0
- package/src/knowledge/chunker.ts +88 -1
- package/src/knowledge/embedder-init.ts +1 -1
- package/src/knowledge/embedder.ts +188 -46
- package/src/knowledge/index.ts +1 -1
- package/src/knowledge/store.ts +101 -21
- package/src/knowledge/webgpu-viability.ts +9 -4
- package/src/knowledge/writer-queue.ts +88 -46
- 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 +25 -4
- package/src/orchestration/headless-observer.ts +8 -0
- package/src/orchestration/quick-research-orchestrator.ts +2 -2
- package/src/orchestration/research-orchestration-service.ts +65 -15
- package/src/orchestration/research-session-service.ts +37 -11
- package/src/orchestration/research-synthesis-service.ts +33 -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/cisa-kev.ts +10 -3
- package/src/security/github-advisories.ts +18 -7
- package/src/security/index.ts +51 -30
- package/src/security/nvd-client.ts +24 -8
- package/src/security/osv-client.ts +14 -4
- package/src/skill-install/skill-installer.ts +153 -11
- package/src/stackexchange/index.ts +20 -5
- package/src/stackexchange/output/table.ts +7 -2
- package/src/stackexchange/rest-client.ts +41 -3
- package/src/tools/research-knowledge-search.ts +15 -0
- package/src/tools/research-tool-definition.ts +8 -2
- package/src/tools/scrape.ts +48 -25
- package/src/tools/search.ts +16 -2
- package/src/tools/security.ts +68 -7
- package/src/tui/research-health.ts +18 -2
- package/src/utils/atomic-replace.ts +101 -0
- package/src/utils/circuit-breaker.ts +9 -2
- package/src/utils/citation-utils.ts +3 -1
- package/src/utils/disk-space-checker.ts +11 -6
- package/src/utils/http-body.ts +176 -0
- 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 +70 -5
- package/src/web-research/scraper-utils.ts +13 -0
- package/src/web-research/search.ts +25 -6
- package/src/web-research/types.ts +19 -4
- package/src/web-research/web-scraper.ts +72 -54
- package/src/youtube/transcript-client.ts +5 -1
- package/src/core/health-check-service.ts +0 -265
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capped response-body reading.
|
|
3
|
+
*
|
|
4
|
+
* `response.json()` and `response.text()` buffer the WHOLE body into the main
|
|
5
|
+
* process before anything can inspect its size — the byte count is only knowable
|
|
6
|
+
* after the memory has already been committed. A hostile or merely broken upstream
|
|
7
|
+
* can therefore drive this process to an OOM kill with a single reply, and because
|
|
8
|
+
* a `Content-Length` header is advisory (absent on chunked encoding, and freely
|
|
9
|
+
* falsifiable) no pre-screen closes that hole. Streaming chunk-by-chunk and
|
|
10
|
+
* aborting the instant the cap is crossed is the only check that actually bounds
|
|
11
|
+
* the allocation.
|
|
12
|
+
*
|
|
13
|
+
* The scraper has read HTML and PDF this way for some time; these helpers exist so
|
|
14
|
+
* the JSON API clients share that one implementation rather than each growing a
|
|
15
|
+
* near-copy of it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Thrown by {@link readBodyCapped} when a streamed body crosses its byte cap. */
|
|
19
|
+
export class BodyTooLargeError extends Error {
|
|
20
|
+
constructor(public readonly bytesRead: number, public readonly maxBytes: number) {
|
|
21
|
+
super(`Response body exceeded ${maxBytes} byte cap`);
|
|
22
|
+
this.name = 'BodyTooLargeError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Default cap for a JSON API response.
|
|
28
|
+
*
|
|
29
|
+
* Every endpoint behind these clients answers in kilobytes for a normal query, so
|
|
30
|
+
* 32MB is orders of magnitude of headroom for legitimate traffic while still
|
|
31
|
+
* bounding a runaway body to something a Node heap absorbs without dying. Bulk
|
|
32
|
+
* endpoints that legitimately return more (a full vulnerability catalogue, a
|
|
33
|
+
* maximum-size CVE page) pass their own larger cap explicitly.
|
|
34
|
+
*/
|
|
35
|
+
export const MAX_JSON_BODY_BYTES = 32 * 1024 * 1024;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Cap for bulk catalogue downloads (CISA KEV's full feed, a 2000-item NVD page).
|
|
39
|
+
* These are legitimately tens of megabytes and grow over time, so they get their
|
|
40
|
+
* own ceiling rather than forcing the shared default up for everyone.
|
|
41
|
+
*/
|
|
42
|
+
export const MAX_BULK_JSON_BODY_BYTES = 96 * 1024 * 1024;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Read a response body with a HARD byte cap, streaming chunk-by-chunk so an
|
|
46
|
+
* over-limit body aborts the moment the cap is crossed.
|
|
47
|
+
*
|
|
48
|
+
* A body-less response (test doubles, some mocks) falls back to `arrayBuffer()`;
|
|
49
|
+
* callers that had a post-read size check keep it as the belt for that path.
|
|
50
|
+
*/
|
|
51
|
+
export async function readBodyCapped(response: Response, maxBytes: number): Promise<Uint8Array> {
|
|
52
|
+
const body = response.body;
|
|
53
|
+
// Also tolerate non-standard doubles that expose a body without getReader.
|
|
54
|
+
if (!body || typeof body.getReader !== 'function') {
|
|
55
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const reader = body.getReader();
|
|
59
|
+
const chunks: Uint8Array[] = [];
|
|
60
|
+
let total = 0;
|
|
61
|
+
try {
|
|
62
|
+
for (;;) {
|
|
63
|
+
const { done, value } = await reader.read();
|
|
64
|
+
if (done) break;
|
|
65
|
+
if (value) {
|
|
66
|
+
total += value.byteLength;
|
|
67
|
+
if (total > maxBytes) throw new BodyTooLargeError(total, maxBytes);
|
|
68
|
+
chunks.push(value);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} finally {
|
|
72
|
+
// Over-cap or downstream throw: release the connection instead of leaving
|
|
73
|
+
// the socket open (and the server still sending) until GC. No-op after a
|
|
74
|
+
// clean done=true read.
|
|
75
|
+
try { await reader.cancel(); } catch { /* already closed */ }
|
|
76
|
+
}
|
|
77
|
+
const out = new Uint8Array(total);
|
|
78
|
+
let offset = 0;
|
|
79
|
+
for (const c of chunks) { out.set(c, offset); offset += c.byteLength; }
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Whether this response can actually be read as a stream — i.e. whether the cap
|
|
85
|
+
* can be enforced at all.
|
|
86
|
+
*
|
|
87
|
+
* A real `Response` from `fetch()` always exposes a `ReadableStream` body except
|
|
88
|
+
* for the null-body statuses (204/205/304), which have nothing to cap. So in
|
|
89
|
+
* production this is true exactly when there are bytes to bound, and the capped
|
|
90
|
+
* path is never quietly skipped. It returns false only for the null-body statuses
|
|
91
|
+
* and for hand-rolled test doubles, which implement whichever of
|
|
92
|
+
* `arrayBuffer()`/`text()`/`json()` they need and no stream at all.
|
|
93
|
+
*/
|
|
94
|
+
function hasStreamBody(response: Response): boolean {
|
|
95
|
+
const body = response.body;
|
|
96
|
+
return !!body && typeof body.getReader === 'function';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Capped replacement for `response.text()`.
|
|
101
|
+
*
|
|
102
|
+
* Decodes UTF-8 non-fatally, matching `Response.text()` — a malformed byte becomes
|
|
103
|
+
* U+FFFD rather than throwing, so swapping this in cannot turn a body that used to
|
|
104
|
+
* parse into an exception.
|
|
105
|
+
*/
|
|
106
|
+
export async function readTextCapped(
|
|
107
|
+
response: Response,
|
|
108
|
+
maxBytes: number = MAX_JSON_BODY_BYTES,
|
|
109
|
+
): Promise<string> {
|
|
110
|
+
if (hasStreamBody(response)) {
|
|
111
|
+
return new TextDecoder('utf-8').decode(await readBodyCapped(response, maxBytes));
|
|
112
|
+
}
|
|
113
|
+
// Double/null-body ladder — see hasStreamBody for why this is unreachable for a
|
|
114
|
+
// real body-bearing response.
|
|
115
|
+
if (typeof response.arrayBuffer === 'function') {
|
|
116
|
+
return new TextDecoder('utf-8').decode(new Uint8Array(await response.arrayBuffer()));
|
|
117
|
+
}
|
|
118
|
+
return response.text();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Capped replacement for `response.json()`.
|
|
123
|
+
*
|
|
124
|
+
* Throws {@link BodyTooLargeError} for an over-cap body and a `SyntaxError` for a
|
|
125
|
+
* malformed one, so callers that already distinguish "malformed JSON" from other
|
|
126
|
+
* failures keep working: the size failure is a distinct, catchable type rather
|
|
127
|
+
* than something that masquerades as a parse error.
|
|
128
|
+
*/
|
|
129
|
+
export async function readJsonCapped<T = unknown>(
|
|
130
|
+
response: Response,
|
|
131
|
+
maxBytes: number = MAX_JSON_BODY_BYTES,
|
|
132
|
+
): Promise<T> {
|
|
133
|
+
if (hasStreamBody(response) || typeof response.arrayBuffer === 'function' || typeof response.text === 'function') {
|
|
134
|
+
return JSON.parse(await readTextCapped(response, maxBytes)) as T;
|
|
135
|
+
}
|
|
136
|
+
// A double that implements json() alone.
|
|
137
|
+
return response.json() as Promise<T>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Accumulator for a Node `IncomingMessage` body that decodes UTF-8 exactly ONCE,
|
|
142
|
+
* at the end.
|
|
143
|
+
*
|
|
144
|
+
* The obvious `body += chunk` is silently lossy. `+=` stringifies each Buffer on
|
|
145
|
+
* its own, so a multi-byte character straddling a chunk boundary is decoded as two
|
|
146
|
+
* independent fragments and both halves become U+FFFD. Chunk boundaries fall
|
|
147
|
+
* wherever the socket happens to split, so the damage scales with payload size and
|
|
148
|
+
* lands only on non-ASCII text — measured at ~15 mangled characters in a 560KB CJK
|
|
149
|
+
* body. Nothing throws: `JSON.parse` still succeeds, so the corruption reaches the
|
|
150
|
+
* report and the knowledge store unannounced.
|
|
151
|
+
*
|
|
152
|
+
* Buffering the chunks and concatenating before a single decode is what makes a
|
|
153
|
+
* split sequence whole again.
|
|
154
|
+
*
|
|
155
|
+
* `byteLength` counts real bytes, which is also the honest unit for a payload cap —
|
|
156
|
+
* the old `body.length` measured UTF-16 code units of an already-corrupted string.
|
|
157
|
+
*/
|
|
158
|
+
export class Utf8Body {
|
|
159
|
+
private readonly chunks: Buffer[] = [];
|
|
160
|
+
private bytes = 0;
|
|
161
|
+
|
|
162
|
+
/** Total bytes accumulated so far. */
|
|
163
|
+
get byteLength(): number {
|
|
164
|
+
return this.bytes;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
push(chunk: Buffer): void {
|
|
168
|
+
this.chunks.push(chunk);
|
|
169
|
+
this.bytes += chunk.byteLength;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Concatenate and decode. Safe to call once the stream has ended. */
|
|
173
|
+
toString(): string {
|
|
174
|
+
return Buffer.concat(this.chunks).toString('utf8');
|
|
175
|
+
}
|
|
176
|
+
}
|
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
|
}
|
|
@@ -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');
|