@lincoln504/pi-research 1.3.4 → 1.3.6

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 CHANGED
@@ -24,38 +24,31 @@
24
24
 
25
25
  <a href="https://github.com/Lincoln504/pi-research/actions/workflows/ci.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/Lincoln504/pi-research/ci.yml?style=flat-square&branch=main" /></a> <a href="https://www.npmjs.com/package/@lincoln504/pi-research"><img alt="npm version" src="https://img.shields.io/npm/v/@lincoln504/pi-research.svg?style=flat-square" /></a>
26
26
 
27
- **Free, unlimited web search &amp; scraping — no monthly quota, no subscription.**
28
- You pay only for the LLM tokens you use, and it builds your own private, searchable
29
- knowledge store on your machine.
27
+ **Free, unlimited web search &amp; scraping — no monthly quota, no subscription.** You pay only for the LLM tokens you use, and it can build a searchable knowledge store locally.
30
28
 
31
29
  ### How it works
32
30
 
33
- A research run is a loop of agent teams: a coordinator plans the work and runs an
34
- opening search, parallel researcher agents scrape and read sources through a fixed,
35
- sandboxed toolkit (no shell access, and no ability to write or modify files), and an evaluator decides whether
36
- to run another round or synthesize — returning one cited Markdown report while its
37
- sources are saved to the knowledge store in the background.
31
+ A research run loops through agent teams: a coordinator plans and opens with a search, researcher agents scrape and read in parallel, and an evaluator decides whether to go another round or synthesize — ending in one cited Markdown report, with findings optionally saved to the knowledge store.
38
32
 
39
- Three depth levels — normal, deep, ultra — set the team size and number of rounds;
40
- ask in natural language and the tool picks the right one:
33
+ Three depth levels — normal, deep, ultra — set the team size and number of rounds; ask in natural language and the tool picks the right one:
41
34
 
