@lincoln504/pi-research 1.1.2 → 1.3.0

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