42
35
  ![Prompt-driven multi-round research in the pi TUI](https://raw.githubusercontent.com/Lincoln504/pi-research/main/docs/media/02-prompt-research.gif)
43
36
 
44
37
  ### Use cases
45
38
 
46
- - Plug-and-play (no API key needed) research tool for your [pi](https://github.com/earendil-works/pi).
47
- - Research from Claude Code, Codex, or another coding agent, with a cheaper lightweight or local model driving the research so it doesn't spend your main agent's budget.
48
- - Populating a dataset or building an index of knowledge sources from the web.
39
+ - Researching for your [Pi](https://github.com/earendil-works/pi) Coding Agent — plug-and-play, no API key needed.
40
+ - Researching from Claude Code, Codex, or another coding agent, with a cheaper lightweight or local model driving the research so it doesn't spend your main agent's budget.
41
+ - Populating a dataset or index of web sources.
49
42
  - Holding research in the knowledge store with a configurable scope — project-specific or globally user-scoped, set per directory from the `/research-config` TUI.
50
43
  - Using the pi-research agent skill as OpenClaw's web access.
51
- - Building agent systems that identify and examine web sources.
44
+ - Building agent systems that find and read web content.
52
45
 
53
46
  ### Why pi-research
54
47
 
55
- - **Free, unlimited search &amp; scrape — no quota, no monthly fee.** Most AI-search providers cap free searches per month and then charge; results run on *their* index on *their* servers. pi-research's search and scraping layer (DuckDuckGo via a stealth browser) is free and unlimited — you pay only for the LLM tokens you use, never per search or per month.
56
- - **You own your index.** Every finding is written into a local [LanceDB](https://lancedb.com) knowledge store on *your* machine — a persistent, searchable index you control and keep, which also seeds future runs so repeat questions get faster, cheaper answers. No borrowed, server-side index you can't keep or query. Findings can also be kept **globally**, scoped to a **single project**, or **not kept at all** — set per directory, changed whenever you like. The optionality is the point: the store is yours to build, partition, or switch off.
57
- - **Cited reports, not raw dumps.** It returns one synthesized, sourced Markdown report to the chat instead of dumping raw web content into the conversation — context-efficient and easy to verify.
58
- - **Read-only by design.** Web access runs inside a specialized, limited research agent that cannot run shell commands or write, edit, or delete anything. It can read and search files, so treat it as you would any read-only agent with access to your machine.
48
+ - **Free, unlimited search &amp; scrape — no quota, no monthly fee.** Most AI-search providers cap free searches per month and then charge; results run on *their* index on *their* servers. pi-research's search and scraping layer (DuckDuckGo via a stealth browser) is free and unlimited — you pay only for the LLM tokens you use.
49
+ - **You own your index.** Every finding is written into a local [LanceDB](https://lancedb.com) knowledge store on *your* machine — a persistent, searchable index you control and keep, which also seeds future runs so repeat questions get faster, cheaper answers. Findings can also be kept globally, scoped to a single project, or not kept at all — set per directory, changed whenever you like.
50
+ - **Cited reports, not raw dumps.** It returns one synthesized, sourced Markdown report instead of pasting raw web content into the conversation — context-efficient and easy to verify.
51
+ - **Read-only by design.** Web access runs inside a limited research agent that cannot run shell commands or write, edit, or delete anything. It can read and search files, so treat it as you would any read-only agent with access to your machine.
59
52
  - **Search a little or a lot** — three depth levels in the pi tool (four via the SDK and standalone CLI, which add the quick depth-0 path). Levels 1 and 2 are recommended for everyday workflow use; level 3 is for larger-scale investigations.
60
53
 
61
54
  ### Requirements / limitations
@@ -63,9 +56,9 @@ ask in natural language and the tool picks the right one:
63
56
  - Node.js >= 22.19.0
64
57
  - An LLM with a 100k+ context window (bring your own key or use a local model)
65
58
  - Internet access
66
- - A residential IP address — search, scraping, and YouTube transcripts all rely on a residential connection. A datacenter/VPS/cloud IP gets bot-blocked by the providers these features depend on.
59
+ - A residential IP address — search, scraping, and YouTube transcripts all rely on a residential connection. A datacenter/VPS/cloud IP gets bot-blocked by these providers.
67
60
  - The pi runtime the engine builds on — `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`, and `@earendil-works/pi-tui`. The pi extension uses the host's copies; the standalone CLI / agent skill install them as dependencies.
68
- - Cloudflare and similar anti-automation systems block scraping on some sites, so runs will identify sources they cannot reach. pi-research compensates with volume: the search tool rapidly pulls a large set of results off free DuckDuckGo, giving the model a wide selection of reachable content to cite.
61
+ - Cloudflare and similar anti-automation systems block scraping on some sites, so a run will flag pages it can't reach. pi-research compensates with volume: the search tool pulls a large set of results off free DuckDuckGo, giving the model a wide selection of reachable content to cite.
69
62
 
70
63
  ### Install
71
64
 
@@ -73,21 +66,23 @@ ask in natural language and the tool picks the right one:
73
66
  pi install npm:@lincoln504/pi-research
74
67
  ```
75
68
 
76
- See [Agent skill](docs/AGENT-SKILL.md) or [SDK](docs/SDK.md) for standalone (no pi extension) installation.
69
+ See [Agent skill](docs/AGENT-SKILL.md) or [SDK](docs/SDK.md) to install standalone (no pi extension).
77
70
 
78
- The pi extension works out of the box — it runs on your pi session's model and pi's own configuration. Standalone use (agent skill / CLI) requires a configured model: set `PI_RESEARCH_MODEL` (e.g. `openai/gpt-4o`) as an environment variable or in `~/.pi/research/config.env` (the CLI also accepts a per-run `--model`), plus `PI_RESEARCH_API_KEY` if you don't use pi; the SDK takes a `model` option. See [Configuration](docs/CONFIGURATION.md).
71
+ The pi extension works out of the box — it runs on your pi session's model and pi's own configuration. To use it standalone (agent skill / CLI), configure a model: set `PI_RESEARCH_MODEL` (e.g. `openai/gpt-4o`) as an environment variable or in `~/.pi/research/config.env` (the CLI also accepts a per-run `--model`), plus `PI_RESEARCH_API_KEY` if you don't use pi; the SDK takes a `model` option. See [Configuration](docs/CONFIGURATION.md).
79
72
 
80
73
  The first install pulls the stealth browser engine, which takes a few minutes.
81
74
 
82
75
  ### Stability (v1.x.x)
83
76
 
84
- v0.1.13 (April 2026) was the last release before a rebuild against current pi APIs
85
- and the stealth-browser stack. v1.0.0 is the first stable release since. Channels:
77
+ v0.1.13 (April 2026) was the last release before a rebuild against current pi APIs and the stealth-browser stack. v1.0.0 is the first stable release since. Channels:
86
78
 
87
79
  - npm (`npm:@lincoln504/pi-research`) is the stable channel, kept current with breaking pi changes.
88
80
  - A git install is the development channel: latest commits, first to break.
89
81
 
90
82
  ### License
91
83
 
92
- MIT. Bundled third-party licenses are listed in
93
- [docs/THIRD-PARTY-NOTICES.md](docs/THIRD-PARTY-NOTICES.md).
84
+ MIT. Bundled third-party licenses are listed in [docs/THIRD-PARTY-NOTICES.md](docs/THIRD-PARTY-NOTICES.md).
85
+
86
+ ### Package identity
87
+
88
+ This project is always `@lincoln504/pi-research` on npm. The `pi-research` (unscoped) package is unrelated and deprecated.
package/dist/cli.mjs CHANGED
@@ -215,7 +215,15 @@ function formatArg(arg) {
215
215
  return String(arg);
216
216
  }
217
217
  function redactSecrets(message) {
218
- let out = message;
218
+ const rawLength = message.length;
219
+ let scanEnd = REDACT_SCAN_LENGTH;
220
+ if (rawLength > scanEnd) {
221
+ const extensionLimit = Math.min(rawLength, scanEnd + REDACT_SCAN_EXTENSION_MAX);
222
+ while (scanEnd < extensionLimit && TOKEN_CHAR_PATTERN.test(message.charAt(scanEnd))) {
223
+ scanEnd += 1;
224
+ }
225
+ }
226
+ let out = rawLength > scanEnd ? message.slice(0, scanEnd) : message;
219
227
  out = out.replace(ANSI_PATTERN, "");
220
228
  out = out.replace(URL_CREDENTIALS_PATTERN, "$1[REDACTED]@");
221
229
  out = out.replace(JWT_PATTERN, "[REDACTED]");
@@ -232,14 +240,14 @@ function redactSecrets(message) {
232
240
  if (lastKeptUnit >= 55296 && lastKeptUnit <= 56319) keep -= 1;
233
241
  }
234
242
  if (out.length > MAX_LOG_MESSAGE_LENGTH) {
235
- out = `${out.slice(0, keep)}\u2026[truncated ${out.length - keep} chars]`;
243
+ out = `${out.slice(0, keep)}\u2026[truncated ${Math.max(rawLength, out.length) - keep} chars]`;
236
244
  }
237
245
  return out;
238
246
  }
239
247
  function stripTerminalEscapes(message) {
240
248
  return message.replace(TERMINAL_ESCAPE_PATTERN, "").replace(CONTROL_CHARS_PATTERN, " ");
241
249
  }
242
- var logContextStorage, MAX_LOG_MESSAGE_LENGTH, ANSI_PATTERN, URL_CREDENTIALS_PATTERN, COOKIE_HEADER_PATTERN, SENSITIVE_KV_PATTERN, KNOWN_TOKEN_PATTERN, JWT_PATTERN, BASIC_AUTH_PATTERN, BEARER_AUTH_PATTERN, PROVIDER_TOKEN_PATTERN, LONG_HEX_SECRET_PATTERN, CONTROL_CHARS_PATTERN, TERMINAL_ESCAPE_PATTERN;
250
+ var logContextStorage, MAX_LOG_MESSAGE_LENGTH, ANSI_PATTERN, URL_CREDENTIALS_PATTERN, COOKIE_HEADER_PATTERN, SENSITIVE_KV_PATTERN, KNOWN_TOKEN_PATTERN, JWT_PATTERN, BASIC_AUTH_PATTERN, BEARER_AUTH_PATTERN, PROVIDER_TOKEN_PATTERN, LONG_HEX_SECRET_PATTERN, CONTROL_CHARS_PATTERN, REDACT_SCAN_LENGTH, TOKEN_CHAR_PATTERN, REDACT_SCAN_EXTENSION_MAX, TERMINAL_ESCAPE_PATTERN;
243
251
  var init_log_utils = __esm({
244
252
  "src/utils/log-utils.ts"() {
245
253
  "use strict";
@@ -256,6 +264,9 @@ var init_log_utils = __esm({
256
264
  PROVIDER_TOKEN_PATTERN = /\b(AIza[0-9A-Za-z_-]{35}|ya29\.[0-9A-Za-z_-]{20,}|[sp]k_(?:live|test)_[0-9A-Za-z]{16,})\b/g;
257
265
  LONG_HEX_SECRET_PATTERN = /\b[0-9a-fA-F]{32,}\b/g;
258
266
  CONTROL_CHARS_PATTERN = /[\x00-\x1f\x7f]/g;
267
+ REDACT_SCAN_LENGTH = MAX_LOG_MESSAGE_LENGTH * 4;
268
+ TOKEN_CHAR_PATTERN = /[A-Za-z0-9_\-./+=]/;
269
+ REDACT_SCAN_EXTENSION_MAX = 2048;
259
270
  TERMINAL_ESCAPE_PATTERN = new RegExp(
260
271
  "\\x1b\\[[0-?]*[ -/]*[@-~]?|\\x1b\\][^\\x07\\x1b]*(?:\\x07|\\x1b\\\\)?|\\x1b[PX^_][^\\x1b]*(?:\\x1b\\\\)?|\\x1b.?",
261
272
  "g"
@@ -266,10 +277,26 @@ var init_log_utils = __esm({
266
277
  // src/utils/log-rotation.ts
267
278
  import * as fs from "node:fs";
268
279
  import * as path2 from "node:path";
269
- var LogRotation;
280
+ function touchOwnerOnly(filePath, mode) {
281
+ let fd;
282
+ try {
283
+ fd = fs.openSync(filePath, APPEND_CREATE_NOFOLLOW_FLAGS, mode);
284
+ fs.fchmodSync(fd, mode);
285
+ } catch {
286
+ } finally {
287
+ if (fd !== void 0) {
288
+ try {
289
+ fs.closeSync(fd);
290
+ } catch {
291
+ }
292
+ }
293
+ }
294
+ }
295
+ var APPEND_CREATE_NOFOLLOW_FLAGS, LogRotation;
270
296
  var init_log_rotation = __esm({
271
297
  "src/utils/log-rotation.ts"() {
272
298
  "use strict";
299
+ APPEND_CREATE_NOFOLLOW_FLAGS = fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | (typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0);
273
300
  LogRotation = class {
274
301
  MAX_LOG_SIZE = 10 * 1024 * 1024;
275
302
  // 10MB max file size
@@ -287,14 +314,7 @@ var init_log_rotation = __esm({
287
314
  try {
288
315
  if (fs.existsSync(logFile)) {
289
316
  fs.unlinkSync(logFile);
290
- try {
291
- fs.closeSync(fs.openSync(logFile, "a", 384));
292
- } catch {
293
- }
294
- try {
295
- fs.chmodSync(logFile, 384);
296
- } catch {
297
- }
317
+ touchOwnerOnly(logFile, 384);
298
318
  }
299
319
  const files = fs.readdirSync(logDir);
300
320
  const baseName = path2.basename(logFile);
@@ -330,14 +350,7 @@ var init_log_rotation = __esm({
330
350
  this.logger.log("[Logger] Log rotation skipped (rename failed; file likely in use):", renameErr);
331
351
  return;
332
352
  }
333
- try {
334
- fs.closeSync(fs.openSync(logFile, "a", 384));
335
- } catch {
336
- }
337
- try {
338
- fs.chmodSync(logFile, 384);
339
- } catch {
340
- }
353
+ touchOwnerOnly(logFile, 384);
341
354
  try {
342
355
  const files = fs.readdirSync(logDir);
343
356
  const logFiles = files.filter((f) => f.startsWith(path2.basename(logFile)) && f !== path2.basename(logFile)).sort();
@@ -9911,19 +9924,19 @@ function reconcileSkillInstalls(opts = {}) {
9911
9924
  if (e.type !== "symlink") {
9912
9925
  const stale2 = currentVersion !== null && e.version !== currentVersion;
9913
9926
  if (stale2 && source !== null && fs16.existsSync(e.path) && isOwnedCopy(e.path)) {
9914
- const dir = path17.dirname(e.path);
9915
- const base = path17.basename(e.path);
9927
+ const dir2 = path17.dirname(e.path);
9928
+ const base2 = path17.basename(e.path);
9916
9929
  try {
9917
- for (const name of fs16.readdirSync(dir)) {
9918
- if (name.startsWith(`.${base}.new-`) || name.startsWith(`.${base}.old-`)) {
9919
- fs16.rmSync(path17.join(dir, name), { recursive: true, force: true });
9930
+ for (const name of fs16.readdirSync(dir2)) {
9931
+ if (name.startsWith(`.${base2}.new-`) || name.startsWith(`.${base2}.old-`)) {
9932
+ fs16.rmSync(path17.join(dir2, name), { recursive: true, force: true });
9920
9933
  }
9921
9934
  }
9922
9935
  } catch {
9923
9936
  }
9924
9937
  const suffix = crypto10.randomBytes(4).toString("hex");
9925
- const fresh = path17.join(dir, `.${base}.new-${suffix}`);
9926
- const retired = path17.join(dir, `.${base}.old-${suffix}`);
9938
+ const fresh = path17.join(dir2, `.${base2}.new-${suffix}`);
9939
+ const retired = path17.join(dir2, `.${base2}.old-${suffix}`);
9927
9940
  try {
9928
9941
  copyDir(source, fresh);
9929
9942
  } catch {
@@ -9969,6 +9982,16 @@ function reconcileSkillInstalls(opts = {}) {
9969
9982
  }
9970
9983
  continue;
9971
9984
  }
9985
+ const dir = path17.dirname(e.path);
9986
+ const base = path17.basename(e.path);
9987
+ try {
9988
+ for (const name of fs16.readdirSync(dir)) {
9989
+ if (name.startsWith(`.${base}.new-`)) {
9990
+ fs16.rmSync(path17.join(dir, name), { recursive: true, force: true });
9991
+ }
9992
+ }
9993
+ } catch {
9994
+ }
9972
9995
  if (!isSymlinkPresent(e.path)) {
9973
9996
  result.pruned.push(e.path);
9974
9997
  continue;
@@ -9988,11 +10011,29 @@ function reconcileSkillInstalls(opts = {}) {
9988
10011
  if (source !== null) {
9989
10012
  try {
9990
10013
  fs16.unlinkSync(e.path);
10014
+ } catch {
10015
+ keep.push(e);
10016
+ continue;
10017
+ }
10018
+ try {
9991
10019
  fs16.symlinkSync(source, e.path, process.platform === "win32" ? "junction" : "dir");
9992
10020
  keep.push({ ...e, type: "symlink", source });
9993
10021
  result.repointed.push(e.path);
9994
10022
  } catch {
9995
- keep.push(e);
10023
+ const suffix = crypto10.randomBytes(4).toString("hex");
10024
+ const fresh = path17.join(dir, `.${base}.new-${suffix}`);
10025
+ try {
10026
+ copyDir(source, fresh);
10027
+ fs16.renameSync(fresh, e.path);
10028
+ keep.push({ ...e, type: "copy", source });
10029
+ result.repointed.push(e.path);
10030
+ } catch {
10031
+ try {
10032
+ fs16.rmSync(fresh, { recursive: true, force: true });
10033
+ } catch {
10034
+ }
10035
+ result.pruned.push(e.path);
10036
+ }
9996
10037
  }
9997
10038
  } else if (!targetLive) {
9998
10039
  try {
@@ -11439,6 +11480,7 @@ function isFullMockMode() {
11439
11480
  init_config();
11440
11481
  init_logger();
11441
11482
  init_error_tracker();
11483
+ init_log_utils();
11442
11484
  init_http_body();
11443
11485
  import * as http2 from "node:http";
11444
11486
 
@@ -11574,7 +11616,7 @@ var BrowserClient = class {
11574
11616
  resolve9(parsed);
11575
11617
  }
11576
11618
  } catch (_e) {
11577
- const raw = collected.toString();
11619
+ const raw = redactSecrets(collected.toString());
11578
11620
  const preview = raw.length > 200 ? raw.slice(0, 200) + "..." : raw;
11579
11621
  const error = new Error(`Failed to parse response (status ${res.statusCode}): ${preview}`);
11580
11622
  errorTracker.trackError(error, {
@@ -17545,6 +17587,7 @@ async function scrape(urls, maxConcurrency = 5, signal, config, sessionId, onUrl
17545
17587
  // src/tools/scrape.ts
17546
17588
  init_constants();
17547
17589
  init_text_utils();
17590
+ init_log_utils();
17548
17591
  init_config();
17549
17592
  init_service_registry();
17550
17593
  init_service_interfaces();
@@ -17716,18 +17759,19 @@ ${evictedDuplicates.map((u) => `- ${u}`).join("\n")}
17716
17759
  let freshResults;
17717
17760
  const concurrency = p.maxConcurrency || defaultConcurrency;
17718
17761
  try {
17719
- const results = await scrape(urlsToFetch, concurrency, signal, options.config, getGlobalState().researchId, (result) => {
17762
+ const results = await scrape(urlsToFetch, concurrency, signal, config, getGlobalState().researchId, (result) => {
17720
17763
  options.onUrlScrapeResult?.(result.url, result.success);
17721
17764
  }, container);
17722
17765
  freshResults = Array.isArray(results) ? results : [];
17723
17766
  } catch (error) {
17724
- logger.error(`[scrape tool] Scrape failed: ${error instanceof Error ? error.message : String(error)}`);
17767
+ const message = error instanceof Error ? error.message : String(error);
17768
+ logger.error(`[scrape tool] Scrape failed: ${message}`);
17725
17769
  metrics.increment("tool_scrape_calls_total", 1, { status: "error" });
17726
17770
  return {
17727
17771
  content: [{ type: "text", text: `# Scrape Failed
17728
17772
 
17729
- ${error instanceof Error ? error.message : String(error)}` }],
17730
- details: { error: String(error) }
17773
+ ${redactSecrets(message)}` }],
17774
+ details: { error: redactSecrets(String(error)) }
17731
17775
  };
17732
17776
  }
17733
17777
  const successfulFresh = freshResults.filter((r) => r.success);
@@ -18974,7 +19018,7 @@ function mapOsvItemToVulnerability(item) {
18974
19018
  for (const cwe of item.database_specific.cwe) {
18975
19019
  if (typeof cwe === "string") {
18976
19020
  cwes.push(cwe);
18977
- } else if (typeof cwe === "object" && typeof cwe.id === "string") {
19021
+ } else if (typeof cwe === "object" && cwe !== null && typeof cwe.id === "string") {
18978
19022
  cwes.push(cwe.id);
18979
19023
  }
18980
19024
  }
@@ -19355,6 +19399,10 @@ async function searchSecurityDatabases(params, signal) {
19355
19399
  // src/tools/security.ts
19356
19400
  init_constants();
19357
19401
  init_metrics();
19402
+ var MAX_DESCRIPTION_CHARS = 300;
19403
+ function truncateDescription(description) {
19404
+ return description.length > MAX_DESCRIPTION_CHARS ? `${description.substring(0, MAX_DESCRIPTION_CHARS)}...` : description;
19405
+ }
19358
19406
  function createSecuritySearchTool(options) {
19359
19407
  const SecuritySearchParamsSchema = Type6.Object({
19360
19408
  databases: Type6.Optional(Type6.Array(Type6.String({
@@ -19557,7 +19605,7 @@ Unable to search security databases. This may be a temporary issue - try again l
19557
19605
  }
19558
19606
  }
19559
19607
  const description = vuln.description;
19560
- markdown += `- **Description:** ${description.length > 300 ? `${description.substring(0, 300)}...` : description}
19608
+ markdown += `- **Description:** ${truncateDescription(description)}
19561
19609
  `;
19562
19610
  if (vuln.knownExploited === true) {
19563
19611
  markdown += "- **[Actively Exploited]**\n";
@@ -19609,7 +19657,7 @@ Unable to search security databases. This may be a temporary issue - try again l
19609
19657
  `;
19610
19658
  }
19611
19659
  const description = vuln.description;
19612
- markdown += `- **Description:** ${description.length > 300 ? `${description.substring(0, 300)}...` : description}
19660
+ markdown += `- **Description:** ${truncateDescription(description)}
19613
19661
  `;
19614
19662
  if (vuln.dueDate !== void 0) {
19615
19663
  markdown += `- **Due Date:** ${vuln.dueDate}
@@ -19657,7 +19705,7 @@ Unable to search security databases. This may be a temporary issue - try again l
19657
19705
  markdown += `- **Summary:** ${adv.summary}
19658
19706
  `;
19659
19707
  const description = adv.description ?? "";
19660
- markdown += `- **Description:** ${description.length > 300 ? `${description.substring(0, 300)}...` : description}
19708
+ markdown += `- **Description:** ${truncateDescription(description)}
19661
19709
  `;
19662
19710
  if (adv.published) {
19663
19711
  markdown += `- **Published:** ${adv.published}
@@ -19699,7 +19747,7 @@ Unable to search security databases. This may be a temporary issue - try again l
19699
19747
  markdown += `- **Severity:** ${vuln.severity}
19700
19748
  `;
19701
19749
  const description = vuln.description;
19702
- markdown += `- **Description:** ${description.length > 300 ? `${description.substring(0, 300)}...` : description}
19750
+ markdown += `- **Description:** ${truncateDescription(description)}
19703
19751
  `;
19704
19752
  if (vuln.affectedProducts && vuln.affectedProducts.length > 0) {
19705
19753
  markdown += `- **Affected:** ${vuln.affectedProducts.join(", ")}
@@ -20628,11 +20676,13 @@ function watchUrl(videoId) {
20628
20676
  init_retry_utils();
20629
20677
  init_circuit_breaker();
20630
20678
  init_logger();
20679
+ init_log_utils();
20631
20680
  init_metrics();
20632
20681
  init_http_body();
20633
20682
 
20634
20683
  // src/youtube/potoken.ts
20635
20684
  init_logger();
20685
+ init_safe_unref();
20636
20686
  init_metrics();
20637
20687
  var DEFAULT_REQUEST_KEY = "O43z0dpjhgX20SCx4KAo";
20638
20688
  var BRIDGED_GLOBALS = ["window", "document", "location", "navigator", "origin"];
@@ -20645,9 +20695,27 @@ function describeError(error) {
20645
20695
  return String(error);
20646
20696
  }
20647
20697
  var mintChain = Promise.resolve();
20698
+ var wedged = false;
20699
+ var MINT_HARD_RELEASE_MS = 5 * 60 * 1e3;
20648
20700
  async function mintPoTokens(identifiers, opts = {}) {
20701
+ if (wedged) {
20702
+ throw new Error("PoToken minter is wedged by a prior mint that never settled; refusing to start a new mint until it resolves.");
20703
+ }
20649
20704
  const run = mintChain.then(() => doMint(identifiers, opts), () => doMint(identifiers, opts));
20650
- mintChain = run.then(() => void 0, () => void 0);
20705
+ const settled = run.then(() => void 0, () => void 0);
20706
+ settled.then(() => {
20707
+ wedged = false;
20708
+ });
20709
+ let hardTimer;
20710
+ const hardRelease = new Promise((resolve9) => {
20711
+ hardTimer = setTimeout(() => {
20712
+ logger.warn("[youtube] potoken mint mutex force-released \u2014 doMint() did not settle within the hard timeout");
20713
+ wedged = true;
20714
+ resolve9();
20715
+ }, MINT_HARD_RELEASE_MS);
20716
+ safeUnref(hardTimer);
20717
+ });
20718
+ mintChain = Promise.race([settled, hardRelease]).finally(() => clearTimeout(hardTimer));
20651
20719
  return run;
20652
20720
  }
20653
20721
  async function doMint(identifiers, opts) {
@@ -20929,7 +20997,7 @@ function parseJson3(body) {
20929
20997
  try {
20930
20998
  parsed = JSON.parse(body);
20931
20999
  } catch {
20932
- logger.debug(`[youtube] timedtext body was not valid json3 (first 120 chars): ${body.slice(0, 120)}`);
21000
+ logger.debug(`[youtube] timedtext body was not valid json3 (first 120 chars): ${redactSecrets(body).slice(0, 120)}`);
20933
21001
  return "";
20934
21002
  }
20935
21003
  const events = parsed.events;
@@ -45,7 +45,15 @@ function formatArg(arg) {
45
45
  return String(arg);
46
46
  }
47
47
  function redactSecrets(message) {
48
- let out = message;
48
+ const rawLength = message.length;
49
+ let scanEnd = REDACT_SCAN_LENGTH;
50
+ if (rawLength > scanEnd) {
51
+ const extensionLimit = Math.min(rawLength, scanEnd + REDACT_SCAN_EXTENSION_MAX);
52
+ while (scanEnd < extensionLimit && TOKEN_CHAR_PATTERN.test(message.charAt(scanEnd))) {
53
+ scanEnd += 1;
54
+ }
55
+ }
56
+ let out = rawLength > scanEnd ? message.slice(0, scanEnd) : message;
49
57
  out = out.replace(ANSI_PATTERN, "");
50
58
  out = out.replace(URL_CREDENTIALS_PATTERN, "$1[REDACTED]@");
51
59
  out = out.replace(JWT_PATTERN, "[REDACTED]");
@@ -62,14 +70,14 @@ function redactSecrets(message) {
62
70
  if (lastKeptUnit >= 55296 && lastKeptUnit <= 56319) keep -= 1;
63
71
  }
64
72
  if (out.length > MAX_LOG_MESSAGE_LENGTH) {
65
- out = `${out.slice(0, keep)}\u2026[truncated ${out.length - keep} chars]`;
73
+ out = `${out.slice(0, keep)}\u2026[truncated ${Math.max(rawLength, out.length) - keep} chars]`;
66
74
  }
67
75
  return out;
68
76
  }
69
77
  function stripTerminalEscapes(message) {
70
78
  return message.replace(TERMINAL_ESCAPE_PATTERN, "").replace(CONTROL_CHARS_PATTERN, " ");
71
79
  }
72
- var logContextStorage, MAX_LOG_MESSAGE_LENGTH, ANSI_PATTERN, URL_CREDENTIALS_PATTERN, COOKIE_HEADER_PATTERN, SENSITIVE_KV_PATTERN, KNOWN_TOKEN_PATTERN, JWT_PATTERN, BASIC_AUTH_PATTERN, BEARER_AUTH_PATTERN, PROVIDER_TOKEN_PATTERN, LONG_HEX_SECRET_PATTERN, CONTROL_CHARS_PATTERN, TERMINAL_ESCAPE_PATTERN;
80
+ var logContextStorage, MAX_LOG_MESSAGE_LENGTH, ANSI_PATTERN, URL_CREDENTIALS_PATTERN, COOKIE_HEADER_PATTERN, SENSITIVE_KV_PATTERN, KNOWN_TOKEN_PATTERN, JWT_PATTERN, BASIC_AUTH_PATTERN, BEARER_AUTH_PATTERN, PROVIDER_TOKEN_PATTERN, LONG_HEX_SECRET_PATTERN, CONTROL_CHARS_PATTERN, REDACT_SCAN_LENGTH, TOKEN_CHAR_PATTERN, REDACT_SCAN_EXTENSION_MAX, TERMINAL_ESCAPE_PATTERN;
73
81
  var init_log_utils = __esm({
74
82
  "src/utils/log-utils.ts"() {
75
83
  "use strict";
@@ -86,6 +94,9 @@ var init_log_utils = __esm({
86
94
  PROVIDER_TOKEN_PATTERN = /\b(AIza[0-9A-Za-z_-]{35}|ya29\.[0-9A-Za-z_-]{20,}|[sp]k_(?:live|test)_[0-9A-Za-z]{16,})\b/g;
87
95
  LONG_HEX_SECRET_PATTERN = /\b[0-9a-fA-F]{32,}\b/g;
88
96
  CONTROL_CHARS_PATTERN = /[\x00-\x1f\x7f]/g;
97
+ REDACT_SCAN_LENGTH = MAX_LOG_MESSAGE_LENGTH * 4;
98
+ TOKEN_CHAR_PATTERN = /[A-Za-z0-9_\-./+=]/;
99
+ REDACT_SCAN_EXTENSION_MAX = 2048;
89
100
  TERMINAL_ESCAPE_PATTERN = new RegExp(
90
101
  "\\x1b\\[[0-?]*[ -/]*[@-~]?|\\x1b\\][^\\x07\\x1b]*(?:\\x07|\\x1b\\\\)?|\\x1b[PX^_][^\\x1b]*(?:\\x1b\\\\)?|\\x1b.?",
91
102
  "g"
@@ -357,10 +368,26 @@ var init_error_tracker = __esm({
357
368
  // src/utils/log-rotation.ts
358
369
  import * as fs2 from "node:fs";
359
370
  import * as path2 from "node:path";
360
- var LogRotation;
371
+ function touchOwnerOnly(filePath, mode) {
372
+ let fd;
373
+ try {
374
+ fd = fs2.openSync(filePath, APPEND_CREATE_NOFOLLOW_FLAGS, mode);
375
+ fs2.fchmodSync(fd, mode);
376
+ } catch {
377
+ } finally {
378
+ if (fd !== void 0) {
379
+ try {
380
+ fs2.closeSync(fd);
381
+ } catch {
382
+ }
383
+ }
384
+ }
385
+ }
386
+ var APPEND_CREATE_NOFOLLOW_FLAGS, LogRotation;
361
387
  var init_log_rotation = __esm({
362
388
  "src/utils/log-rotation.ts"() {
363
389
  "use strict";
390
+ APPEND_CREATE_NOFOLLOW_FLAGS = fs2.constants.O_WRONLY | fs2.constants.O_APPEND | fs2.constants.O_CREAT | (typeof fs2.constants.O_NOFOLLOW === "number" ? fs2.constants.O_NOFOLLOW : 0);
364
391
  LogRotation = class {
365
392
  MAX_LOG_SIZE = 10 * 1024 * 1024;
366
393
  // 10MB max file size
@@ -378,14 +405,7 @@ var init_log_rotation = __esm({
378
405
  try {
379
406
  if (fs2.existsSync(logFile)) {
380
407
  fs2.unlinkSync(logFile);
381
- try {
382
- fs2.closeSync(fs2.openSync(logFile, "a", 384));
383
- } catch {
384
- }
385
- try {
386
- fs2.chmodSync(logFile, 384);
387
- } catch {
388
- }
408
+ touchOwnerOnly(logFile, 384);
389
409
  }
390
410
  const files = fs2.readdirSync(logDir);
391
411
  const baseName = path2.basename(logFile);
@@ -421,14 +441,7 @@ var init_log_rotation = __esm({
421
441
  this.logger.log("[Logger] Log rotation skipped (rename failed; file likely in use):", renameErr);
422
442
  return;
423
443
  }
424
- try {
425
- fs2.closeSync(fs2.openSync(logFile, "a", 384));
426
- } catch {
427
- }
428
- try {
429
- fs2.chmodSync(logFile, 384);
430
- } catch {
431
- }
444
+ touchOwnerOnly(logFile, 384);
432
445
  try {
433
446
  const files = fs2.readdirSync(logDir);
434
447
  const logFiles = files.filter((f) => f.startsWith(path2.basename(logFile)) && f !== path2.basename(logFile)).sort();
@@ -1917,10 +1930,21 @@ import { platform as platform2 } from "node:os";
1917
1930
 
1918
1931
  // src/infrastructure/browser/thread-worker-messaging.ts
1919
1932
  init_log_utils();
1920
- init_scraper_types();
1921
1933
  import { appendFileSync as appendFileSync2 } from "node:fs";
1934
+
1935
+ // src/utils/safe-unref.ts
1936
+ function safeUnref(timer) {
1937
+ if (timer && typeof timer.unref === "function") {
1938
+ timer.unref();
1939
+ }
1940
+ }
1941
+
1942
+ // src/infrastructure/browser/thread-worker-messaging.ts
1943
+ init_scraper_types();
1922
1944
  var workerId2 = "";
1923
1945
  var pageCreationLock = Promise.resolve();
1946
+ var PAGE_CREATION_HARD_RELEASE_MS = 5 * 60 * 1e3;
1947
+ var wedgedContext = null;
1924
1948
  async function createPageSafe(context2) {
1925
1949
  let release;
1926
1950
  const nextLock = new Promise((resolve) => {
@@ -1929,8 +1953,34 @@ async function createPageSafe(context2) {
1929
1953
  const currentLock = pageCreationLock;
1930
1954
  pageCreationLock = currentLock.then(() => nextLock);
1931
1955
  await currentLock;
1956
+ if (context2 === wedgedContext) {
1957
+ release();
1958
+ throw new Error("Browser context is wedged from a prior page-creation call that never settled; a new browser context is required.");
1959
+ }
1932
1960
  const pagePromise = context2.newPage();
1933
- pagePromise.then(release, release);
1961
+ let hardReleased = false;
1962
+ const releaseOnce = () => {
1963
+ if (hardReleased) return;
1964
+ hardReleased = true;
1965
+ clearTimeout(hardReleaseTimer);
1966
+ release();
1967
+ };
1968
+ const hardReleaseTimer = setTimeout(() => {
1969
+ logToDebugFile2("WARN", "[ThreadWorker] page-creation mutex force-released \u2014 newPage() did not settle within the hard timeout");
1970
+ wedgedContext = context2;
1971
+ releaseOnce();
1972
+ }, PAGE_CREATION_HARD_RELEASE_MS);
1973
+ safeUnref(hardReleaseTimer);
1974
+ pagePromise.then(
1975
+ () => {
1976
+ if (wedgedContext === context2) wedgedContext = null;
1977
+ releaseOnce();
1978
+ },
1979
+ () => {
1980
+ if (wedgedContext === context2) wedgedContext = null;
1981
+ releaseOnce();
1982
+ }
1983
+ );
1934
1984
  pagePromise.catch((err) => logToDebugFile2("DEBUG", `[ThreadWorker] Background page creation rejection: ${err.message}`));
1935
1985
  let timeoutId;
1936
1986
  try {
@@ -1957,6 +2007,15 @@ async function createPageSafe(context2) {
1957
2007
  function setWorkerId2(id) {
1958
2008
  workerId2 = id;
1959
2009
  }
2010
+ function parseTimeoutMs(envVar, def) {
2011
+ if (envVar === void 0 || envVar.trim() === "") return def;
2012
+ const n = Number(envVar.trim());
2013
+ if (!Number.isFinite(n) || n < 0) {
2014
+ logToDebugFile2("WARN", `[ThreadWorker] Invalid timeout value "${envVar}", using default: ${def}ms`);
2015
+ return def;
2016
+ }
2017
+ return n;
2018
+ }
1960
2019
  function logToDebugFile2(level, ...args) {
1961
2020
  const logFile = process.env["PI_RESEARCH_LOG_FILE"];
1962
2021
  if (!logFile) return;
@@ -2066,7 +2125,7 @@ async function extractSearchResults(page) {
2066
2125
  }
2067
2126
  async function executeSearchTask(_context, query) {
2068
2127
  const page = await createPageSafe(_context);
2069
- const SEARCH_TIMEOUT = parseInt(process.env["PI_RESEARCH_SEARCH_TIMEOUT_MS"] || "45000", 10);
2128
+ const SEARCH_TIMEOUT = parseTimeoutMs(process.env["PI_RESEARCH_SEARCH_TIMEOUT_MS"], 45e3);
2070
2129
  page.setDefaultTimeout(SEARCH_TIMEOUT);
2071
2130
  page.setDefaultNavigationTimeout(SEARCH_TIMEOUT);
2072
2131
  try {
@@ -2135,7 +2194,7 @@ function seedSubresourceVerdict(validatedUrl) {
2135
2194
  }
2136
2195
  async function executeScrapeTask(_context, url, signal) {
2137
2196
  const page = await createPageSafe(_context);
2138
- const SCRAPE_TIMEOUT = parseInt(process.env["PI_RESEARCH_SCRAPE_TIMEOUT_MS"] || "15000", 10);
2197
+ const SCRAPE_TIMEOUT = parseTimeoutMs(process.env["PI_RESEARCH_SCRAPE_TIMEOUT_MS"], 15e3);
2139
2198
  page.setDefaultTimeout(SCRAPE_TIMEOUT);
2140
2199
  page.setDefaultNavigationTimeout(SCRAPE_TIMEOUT);
2141
2200
  const onAbort = () => {
@@ -2301,7 +2360,7 @@ async function executeHealthCheckAttempt(_context, navTimeoutMs, url = HEALTH_PR
2301
2360
  }
2302
2361
  }
2303
2362
  async function executeHealthCheck(_context, initMs) {
2304
- const configuredMs = parseInt(process.env["PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS"] || "0", 10);
2363
+ const configuredMs = parseTimeoutMs(process.env["PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS"], 0);
2305
2364
  const HEALTH_TIMEOUT = configuredMs > 0 ? Math.max(1e4, configuredMs) : 1e4;
2306
2365
  if (initMs > 3e3) {
2307
2366
  logToDebugFile2("WARN", `[Worker-${workerId2}] Browser init was slow: ${initMs}ms \u2014 leaving less headroom for DDG navigation.`);
@@ -2594,6 +2653,35 @@ async function initBrowser() {
2594
2653
  height: 1080,
2595
2654
  colorDepth: 24,
2596
2655
  pixelRatio: 1
2656
+ },
2657
+ // Mute all audio output at the engine level. The general scrape
2658
+ // path drives a REAL Firefox (Camoufox), and a researcher can be
2659
+ // pointed at a youtube.com watch page through the ordinary
2660
+ // `scrape` tool (as opposed to the dedicated `youtube_transcript`
2661
+ // tool, which never touches the browser) — YouTube watch pages
2662
+ // autoplay video with sound, so without an explicit mute the
2663
+ // process plays audible audio through the host's sound device.
2664
+ //
2665
+ // media.volume_scale forces the browser's effective output
2666
+ // volume to 0 regardless of per-tab mute/autoplay state or any
2667
+ // page script re-enabling audio — the most reliable, engine-level
2668
+ // mute available for headless Firefox/Camoufox (the same pref
2669
+ // Selenium/Playwright Firefox test grids use to silence CI runs).
2670
+ // media.autoplay.default=5 additionally blocks autoplay outright
2671
+ // (belt-and-suspenders; some sites route around the volume scale
2672
+ // via Web Audio gain nodes). Neither pref affects rendering — the
2673
+ // page still loads and paints normally, only the audio output
2674
+ // device is silenced.
2675
+ //
2676
+ // Set on every platform/headless-mode combination, not just
2677
+ // Linux: this was only *observed* on Linux (true headless and
2678
+ // Xvfb-backed 'virtual' mode both retain a real PulseAudio/
2679
+ // PipeWire/ALSA sink for the Firefox process to play through),
2680
+ // but headless Firefox on macOS/Windows is not guaranteed to be
2681
+ // silent either, so the mute is applied unconditionally.
2682
+ firefox_user_prefs: {
2683
+ "media.volume_scale": "0.0",
2684
+ "media.autoplay.default": 5
2597
2685
  }
2598
2686
  });
2599
2687
  launchPromise.catch((err) => logToDebugFile3("DEBUG", `[ThreadWorker] Background browser launch rejection: ${err.message}`));