@dreb/coding-agent 2.19.0 → 2.19.2

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 (63) hide show
  1. package/README.md +2 -1
  2. package/dist/cli/args.d.ts.map +1 -1
  3. package/dist/cli/args.js +3 -2
  4. package/dist/cli/args.js.map +1 -1
  5. package/dist/cli/file-processor.d.ts.map +1 -1
  6. package/dist/cli/file-processor.js +3 -2
  7. package/dist/cli/file-processor.js.map +1 -1
  8. package/dist/core/agent-session.d.ts.map +1 -1
  9. package/dist/core/agent-session.js +6 -5
  10. package/dist/core/agent-session.js.map +1 -1
  11. package/dist/core/buddy/buddy-controller.d.ts.map +1 -1
  12. package/dist/core/buddy/buddy-controller.js +3 -2
  13. package/dist/core/buddy/buddy-controller.js.map +1 -1
  14. package/dist/core/event-bus.d.ts.map +1 -1
  15. package/dist/core/event-bus.js +2 -1
  16. package/dist/core/event-bus.js.map +1 -1
  17. package/dist/core/logger.d.ts +29 -0
  18. package/dist/core/logger.d.ts.map +1 -0
  19. package/dist/core/logger.js +54 -0
  20. package/dist/core/logger.js.map +1 -0
  21. package/dist/core/model-resolver.d.ts.map +1 -1
  22. package/dist/core/model-resolver.js +3 -2
  23. package/dist/core/model-resolver.js.map +1 -1
  24. package/dist/core/package-manager.d.ts.map +1 -1
  25. package/dist/core/package-manager.js +25 -2
  26. package/dist/core/package-manager.js.map +1 -1
  27. package/dist/core/sdk.d.ts.map +1 -1
  28. package/dist/core/sdk.js +6 -2
  29. package/dist/core/sdk.js.map +1 -1
  30. package/dist/core/stderr-guard.d.ts +37 -0
  31. package/dist/core/stderr-guard.d.ts.map +1 -0
  32. package/dist/core/stderr-guard.js +90 -0
  33. package/dist/core/stderr-guard.js.map +1 -0
  34. package/dist/core/timings.d.ts.map +1 -1
  35. package/dist/core/timings.js +5 -4
  36. package/dist/core/timings.js.map +1 -1
  37. package/dist/core/tools/subagent.d.ts.map +1 -1
  38. package/dist/core/tools/subagent.js +24 -22
  39. package/dist/core/tools/subagent.js.map +1 -1
  40. package/dist/core/tools/suggest-next.d.ts +2 -0
  41. package/dist/core/tools/suggest-next.d.ts.map +1 -1
  42. package/dist/core/tools/suggest-next.js +32 -12
  43. package/dist/core/tools/suggest-next.js.map +1 -1
  44. package/dist/core/tools/terminal-render.d.ts.map +1 -1
  45. package/dist/core/tools/terminal-render.js +2 -1
  46. package/dist/core/tools/terminal-render.js.map +1 -1
  47. package/dist/core/tools/web-search-queue.d.ts.map +1 -1
  48. package/dist/core/tools/web-search-queue.js +2 -1
  49. package/dist/core/tools/web-search-queue.js.map +1 -1
  50. package/dist/core/tools/web.d.ts.map +1 -1
  51. package/dist/core/tools/web.js +6 -5
  52. package/dist/core/tools/web.js.map +1 -1
  53. package/dist/main.d.ts.map +1 -1
  54. package/dist/main.js +25 -24
  55. package/dist/main.js.map +1 -1
  56. package/dist/modes/interactive/interactive-mode.d.ts +5 -0
  57. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  58. package/dist/modes/interactive/interactive-mode.js +32 -2
  59. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  60. package/dist/modes/print-mode.d.ts.map +1 -1
  61. package/dist/modes/print-mode.js +3 -2
  62. package/dist/modes/print-mode.js.map +1 -1
  63. package/package.json +1 -1
@@ -4,14 +4,18 @@
4
4
  * Allows the agent to suggest a command the user might want to run next.
5
5
  * The suggestion is shown as ghost text in the editor prompt (Tab to accept).
6
6
  */
7
- import { Text } from "@dreb/tui";
7
+ import { Container, Markdown, Text } from "@dreb/tui";
8
8
  import { Type } from "@sinclair/typebox";
9
+ import { getMarkdownTheme } from "../../modes/interactive/theme/theme.js";
9
10
  // ============================================================================
10
11
  // Schema
11
12
  const suggestNextSchema = Type.Object({
12
13
  command: Type.String({
13
14
  description: "The suggested command for the user to run next (e.g. /skill:mach6-push, /compact)",
14
15
  }),
16
+ summary: Type.Optional(Type.String({
17
+ description: "Brief markdown summary of the work done this turn. Displayed to the user as the final message before the suggestion.",
18
+ })),
15
19
  });
16
20
  // ============================================================================
17
21
  // Render helpers
@@ -19,14 +23,6 @@ function formatSuggestNextCall(args, theme) {
19
23
  const cmd = args?.command ?? "";
20
24
  return `${theme.fg("toolTitle", theme.bold("suggest_next"))} ${theme.fg("accent", cmd)}`;
21
25
  }
22
- function formatSuggestNextResult(result, theme) {
23
- const details = result.details;
24
- if (!details) {
25
- const text = result.content?.[0];
26
- return text?.type === "text" && text.text ? theme.fg("toolOutput", text.text) : "";
27
- }
28
- return theme.fg("toolOutput", `→ ${details.suggestion}`);
29
- }
30
26
  // ============================================================================
31
27
  // Tool definition factory
32
28
  export function createSuggestNextToolDefinition(onSuggest) {
@@ -41,8 +37,10 @@ export function createSuggestNextToolDefinition(onSuggest) {
41
37
  "Use full command syntax: /skill:name args, /compact, etc.",
42
38
  "Only suggest one command — pick the most likely next step",
43
39
  "Don't suggest if the conversation is open-ended with no obvious next action",
40
+ "Include a brief summary of work done in the `summary` parameter — this is your last chance to communicate before the turn ends",
41
+ "Calling this tool ends your turn automatically — do not call wait afterwards",
44
42
  ],
45
- async execute(_toolCallId, { command: rawCommand }, _signal, _onUpdate, _ctx) {
43
+ async execute(_toolCallId, { command: rawCommand, summary }, _signal, _onUpdate, _ctx) {
46
44
  // Strip control characters (newlines, tabs, etc.) that would corrupt TUI rendering
47
45
  const command = rawCommand?.replace(/[\x00-\x1f\x7f]/g, "").trim();
48
46
  if (!command || !command.startsWith("/")) {
@@ -52,9 +50,16 @@ export function createSuggestNextToolDefinition(onSuggest) {
52
50
  };
53
51
  }
54
52
  onSuggest(command);
53
+ // Convert literal \n sequences to actual newlines (LLMs emit these in XML tool calls),
54
+ // then strip control characters (preserve only newlines for markdown)
55
+ const sanitizedSummary = summary
56
+ ?.replace(/\\n/g, "\n")
57
+ .replace(/[\x00-\x09\x0b-\x1f\x7f]/g, "")
58
+ .trim() || undefined;
55
59
  return {
56
60
  content: [{ type: "text", text: `Suggestion registered: ${command}` }],
57
- details: { suggestion: command },
61
+ details: { suggestion: command, summary: sanitizedSummary },
62
+ endTurn: true,
58
63
  };
59
64
  },
60
65
  renderCall(args, theme, context) {
@@ -63,8 +68,23 @@ export function createSuggestNextToolDefinition(onSuggest) {
63
68
  return text;
64
69
  },
65
70
  renderResult(result, _options, theme, context) {
71
+ const details = result.details;
72
+ if (!details) {
73
+ const text = context.lastComponent ?? new Text("", 0, 0);
74
+ const content = result.content?.[0];
75
+ const msg = content?.type === "text" && content.text ? content.text : "";
76
+ text.setText(theme.fg("toolOutput", msg));
77
+ return text;
78
+ }
79
+ if (details.summary) {
80
+ const container = context.lastComponent ?? new Container();
81
+ container.clear();
82
+ container.addChild(new Markdown(details.summary, 0, 0, getMarkdownTheme()));
83
+ container.addChild(new Text(theme.fg("toolOutput", `→ ${details.suggestion}`), 0, 0));
84
+ return container;
85
+ }
66
86
  const text = context.lastComponent ?? new Text("", 0, 0);
67
- text.setText(formatSuggestNextResult(result, theme));
87
+ text.setText(theme.fg("toolOutput", `→ ${details.suggestion}`));
68
88
  return text;
69
89
  },
70
90
  };
@@ -1 +1 @@
1
- {"version":3,"file":"suggest-next.js","sourceRoot":"","sources":["../../../src/core/tools/suggest-next.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Suggest next command tool.\n *\n * Allows the agent to suggest a command the user might want to run next.\n * The suggestion is shown as ghost text in the editor prompt (Tab to accept).\n */\n\nimport { Text } from \"@dreb/tui\";\nimport { type Static, Type } from \"@sinclair/typebox\";\nimport type { ToolDefinition } from \"../extensions/types.js\";\n\n// ============================================================================\n// Types\n\nexport interface SuggestNextDetails {\n\tsuggestion: string;\n}\n\nexport type SuggestNextCallback = (suggestion: string) => void;\n\n// ============================================================================\n// Schema\n\nconst suggestNextSchema = Type.Object({\n\tcommand: Type.String({\n\t\tdescription: \"The suggested command for the user to run next (e.g. /skill:mach6-push, /compact)\",\n\t}),\n});\n\nexport type SuggestNextInput = Static<typeof suggestNextSchema>;\n\n// ============================================================================\n// Render helpers\n\nfunction formatSuggestNextCall(args: { command?: string } | undefined, theme: any): string {\n\tconst cmd = args?.command ?? \"\";\n\treturn `${theme.fg(\"toolTitle\", theme.bold(\"suggest_next\"))} ${theme.fg(\"accent\", cmd)}`;\n}\n\nfunction formatSuggestNextResult(\n\tresult: { content: Array<{ type: string; text?: string }>; details?: SuggestNextDetails },\n\ttheme: any,\n): string {\n\tconst details = result.details;\n\tif (!details) {\n\t\tconst text = result.content?.[0];\n\t\treturn text?.type === \"text\" && text.text ? theme.fg(\"toolOutput\", text.text) : \"\";\n\t}\n\treturn theme.fg(\"toolOutput\", `→ ${details.suggestion}`);\n}\n\n// ============================================================================\n// Tool definition factory\n\nexport function createSuggestNextToolDefinition(\n\tonSuggest: SuggestNextCallback,\n): ToolDefinition<typeof suggestNextSchema, SuggestNextDetails | undefined> {\n\treturn {\n\t\tname: \"suggest_next\",\n\t\tlabel: \"suggest_next\",\n\t\tdescription:\n\t\t\t\"Suggest a command for the user to run next. Shows as ghost text in the prompt that the user can Tab-accept.\",\n\n\t\tparameters: suggestNextSchema,\n\n\t\tpromptSnippet: \"Suggest a next command (shown as ghost text the user can Tab-accept)\",\n\n\t\tpromptGuidelines: [\n\t\t\t\"Call suggest_next at the end of your turn when there's a clear next action the user might want\",\n\t\t\t\"Use full command syntax: /skill:name args, /compact, etc.\",\n\t\t\t\"Only suggest one command — pick the most likely next step\",\n\t\t\t\"Don't suggest if the conversation is open-ended with no obvious next action\",\n\t\t],\n\n\t\tasync execute(_toolCallId, { command: rawCommand }: SuggestNextInput, _signal?, _onUpdate?, _ctx?) {\n\t\t\t// Strip control characters (newlines, tabs, etc.) that would corrupt TUI rendering\n\t\t\tconst command = rawCommand?.replace(/[\\x00-\\x1f\\x7f]/g, \"\").trim();\n\t\t\tif (!command || !command.startsWith(\"/\")) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\" as const, text: \"Error: command must start with /\" }],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tonSuggest(command);\n\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\" as const, text: `Suggestion registered: ${command}` }],\n\t\t\t\tdetails: { suggestion: command },\n\t\t\t};\n\t\t},\n\n\t\trenderCall(args, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatSuggestNextCall(args, theme));\n\t\t\treturn text;\n\t\t},\n\n\t\trenderResult(result, _options, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatSuggestNextResult(result as any, theme));\n\t\t\treturn text;\n\t\t},\n\t};\n}\n"]}
1
+ {"version":3,"file":"suggest-next.js","sourceRoot":"","sources":["../../../src/core/tools/suggest-next.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Suggest next command tool.\n *\n * Allows the agent to suggest a command the user might want to run next.\n * The suggestion is shown as ghost text in the editor prompt (Tab to accept).\n */\n\nimport { Container, Markdown, Text } from \"@dreb/tui\";\nimport { type Static, Type } from \"@sinclair/typebox\";\nimport { getMarkdownTheme } from \"../../modes/interactive/theme/theme.js\";\nimport type { ToolDefinition } from \"../extensions/types.js\";\n\n// ============================================================================\n// Types\n\nexport interface SuggestNextDetails {\n\tsuggestion: string;\n\tsummary?: string;\n}\n\nexport type SuggestNextCallback = (suggestion: string) => void;\n\n// ============================================================================\n// Schema\n\nconst suggestNextSchema = Type.Object({\n\tcommand: Type.String({\n\t\tdescription: \"The suggested command for the user to run next (e.g. /skill:mach6-push, /compact)\",\n\t}),\n\tsummary: Type.Optional(\n\t\tType.String({\n\t\t\tdescription:\n\t\t\t\t\"Brief markdown summary of the work done this turn. Displayed to the user as the final message before the suggestion.\",\n\t\t}),\n\t),\n});\n\nexport type SuggestNextInput = Static<typeof suggestNextSchema>;\n\n// ============================================================================\n// Render helpers\n\nfunction formatSuggestNextCall(args: { command?: string } | undefined, theme: any): string {\n\tconst cmd = args?.command ?? \"\";\n\treturn `${theme.fg(\"toolTitle\", theme.bold(\"suggest_next\"))} ${theme.fg(\"accent\", cmd)}`;\n}\n\n// ============================================================================\n// Tool definition factory\n\nexport function createSuggestNextToolDefinition(\n\tonSuggest: SuggestNextCallback,\n): ToolDefinition<typeof suggestNextSchema, SuggestNextDetails | undefined> {\n\treturn {\n\t\tname: \"suggest_next\",\n\t\tlabel: \"suggest_next\",\n\t\tdescription:\n\t\t\t\"Suggest a command for the user to run next. Shows as ghost text in the prompt that the user can Tab-accept.\",\n\n\t\tparameters: suggestNextSchema,\n\n\t\tpromptSnippet: \"Suggest a next command (shown as ghost text the user can Tab-accept)\",\n\n\t\tpromptGuidelines: [\n\t\t\t\"Call suggest_next at the end of your turn when there's a clear next action the user might want\",\n\t\t\t\"Use full command syntax: /skill:name args, /compact, etc.\",\n\t\t\t\"Only suggest one command — pick the most likely next step\",\n\t\t\t\"Don't suggest if the conversation is open-ended with no obvious next action\",\n\t\t\t\"Include a brief summary of work done in the `summary` parameter — this is your last chance to communicate before the turn ends\",\n\t\t\t\"Calling this tool ends your turn automatically — do not call wait afterwards\",\n\t\t],\n\n\t\tasync execute(_toolCallId, { command: rawCommand, summary }: SuggestNextInput, _signal?, _onUpdate?, _ctx?) {\n\t\t\t// Strip control characters (newlines, tabs, etc.) that would corrupt TUI rendering\n\t\t\tconst command = rawCommand?.replace(/[\\x00-\\x1f\\x7f]/g, \"\").trim();\n\t\t\tif (!command || !command.startsWith(\"/\")) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\" as const, text: \"Error: command must start with /\" }],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tonSuggest(command);\n\n\t\t\t// Convert literal \\n sequences to actual newlines (LLMs emit these in XML tool calls),\n\t\t\t// then strip control characters (preserve only newlines for markdown)\n\t\t\tconst sanitizedSummary =\n\t\t\t\tsummary\n\t\t\t\t\t?.replace(/\\\\n/g, \"\\n\")\n\t\t\t\t\t.replace(/[\\x00-\\x09\\x0b-\\x1f\\x7f]/g, \"\")\n\t\t\t\t\t.trim() || undefined;\n\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\" as const, text: `Suggestion registered: ${command}` }],\n\t\t\t\tdetails: { suggestion: command, summary: sanitizedSummary },\n\t\t\t\tendTurn: true,\n\t\t\t};\n\t\t},\n\n\t\trenderCall(args, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatSuggestNextCall(args, theme));\n\t\t\treturn text;\n\t\t},\n\n\t\trenderResult(result, _options, theme, context) {\n\t\t\tconst details = (result as any).details as SuggestNextDetails | undefined;\n\t\t\tif (!details) {\n\t\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\t\tconst content = result.content?.[0];\n\t\t\t\tconst msg = content?.type === \"text\" && content.text ? content.text : \"\";\n\t\t\t\ttext.setText(theme.fg(\"toolOutput\", msg));\n\t\t\t\treturn text;\n\t\t\t}\n\n\t\t\tif (details.summary) {\n\t\t\t\tconst container = (context.lastComponent as Container | undefined) ?? new Container();\n\t\t\t\tcontainer.clear();\n\t\t\t\tcontainer.addChild(new Markdown(details.summary, 0, 0, getMarkdownTheme()));\n\t\t\t\tcontainer.addChild(new Text(theme.fg(\"toolOutput\", `→ ${details.suggestion}`), 0, 0));\n\t\t\t\treturn container;\n\t\t\t}\n\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(theme.fg(\"toolOutput\", `→ ${details.suggestion}`));\n\t\t\treturn text;\n\t\t},\n\t};\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"terminal-render.d.ts","sourceRoot":"","sources":["../../../src/core/tools/terminal-render.ts"],"names":[],"mappings":"AASA;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CA+F/D;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAYxD","sourcesContent":["import { TerminalTextRender } from \"terminal-render\";\n\n/**\n * Maximum row or column value allowed in ANSI cursor positioning sequences.\n * Anything larger gets capped to this value to prevent memory exhaustion from\n * malicious sequences like `ESC[9999999;1H`.\n */\nconst MAX_CURSOR_POSITION = 5000;\n\n/**\n * Sanitize ANSI cursor positioning sequences to prevent memory exhaustion.\n *\n * Sequences like `ESC[9999999;1H` can cause TerminalTextRender to allocate\n * millions of empty lines. This function caps row/column values in:\n * - CUP (cursor position): ESC[<row>;<col>H or ESC[<row>;<col>f\n * - CUU/CUD/CUF/CUB (cursor movement): ESC[<n>A/B/C/D\n * - CNL (cursor next line): ESC[<n>E\n * - VPA (vertical position absolute): ESC[<row>d\n * - HPA (horizontal position absolute): ESC[<col>G or ESC[<col>`\n *\n * In addition to per-sequence caps, this tracks cumulative cursor position\n * to prevent accumulation attacks where many sequences each at the per-sequence\n * cap combine to push the cursor to millions of rows/columns, triggering OOM\n * via array allocation in the terminal renderer.\n */\nexport function sanitizeCursorPositioning(input: string): string {\n\t// Track cumulative cursor position across sequences. An attacker can send\n\t// thousands of ESC[5000B sequences, each passing the per-sequence cap but\n\t// accumulating to ~55M rows. By tracking position, we clamp movement that\n\t// would exceed the limit.\n\tlet cursorRow = 0;\n\tlet cursorCol = 0;\n\n\tconst parsePart = (p: string | undefined): number | null => {\n\t\tif (p === undefined) return null;\n\t\tconst n = Number.parseInt(p, 10);\n\t\treturn Number.isNaN(n) ? null : n;\n\t};\n\n\t// Cap all numeric params individually and rebuild the param string\n\tconst capParams = (rawParts: string[]) =>\n\t\trawParts\n\t\t\t.map((p: string) => {\n\t\t\t\tconst n = Number.parseInt(p, 10);\n\t\t\t\tif (Number.isNaN(n)) return p;\n\t\t\t\treturn String(Math.min(n, MAX_CURSOR_POSITION));\n\t\t\t})\n\t\t\t.join(\";\");\n\n\t// Match CSI sequences: ESC[ followed by params and a final byte\n\t// Covers H, f (CUP), A/B/C/D (movement), E (CNL), d (VPA), G/` (HPA), r (scroll region)\n\treturn input.replace(/\\x1b\\[([0-9;]*)([ABCDEGHfdr`])/g, (_match, params: string, cmd: string) => {\n\t\tconst rawParts = params.split(\";\");\n\n\t\tswitch (cmd) {\n\t\t\tcase \"B\": // Cursor down by n (default 1)\n\t\t\tcase \"E\": {\n\t\t\t\t// Cursor next line by n (default 1)\n\t\t\t\tconst n = parsePart(rawParts[0]) ?? 1;\n\t\t\t\tconst capped = Math.min(n, MAX_CURSOR_POSITION);\n\t\t\t\tconst allowed = Math.max(0, MAX_CURSOR_POSITION - cursorRow);\n\t\t\t\tconst clamped = Math.min(capped, allowed);\n\t\t\t\tcursorRow += clamped;\n\t\t\t\tif (cmd === \"E\") cursorCol = 0;\n\t\t\t\treturn `\\x1b[${clamped}${cmd}`;\n\t\t\t}\n\t\t\tcase \"A\": {\n\t\t\t\t// Cursor up by n (default 1)\n\t\t\t\tconst n = parsePart(rawParts[0]) ?? 1;\n\t\t\t\tconst capped = Math.min(n, MAX_CURSOR_POSITION);\n\t\t\t\tcursorRow = Math.max(0, cursorRow - capped);\n\t\t\t\treturn `\\x1b[${capped}A`;\n\t\t\t}\n\t\t\tcase \"C\": {\n\t\t\t\t// Cursor forward by n (default 1)\n\t\t\t\tconst n = parsePart(rawParts[0]) ?? 1;\n\t\t\t\tconst capped = Math.min(n, MAX_CURSOR_POSITION);\n\t\t\t\tconst allowed = Math.max(0, MAX_CURSOR_POSITION - cursorCol);\n\t\t\t\tconst clamped = Math.min(capped, allowed);\n\t\t\t\tcursorCol += clamped;\n\t\t\t\treturn `\\x1b[${clamped}C`;\n\t\t\t}\n\t\t\tcase \"D\": {\n\t\t\t\t// Cursor back by n (default 1)\n\t\t\t\tconst n = parsePart(rawParts[0]) ?? 1;\n\t\t\t\tconst capped = Math.min(n, MAX_CURSOR_POSITION);\n\t\t\t\tcursorCol = Math.max(0, cursorCol - capped);\n\t\t\t\treturn `\\x1b[${capped}D`;\n\t\t\t}\n\t\t\tcase \"H\":\n\t\t\tcase \"f\": {\n\t\t\t\t// Absolute cursor position: ESC[row;colH\n\t\t\t\tconst row = parsePart(rawParts[0]);\n\t\t\t\tconst col = parsePart(rawParts[1]);\n\t\t\t\tcursorRow = row != null ? Math.min(row, MAX_CURSOR_POSITION) : 1;\n\t\t\t\tcursorCol = col != null ? Math.min(col, MAX_CURSOR_POSITION) : 1;\n\t\t\t\treturn `\\x1b[${capParams(rawParts)}${cmd}`;\n\t\t\t}\n\t\t\tcase \"d\": {\n\t\t\t\t// VPA - vertical position absolute\n\t\t\t\tconst row = parsePart(rawParts[0]);\n\t\t\t\tcursorRow = row != null ? Math.min(row, MAX_CURSOR_POSITION) : 1;\n\t\t\t\treturn `\\x1b[${capParams(rawParts)}d`;\n\t\t\t}\n\t\t\tcase \"G\":\n\t\t\tcase \"`\": {\n\t\t\t\t// HPA - horizontal position absolute\n\t\t\t\tconst col = parsePart(rawParts[0]);\n\t\t\t\tcursorCol = col != null ? Math.min(col, MAX_CURSOR_POSITION) : 1;\n\t\t\t\treturn `\\x1b[${capParams(rawParts)}${cmd}`;\n\t\t\t}\n\t\t\tcase \"r\": {\n\t\t\t\t// Set scroll region - cap params, no position tracking\n\t\t\t\treturn `\\x1b[${capParams(rawParts)}r`;\n\t\t\t}\n\t\t\tdefault: {\n\t\t\t\treturn `\\x1b[${capParams(rawParts)}${cmd}`;\n\t\t\t}\n\t\t}\n\t});\n}\n\n/**\n * Process raw terminal output through a terminal renderer, producing the clean\n * text a human would actually see on screen.\n *\n * This handles:\n * - Carriage returns (`\\r`) — progress bars overwrite the current line\n * - ANSI cursor movement — up, down, forward, backward, absolute positioning\n * - Backspace (`\\b`) — moves cursor back one position\n * - Line clearing / screen clearing escape sequences\n * - Tab stops\n *\n * The result is the final rendered state of the terminal — identical to what\n * a human would see in a real terminal after the output completes.\n *\n * Safety:\n * - Cursor positioning values are capped to prevent memory exhaustion\n * - Errors fall back to returning the raw input\n */\nexport function renderTerminalOutput(raw: string): string {\n\tif (!raw) return raw;\n\ttry {\n\t\tconst sanitized = sanitizeCursorPositioning(raw);\n\t\tconst renderer = new TerminalTextRender();\n\t\trenderer.write(sanitized);\n\t\treturn renderer.render();\n\t} catch (err) {\n\t\tconst detail = err instanceof Error ? err.message : String(err);\n\t\tconsole.error(`[dreb] terminal-render fallback: TerminalTextRender failed (${detail}), returning raw output`);\n\t\treturn raw;\n\t}\n}\n"]}
1
+ {"version":3,"file":"terminal-render.d.ts","sourceRoot":"","sources":["../../../src/core/tools/terminal-render.ts"],"names":[],"mappings":"AAUA;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CA+F/D;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAYxD","sourcesContent":["import { TerminalTextRender } from \"terminal-render\";\nimport { log } from \"../logger.js\";\n\n/**\n * Maximum row or column value allowed in ANSI cursor positioning sequences.\n * Anything larger gets capped to this value to prevent memory exhaustion from\n * malicious sequences like `ESC[9999999;1H`.\n */\nconst MAX_CURSOR_POSITION = 5000;\n\n/**\n * Sanitize ANSI cursor positioning sequences to prevent memory exhaustion.\n *\n * Sequences like `ESC[9999999;1H` can cause TerminalTextRender to allocate\n * millions of empty lines. This function caps row/column values in:\n * - CUP (cursor position): ESC[<row>;<col>H or ESC[<row>;<col>f\n * - CUU/CUD/CUF/CUB (cursor movement): ESC[<n>A/B/C/D\n * - CNL (cursor next line): ESC[<n>E\n * - VPA (vertical position absolute): ESC[<row>d\n * - HPA (horizontal position absolute): ESC[<col>G or ESC[<col>`\n *\n * In addition to per-sequence caps, this tracks cumulative cursor position\n * to prevent accumulation attacks where many sequences each at the per-sequence\n * cap combine to push the cursor to millions of rows/columns, triggering OOM\n * via array allocation in the terminal renderer.\n */\nexport function sanitizeCursorPositioning(input: string): string {\n\t// Track cumulative cursor position across sequences. An attacker can send\n\t// thousands of ESC[5000B sequences, each passing the per-sequence cap but\n\t// accumulating to ~55M rows. By tracking position, we clamp movement that\n\t// would exceed the limit.\n\tlet cursorRow = 0;\n\tlet cursorCol = 0;\n\n\tconst parsePart = (p: string | undefined): number | null => {\n\t\tif (p === undefined) return null;\n\t\tconst n = Number.parseInt(p, 10);\n\t\treturn Number.isNaN(n) ? null : n;\n\t};\n\n\t// Cap all numeric params individually and rebuild the param string\n\tconst capParams = (rawParts: string[]) =>\n\t\trawParts\n\t\t\t.map((p: string) => {\n\t\t\t\tconst n = Number.parseInt(p, 10);\n\t\t\t\tif (Number.isNaN(n)) return p;\n\t\t\t\treturn String(Math.min(n, MAX_CURSOR_POSITION));\n\t\t\t})\n\t\t\t.join(\";\");\n\n\t// Match CSI sequences: ESC[ followed by params and a final byte\n\t// Covers H, f (CUP), A/B/C/D (movement), E (CNL), d (VPA), G/` (HPA), r (scroll region)\n\treturn input.replace(/\\x1b\\[([0-9;]*)([ABCDEGHfdr`])/g, (_match, params: string, cmd: string) => {\n\t\tconst rawParts = params.split(\";\");\n\n\t\tswitch (cmd) {\n\t\t\tcase \"B\": // Cursor down by n (default 1)\n\t\t\tcase \"E\": {\n\t\t\t\t// Cursor next line by n (default 1)\n\t\t\t\tconst n = parsePart(rawParts[0]) ?? 1;\n\t\t\t\tconst capped = Math.min(n, MAX_CURSOR_POSITION);\n\t\t\t\tconst allowed = Math.max(0, MAX_CURSOR_POSITION - cursorRow);\n\t\t\t\tconst clamped = Math.min(capped, allowed);\n\t\t\t\tcursorRow += clamped;\n\t\t\t\tif (cmd === \"E\") cursorCol = 0;\n\t\t\t\treturn `\\x1b[${clamped}${cmd}`;\n\t\t\t}\n\t\t\tcase \"A\": {\n\t\t\t\t// Cursor up by n (default 1)\n\t\t\t\tconst n = parsePart(rawParts[0]) ?? 1;\n\t\t\t\tconst capped = Math.min(n, MAX_CURSOR_POSITION);\n\t\t\t\tcursorRow = Math.max(0, cursorRow - capped);\n\t\t\t\treturn `\\x1b[${capped}A`;\n\t\t\t}\n\t\t\tcase \"C\": {\n\t\t\t\t// Cursor forward by n (default 1)\n\t\t\t\tconst n = parsePart(rawParts[0]) ?? 1;\n\t\t\t\tconst capped = Math.min(n, MAX_CURSOR_POSITION);\n\t\t\t\tconst allowed = Math.max(0, MAX_CURSOR_POSITION - cursorCol);\n\t\t\t\tconst clamped = Math.min(capped, allowed);\n\t\t\t\tcursorCol += clamped;\n\t\t\t\treturn `\\x1b[${clamped}C`;\n\t\t\t}\n\t\t\tcase \"D\": {\n\t\t\t\t// Cursor back by n (default 1)\n\t\t\t\tconst n = parsePart(rawParts[0]) ?? 1;\n\t\t\t\tconst capped = Math.min(n, MAX_CURSOR_POSITION);\n\t\t\t\tcursorCol = Math.max(0, cursorCol - capped);\n\t\t\t\treturn `\\x1b[${capped}D`;\n\t\t\t}\n\t\t\tcase \"H\":\n\t\t\tcase \"f\": {\n\t\t\t\t// Absolute cursor position: ESC[row;colH\n\t\t\t\tconst row = parsePart(rawParts[0]);\n\t\t\t\tconst col = parsePart(rawParts[1]);\n\t\t\t\tcursorRow = row != null ? Math.min(row, MAX_CURSOR_POSITION) : 1;\n\t\t\t\tcursorCol = col != null ? Math.min(col, MAX_CURSOR_POSITION) : 1;\n\t\t\t\treturn `\\x1b[${capParams(rawParts)}${cmd}`;\n\t\t\t}\n\t\t\tcase \"d\": {\n\t\t\t\t// VPA - vertical position absolute\n\t\t\t\tconst row = parsePart(rawParts[0]);\n\t\t\t\tcursorRow = row != null ? Math.min(row, MAX_CURSOR_POSITION) : 1;\n\t\t\t\treturn `\\x1b[${capParams(rawParts)}d`;\n\t\t\t}\n\t\t\tcase \"G\":\n\t\t\tcase \"`\": {\n\t\t\t\t// HPA - horizontal position absolute\n\t\t\t\tconst col = parsePart(rawParts[0]);\n\t\t\t\tcursorCol = col != null ? Math.min(col, MAX_CURSOR_POSITION) : 1;\n\t\t\t\treturn `\\x1b[${capParams(rawParts)}${cmd}`;\n\t\t\t}\n\t\t\tcase \"r\": {\n\t\t\t\t// Set scroll region - cap params, no position tracking\n\t\t\t\treturn `\\x1b[${capParams(rawParts)}r`;\n\t\t\t}\n\t\t\tdefault: {\n\t\t\t\treturn `\\x1b[${capParams(rawParts)}${cmd}`;\n\t\t\t}\n\t\t}\n\t});\n}\n\n/**\n * Process raw terminal output through a terminal renderer, producing the clean\n * text a human would actually see on screen.\n *\n * This handles:\n * - Carriage returns (`\\r`) — progress bars overwrite the current line\n * - ANSI cursor movement — up, down, forward, backward, absolute positioning\n * - Backspace (`\\b`) — moves cursor back one position\n * - Line clearing / screen clearing escape sequences\n * - Tab stops\n *\n * The result is the final rendered state of the terminal — identical to what\n * a human would see in a real terminal after the output completes.\n *\n * Safety:\n * - Cursor positioning values are capped to prevent memory exhaustion\n * - Errors fall back to returning the raw input\n */\nexport function renderTerminalOutput(raw: string): string {\n\tif (!raw) return raw;\n\ttry {\n\t\tconst sanitized = sanitizeCursorPositioning(raw);\n\t\tconst renderer = new TerminalTextRender();\n\t\trenderer.write(sanitized);\n\t\treturn renderer.render();\n\t} catch (err) {\n\t\tconst detail = err instanceof Error ? err.message : String(err);\n\t\tlog.debug(`[dreb] terminal-render fallback: TerminalTextRender failed (${detail}), returning raw output`);\n\t\treturn raw;\n\t}\n}\n"]}
@@ -1,4 +1,5 @@
1
1
  import { TerminalTextRender } from "terminal-render";
2
+ import { log } from "../logger.js";
2
3
  /**
3
4
  * Maximum row or column value allowed in ANSI cursor positioning sequences.
4
5
  * Anything larger gets capped to this value to prevent memory exhaustion from
@@ -144,7 +145,7 @@ export function renderTerminalOutput(raw) {
144
145
  }
145
146
  catch (err) {
146
147
  const detail = err instanceof Error ? err.message : String(err);
147
- console.error(`[dreb] terminal-render fallback: TerminalTextRender failed (${detail}), returning raw output`);
148
+ log.debug(`[dreb] terminal-render fallback: TerminalTextRender failed (${detail}), returning raw output`);
148
149
  return raw;
149
150
  }
150
151
  }
@@ -1 +1 @@
1
- {"version":3,"file":"terminal-render.js","sourceRoot":"","sources":["../../../src/core/tools/terminal-render.ts"],"names":[],"mappings":"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","sourcesContent":["import { TerminalTextRender } from \"terminal-render\";\n\n/**\n * Maximum row or column value allowed in ANSI cursor positioning sequences.\n * Anything larger gets capped to this value to prevent memory exhaustion from\n * malicious sequences like `ESC[9999999;1H`.\n */\nconst MAX_CURSOR_POSITION = 5000;\n\n/**\n * Sanitize ANSI cursor positioning sequences to prevent memory exhaustion.\n *\n * Sequences like `ESC[9999999;1H` can cause TerminalTextRender to allocate\n * millions of empty lines. This function caps row/column values in:\n * - CUP (cursor position): ESC[<row>;<col>H or ESC[<row>;<col>f\n * - CUU/CUD/CUF/CUB (cursor movement): ESC[<n>A/B/C/D\n * - CNL (cursor next line): ESC[<n>E\n * - VPA (vertical position absolute): ESC[<row>d\n * - HPA (horizontal position absolute): ESC[<col>G or ESC[<col>`\n *\n * In addition to per-sequence caps, this tracks cumulative cursor position\n * to prevent accumulation attacks where many sequences each at the per-sequence\n * cap combine to push the cursor to millions of rows/columns, triggering OOM\n * via array allocation in the terminal renderer.\n */\nexport function sanitizeCursorPositioning(input: string): string {\n\t// Track cumulative cursor position across sequences. An attacker can send\n\t// thousands of ESC[5000B sequences, each passing the per-sequence cap but\n\t// accumulating to ~55M rows. By tracking position, we clamp movement that\n\t// would exceed the limit.\n\tlet cursorRow = 0;\n\tlet cursorCol = 0;\n\n\tconst parsePart = (p: string | undefined): number | null => {\n\t\tif (p === undefined) return null;\n\t\tconst n = Number.parseInt(p, 10);\n\t\treturn Number.isNaN(n) ? null : n;\n\t};\n\n\t// Cap all numeric params individually and rebuild the param string\n\tconst capParams = (rawParts: string[]) =>\n\t\trawParts\n\t\t\t.map((p: string) => {\n\t\t\t\tconst n = Number.parseInt(p, 10);\n\t\t\t\tif (Number.isNaN(n)) return p;\n\t\t\t\treturn String(Math.min(n, MAX_CURSOR_POSITION));\n\t\t\t})\n\t\t\t.join(\";\");\n\n\t// Match CSI sequences: ESC[ followed by params and a final byte\n\t// Covers H, f (CUP), A/B/C/D (movement), E (CNL), d (VPA), G/` (HPA), r (scroll region)\n\treturn input.replace(/\\x1b\\[([0-9;]*)([ABCDEGHfdr`])/g, (_match, params: string, cmd: string) => {\n\t\tconst rawParts = params.split(\";\");\n\n\t\tswitch (cmd) {\n\t\t\tcase \"B\": // Cursor down by n (default 1)\n\t\t\tcase \"E\": {\n\t\t\t\t// Cursor next line by n (default 1)\n\t\t\t\tconst n = parsePart(rawParts[0]) ?? 1;\n\t\t\t\tconst capped = Math.min(n, MAX_CURSOR_POSITION);\n\t\t\t\tconst allowed = Math.max(0, MAX_CURSOR_POSITION - cursorRow);\n\t\t\t\tconst clamped = Math.min(capped, allowed);\n\t\t\t\tcursorRow += clamped;\n\t\t\t\tif (cmd === \"E\") cursorCol = 0;\n\t\t\t\treturn `\\x1b[${clamped}${cmd}`;\n\t\t\t}\n\t\t\tcase \"A\": {\n\t\t\t\t// Cursor up by n (default 1)\n\t\t\t\tconst n = parsePart(rawParts[0]) ?? 1;\n\t\t\t\tconst capped = Math.min(n, MAX_CURSOR_POSITION);\n\t\t\t\tcursorRow = Math.max(0, cursorRow - capped);\n\t\t\t\treturn `\\x1b[${capped}A`;\n\t\t\t}\n\t\t\tcase \"C\": {\n\t\t\t\t// Cursor forward by n (default 1)\n\t\t\t\tconst n = parsePart(rawParts[0]) ?? 1;\n\t\t\t\tconst capped = Math.min(n, MAX_CURSOR_POSITION);\n\t\t\t\tconst allowed = Math.max(0, MAX_CURSOR_POSITION - cursorCol);\n\t\t\t\tconst clamped = Math.min(capped, allowed);\n\t\t\t\tcursorCol += clamped;\n\t\t\t\treturn `\\x1b[${clamped}C`;\n\t\t\t}\n\t\t\tcase \"D\": {\n\t\t\t\t// Cursor back by n (default 1)\n\t\t\t\tconst n = parsePart(rawParts[0]) ?? 1;\n\t\t\t\tconst capped = Math.min(n, MAX_CURSOR_POSITION);\n\t\t\t\tcursorCol = Math.max(0, cursorCol - capped);\n\t\t\t\treturn `\\x1b[${capped}D`;\n\t\t\t}\n\t\t\tcase \"H\":\n\t\t\tcase \"f\": {\n\t\t\t\t// Absolute cursor position: ESC[row;colH\n\t\t\t\tconst row = parsePart(rawParts[0]);\n\t\t\t\tconst col = parsePart(rawParts[1]);\n\t\t\t\tcursorRow = row != null ? Math.min(row, MAX_CURSOR_POSITION) : 1;\n\t\t\t\tcursorCol = col != null ? Math.min(col, MAX_CURSOR_POSITION) : 1;\n\t\t\t\treturn `\\x1b[${capParams(rawParts)}${cmd}`;\n\t\t\t}\n\t\t\tcase \"d\": {\n\t\t\t\t// VPA - vertical position absolute\n\t\t\t\tconst row = parsePart(rawParts[0]);\n\t\t\t\tcursorRow = row != null ? Math.min(row, MAX_CURSOR_POSITION) : 1;\n\t\t\t\treturn `\\x1b[${capParams(rawParts)}d`;\n\t\t\t}\n\t\t\tcase \"G\":\n\t\t\tcase \"`\": {\n\t\t\t\t// HPA - horizontal position absolute\n\t\t\t\tconst col = parsePart(rawParts[0]);\n\t\t\t\tcursorCol = col != null ? Math.min(col, MAX_CURSOR_POSITION) : 1;\n\t\t\t\treturn `\\x1b[${capParams(rawParts)}${cmd}`;\n\t\t\t}\n\t\t\tcase \"r\": {\n\t\t\t\t// Set scroll region - cap params, no position tracking\n\t\t\t\treturn `\\x1b[${capParams(rawParts)}r`;\n\t\t\t}\n\t\t\tdefault: {\n\t\t\t\treturn `\\x1b[${capParams(rawParts)}${cmd}`;\n\t\t\t}\n\t\t}\n\t});\n}\n\n/**\n * Process raw terminal output through a terminal renderer, producing the clean\n * text a human would actually see on screen.\n *\n * This handles:\n * - Carriage returns (`\\r`) — progress bars overwrite the current line\n * - ANSI cursor movement — up, down, forward, backward, absolute positioning\n * - Backspace (`\\b`) — moves cursor back one position\n * - Line clearing / screen clearing escape sequences\n * - Tab stops\n *\n * The result is the final rendered state of the terminal — identical to what\n * a human would see in a real terminal after the output completes.\n *\n * Safety:\n * - Cursor positioning values are capped to prevent memory exhaustion\n * - Errors fall back to returning the raw input\n */\nexport function renderTerminalOutput(raw: string): string {\n\tif (!raw) return raw;\n\ttry {\n\t\tconst sanitized = sanitizeCursorPositioning(raw);\n\t\tconst renderer = new TerminalTextRender();\n\t\trenderer.write(sanitized);\n\t\treturn renderer.render();\n\t} catch (err) {\n\t\tconst detail = err instanceof Error ? err.message : String(err);\n\t\tconsole.error(`[dreb] terminal-render fallback: TerminalTextRender failed (${detail}), returning raw output`);\n\t\treturn raw;\n\t}\n}\n"]}
1
+ {"version":3,"file":"terminal-render.js","sourceRoot":"","sources":["../../../src/core/tools/terminal-render.ts"],"names":[],"mappings":"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","sourcesContent":["import { TerminalTextRender } from \"terminal-render\";\nimport { log } from \"../logger.js\";\n\n/**\n * Maximum row or column value allowed in ANSI cursor positioning sequences.\n * Anything larger gets capped to this value to prevent memory exhaustion from\n * malicious sequences like `ESC[9999999;1H`.\n */\nconst MAX_CURSOR_POSITION = 5000;\n\n/**\n * Sanitize ANSI cursor positioning sequences to prevent memory exhaustion.\n *\n * Sequences like `ESC[9999999;1H` can cause TerminalTextRender to allocate\n * millions of empty lines. This function caps row/column values in:\n * - CUP (cursor position): ESC[<row>;<col>H or ESC[<row>;<col>f\n * - CUU/CUD/CUF/CUB (cursor movement): ESC[<n>A/B/C/D\n * - CNL (cursor next line): ESC[<n>E\n * - VPA (vertical position absolute): ESC[<row>d\n * - HPA (horizontal position absolute): ESC[<col>G or ESC[<col>`\n *\n * In addition to per-sequence caps, this tracks cumulative cursor position\n * to prevent accumulation attacks where many sequences each at the per-sequence\n * cap combine to push the cursor to millions of rows/columns, triggering OOM\n * via array allocation in the terminal renderer.\n */\nexport function sanitizeCursorPositioning(input: string): string {\n\t// Track cumulative cursor position across sequences. An attacker can send\n\t// thousands of ESC[5000B sequences, each passing the per-sequence cap but\n\t// accumulating to ~55M rows. By tracking position, we clamp movement that\n\t// would exceed the limit.\n\tlet cursorRow = 0;\n\tlet cursorCol = 0;\n\n\tconst parsePart = (p: string | undefined): number | null => {\n\t\tif (p === undefined) return null;\n\t\tconst n = Number.parseInt(p, 10);\n\t\treturn Number.isNaN(n) ? null : n;\n\t};\n\n\t// Cap all numeric params individually and rebuild the param string\n\tconst capParams = (rawParts: string[]) =>\n\t\trawParts\n\t\t\t.map((p: string) => {\n\t\t\t\tconst n = Number.parseInt(p, 10);\n\t\t\t\tif (Number.isNaN(n)) return p;\n\t\t\t\treturn String(Math.min(n, MAX_CURSOR_POSITION));\n\t\t\t})\n\t\t\t.join(\";\");\n\n\t// Match CSI sequences: ESC[ followed by params and a final byte\n\t// Covers H, f (CUP), A/B/C/D (movement), E (CNL), d (VPA), G/` (HPA), r (scroll region)\n\treturn input.replace(/\\x1b\\[([0-9;]*)([ABCDEGHfdr`])/g, (_match, params: string, cmd: string) => {\n\t\tconst rawParts = params.split(\";\");\n\n\t\tswitch (cmd) {\n\t\t\tcase \"B\": // Cursor down by n (default 1)\n\t\t\tcase \"E\": {\n\t\t\t\t// Cursor next line by n (default 1)\n\t\t\t\tconst n = parsePart(rawParts[0]) ?? 1;\n\t\t\t\tconst capped = Math.min(n, MAX_CURSOR_POSITION);\n\t\t\t\tconst allowed = Math.max(0, MAX_CURSOR_POSITION - cursorRow);\n\t\t\t\tconst clamped = Math.min(capped, allowed);\n\t\t\t\tcursorRow += clamped;\n\t\t\t\tif (cmd === \"E\") cursorCol = 0;\n\t\t\t\treturn `\\x1b[${clamped}${cmd}`;\n\t\t\t}\n\t\t\tcase \"A\": {\n\t\t\t\t// Cursor up by n (default 1)\n\t\t\t\tconst n = parsePart(rawParts[0]) ?? 1;\n\t\t\t\tconst capped = Math.min(n, MAX_CURSOR_POSITION);\n\t\t\t\tcursorRow = Math.max(0, cursorRow - capped);\n\t\t\t\treturn `\\x1b[${capped}A`;\n\t\t\t}\n\t\t\tcase \"C\": {\n\t\t\t\t// Cursor forward by n (default 1)\n\t\t\t\tconst n = parsePart(rawParts[0]) ?? 1;\n\t\t\t\tconst capped = Math.min(n, MAX_CURSOR_POSITION);\n\t\t\t\tconst allowed = Math.max(0, MAX_CURSOR_POSITION - cursorCol);\n\t\t\t\tconst clamped = Math.min(capped, allowed);\n\t\t\t\tcursorCol += clamped;\n\t\t\t\treturn `\\x1b[${clamped}C`;\n\t\t\t}\n\t\t\tcase \"D\": {\n\t\t\t\t// Cursor back by n (default 1)\n\t\t\t\tconst n = parsePart(rawParts[0]) ?? 1;\n\t\t\t\tconst capped = Math.min(n, MAX_CURSOR_POSITION);\n\t\t\t\tcursorCol = Math.max(0, cursorCol - capped);\n\t\t\t\treturn `\\x1b[${capped}D`;\n\t\t\t}\n\t\t\tcase \"H\":\n\t\t\tcase \"f\": {\n\t\t\t\t// Absolute cursor position: ESC[row;colH\n\t\t\t\tconst row = parsePart(rawParts[0]);\n\t\t\t\tconst col = parsePart(rawParts[1]);\n\t\t\t\tcursorRow = row != null ? Math.min(row, MAX_CURSOR_POSITION) : 1;\n\t\t\t\tcursorCol = col != null ? Math.min(col, MAX_CURSOR_POSITION) : 1;\n\t\t\t\treturn `\\x1b[${capParams(rawParts)}${cmd}`;\n\t\t\t}\n\t\t\tcase \"d\": {\n\t\t\t\t// VPA - vertical position absolute\n\t\t\t\tconst row = parsePart(rawParts[0]);\n\t\t\t\tcursorRow = row != null ? Math.min(row, MAX_CURSOR_POSITION) : 1;\n\t\t\t\treturn `\\x1b[${capParams(rawParts)}d`;\n\t\t\t}\n\t\t\tcase \"G\":\n\t\t\tcase \"`\": {\n\t\t\t\t// HPA - horizontal position absolute\n\t\t\t\tconst col = parsePart(rawParts[0]);\n\t\t\t\tcursorCol = col != null ? Math.min(col, MAX_CURSOR_POSITION) : 1;\n\t\t\t\treturn `\\x1b[${capParams(rawParts)}${cmd}`;\n\t\t\t}\n\t\t\tcase \"r\": {\n\t\t\t\t// Set scroll region - cap params, no position tracking\n\t\t\t\treturn `\\x1b[${capParams(rawParts)}r`;\n\t\t\t}\n\t\t\tdefault: {\n\t\t\t\treturn `\\x1b[${capParams(rawParts)}${cmd}`;\n\t\t\t}\n\t\t}\n\t});\n}\n\n/**\n * Process raw terminal output through a terminal renderer, producing the clean\n * text a human would actually see on screen.\n *\n * This handles:\n * - Carriage returns (`\\r`) — progress bars overwrite the current line\n * - ANSI cursor movement — up, down, forward, backward, absolute positioning\n * - Backspace (`\\b`) — moves cursor back one position\n * - Line clearing / screen clearing escape sequences\n * - Tab stops\n *\n * The result is the final rendered state of the terminal — identical to what\n * a human would see in a real terminal after the output completes.\n *\n * Safety:\n * - Cursor positioning values are capped to prevent memory exhaustion\n * - Errors fall back to returning the raw input\n */\nexport function renderTerminalOutput(raw: string): string {\n\tif (!raw) return raw;\n\ttry {\n\t\tconst sanitized = sanitizeCursorPositioning(raw);\n\t\tconst renderer = new TerminalTextRender();\n\t\trenderer.write(sanitized);\n\t\treturn renderer.render();\n\t} catch (err) {\n\t\tconst detail = err instanceof Error ? err.message : String(err);\n\t\tlog.debug(`[dreb] terminal-render fallback: TerminalTextRender failed (${detail}), returning raw output`);\n\t\treturn raw;\n\t}\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"web-search-queue.d.ts","sourceRoot":"","sources":["../../../src/core/tools/web-search-queue.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,qBAAqB;IACrC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;CACtB;AAMD,qBAAa,cAAc;IAC1B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IAEtC,YAAY,OAAO,GAAE,qBAA0B,EAI9C;IAEK,OAAO,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAiEjD;CACD","sourcesContent":["import { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport lockfile from \"proper-lockfile\";\nimport { getAgentDir } from \"../../config.js\";\n\nexport interface WebSearchQueueOptions {\n\trateLimitMs?: number;\n\tlockFilePath?: string;\n\ttimeFilePath?: string;\n}\n\ninterface TimestampData {\n\tlastSearchTime: number;\n}\n\nexport class WebSearchQueue {\n\tprivate readonly rateLimitMs: number;\n\tprivate readonly lockFilePath: string;\n\tprivate readonly timeFilePath: string;\n\n\tconstructor(options: WebSearchQueueOptions = {}) {\n\t\tthis.rateLimitMs = options.rateLimitMs ?? 10_000;\n\t\tthis.lockFilePath = options.lockFilePath ?? join(getAgentDir(), \"web-search-queue.lock\");\n\t\tthis.timeFilePath = options.timeFilePath ?? join(getAgentDir(), \"web-search-queue.time\");\n\t}\n\n\tasync enqueue<T>(fn: () => Promise<T>): Promise<T> {\n\t\t// Ensure parent directory and lock file exist (proper-lockfile requires the file to exist)\n\t\ttry {\n\t\t\tconst dir = dirname(this.lockFilePath);\n\t\t\tif (!existsSync(dir)) {\n\t\t\t\tmkdirSync(dir, { recursive: true });\n\t\t\t}\n\t\t\tif (!existsSync(this.lockFilePath)) {\n\t\t\t\twriteFileSync(this.lockFilePath, \"\");\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tconst msg = err instanceof Error ? err.message : String(err);\n\t\t\tthrow new Error(`Failed to initialize web search queue lock file at ${this.lockFilePath}: ${msg}`);\n\t\t}\n\n\t\tlet release: (() => Promise<void>) | undefined;\n\t\ttry {\n\t\t\trelease = await lockfile.lock(this.lockFilePath, {\n\t\t\t\tstale: 60_000,\n\t\t\t\tretries: { retries: 10, factor: 2, minTimeout: 100, maxTimeout: 10_000, randomize: true },\n\t\t\t});\n\n\t\t\t// Read last search timestamp\n\t\t\tlet lastSearchTime = 0;\n\t\t\ttry {\n\t\t\t\tconst raw = readFileSync(this.timeFilePath, \"utf-8\");\n\t\t\t\tconst data = JSON.parse(raw) as TimestampData;\n\t\t\t\tif (typeof data.lastSearchTime === \"number\") {\n\t\t\t\t\tlastSearchTime = data.lastSearchTime;\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// Missing or malformed — treat as 0\n\t\t\t}\n\n\t\t\t// Enforce rate limit\n\t\t\tconst delayNeeded = Math.max(0, this.rateLimitMs - (Date.now() - lastSearchTime));\n\t\t\tif (delayNeeded > 0) {\n\t\t\t\tawait new Promise((resolve) => setTimeout(resolve, delayNeeded));\n\t\t\t}\n\n\t\t\t// Execute the search operation\n\t\t\ttry {\n\t\t\t\treturn await fn();\n\t\t\t} finally {\n\t\t\t\t// Ensure time file directory exists before writing\n\t\t\t\tconst timeDir = dirname(this.timeFilePath);\n\t\t\t\tif (!existsSync(timeDir)) {\n\t\t\t\t\tmkdirSync(timeDir, { recursive: true });\n\t\t\t\t}\n\t\t\t\t// Update timestamp even on error to prevent retry storms\n\t\t\t\ttry {\n\t\t\t\t\tconst timestampData: TimestampData = { lastSearchTime: Date.now() };\n\t\t\t\t\twriteFileSync(this.timeFilePath, JSON.stringify(timestampData));\n\t\t\t\t} catch (tsErr) {\n\t\t\t\t\t// Don't let timestamp write failure mask the original error\n\t\t\t\t\tconsole.error(`Failed to write search timestamp: ${tsErr}`);\n\t\t\t\t}\n\t\t\t}\n\t\t} finally {\n\t\t\ttry {\n\t\t\t\tawait release?.();\n\t\t\t} catch {\n\t\t\t\t// Swallow unlock errors\n\t\t\t}\n\t\t}\n\t}\n}\n"]}
1
+ {"version":3,"file":"web-search-queue.d.ts","sourceRoot":"","sources":["../../../src/core/tools/web-search-queue.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,qBAAqB;IACrC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;CACtB;AAMD,qBAAa,cAAc;IAC1B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IAEtC,YAAY,OAAO,GAAE,qBAA0B,EAI9C;IAEK,OAAO,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAiEjD;CACD","sourcesContent":["import { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport lockfile from \"proper-lockfile\";\nimport { getAgentDir } from \"../../config.js\";\nimport { log } from \"../logger.js\";\n\nexport interface WebSearchQueueOptions {\n\trateLimitMs?: number;\n\tlockFilePath?: string;\n\ttimeFilePath?: string;\n}\n\ninterface TimestampData {\n\tlastSearchTime: number;\n}\n\nexport class WebSearchQueue {\n\tprivate readonly rateLimitMs: number;\n\tprivate readonly lockFilePath: string;\n\tprivate readonly timeFilePath: string;\n\n\tconstructor(options: WebSearchQueueOptions = {}) {\n\t\tthis.rateLimitMs = options.rateLimitMs ?? 10_000;\n\t\tthis.lockFilePath = options.lockFilePath ?? join(getAgentDir(), \"web-search-queue.lock\");\n\t\tthis.timeFilePath = options.timeFilePath ?? join(getAgentDir(), \"web-search-queue.time\");\n\t}\n\n\tasync enqueue<T>(fn: () => Promise<T>): Promise<T> {\n\t\t// Ensure parent directory and lock file exist (proper-lockfile requires the file to exist)\n\t\ttry {\n\t\t\tconst dir = dirname(this.lockFilePath);\n\t\t\tif (!existsSync(dir)) {\n\t\t\t\tmkdirSync(dir, { recursive: true });\n\t\t\t}\n\t\t\tif (!existsSync(this.lockFilePath)) {\n\t\t\t\twriteFileSync(this.lockFilePath, \"\");\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tconst msg = err instanceof Error ? err.message : String(err);\n\t\t\tthrow new Error(`Failed to initialize web search queue lock file at ${this.lockFilePath}: ${msg}`);\n\t\t}\n\n\t\tlet release: (() => Promise<void>) | undefined;\n\t\ttry {\n\t\t\trelease = await lockfile.lock(this.lockFilePath, {\n\t\t\t\tstale: 60_000,\n\t\t\t\tretries: { retries: 10, factor: 2, minTimeout: 100, maxTimeout: 10_000, randomize: true },\n\t\t\t});\n\n\t\t\t// Read last search timestamp\n\t\t\tlet lastSearchTime = 0;\n\t\t\ttry {\n\t\t\t\tconst raw = readFileSync(this.timeFilePath, \"utf-8\");\n\t\t\t\tconst data = JSON.parse(raw) as TimestampData;\n\t\t\t\tif (typeof data.lastSearchTime === \"number\") {\n\t\t\t\t\tlastSearchTime = data.lastSearchTime;\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// Missing or malformed — treat as 0\n\t\t\t}\n\n\t\t\t// Enforce rate limit\n\t\t\tconst delayNeeded = Math.max(0, this.rateLimitMs - (Date.now() - lastSearchTime));\n\t\t\tif (delayNeeded > 0) {\n\t\t\t\tawait new Promise((resolve) => setTimeout(resolve, delayNeeded));\n\t\t\t}\n\n\t\t\t// Execute the search operation\n\t\t\ttry {\n\t\t\t\treturn await fn();\n\t\t\t} finally {\n\t\t\t\t// Ensure time file directory exists before writing\n\t\t\t\tconst timeDir = dirname(this.timeFilePath);\n\t\t\t\tif (!existsSync(timeDir)) {\n\t\t\t\t\tmkdirSync(timeDir, { recursive: true });\n\t\t\t\t}\n\t\t\t\t// Update timestamp even on error to prevent retry storms\n\t\t\t\ttry {\n\t\t\t\t\tconst timestampData: TimestampData = { lastSearchTime: Date.now() };\n\t\t\t\t\twriteFileSync(this.timeFilePath, JSON.stringify(timestampData));\n\t\t\t\t} catch (tsErr) {\n\t\t\t\t\t// Don't let timestamp write failure mask the original error\n\t\t\t\t\tlog.warn(`Failed to write search timestamp: ${tsErr}`);\n\t\t\t\t}\n\t\t\t}\n\t\t} finally {\n\t\t\ttry {\n\t\t\t\tawait release?.();\n\t\t\t} catch {\n\t\t\t\t// Swallow unlock errors\n\t\t\t}\n\t\t}\n\t}\n}\n"]}
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import lockfile from "proper-lockfile";
4
4
  import { getAgentDir } from "../../config.js";
5
+ import { log } from "../logger.js";
5
6
  export class WebSearchQueue {
6
7
  rateLimitMs;
7
8
  lockFilePath;
@@ -66,7 +67,7 @@ export class WebSearchQueue {
66
67
  }
67
68
  catch (tsErr) {
68
69
  // Don't let timestamp write failure mask the original error
69
- console.error(`Failed to write search timestamp: ${tsErr}`);
70
+ log.warn(`Failed to write search timestamp: ${tsErr}`);
70
71
  }
71
72
  }
72
73
  }
@@ -1 +1 @@
1
- {"version":3,"file":"web-search-queue.js","sourceRoot":"","sources":["../../../src/core/tools/web-search-queue.ts"],"names":[],"mappings":"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","sourcesContent":["import { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport lockfile from \"proper-lockfile\";\nimport { getAgentDir } from \"../../config.js\";\n\nexport interface WebSearchQueueOptions {\n\trateLimitMs?: number;\n\tlockFilePath?: string;\n\ttimeFilePath?: string;\n}\n\ninterface TimestampData {\n\tlastSearchTime: number;\n}\n\nexport class WebSearchQueue {\n\tprivate readonly rateLimitMs: number;\n\tprivate readonly lockFilePath: string;\n\tprivate readonly timeFilePath: string;\n\n\tconstructor(options: WebSearchQueueOptions = {}) {\n\t\tthis.rateLimitMs = options.rateLimitMs ?? 10_000;\n\t\tthis.lockFilePath = options.lockFilePath ?? join(getAgentDir(), \"web-search-queue.lock\");\n\t\tthis.timeFilePath = options.timeFilePath ?? join(getAgentDir(), \"web-search-queue.time\");\n\t}\n\n\tasync enqueue<T>(fn: () => Promise<T>): Promise<T> {\n\t\t// Ensure parent directory and lock file exist (proper-lockfile requires the file to exist)\n\t\ttry {\n\t\t\tconst dir = dirname(this.lockFilePath);\n\t\t\tif (!existsSync(dir)) {\n\t\t\t\tmkdirSync(dir, { recursive: true });\n\t\t\t}\n\t\t\tif (!existsSync(this.lockFilePath)) {\n\t\t\t\twriteFileSync(this.lockFilePath, \"\");\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tconst msg = err instanceof Error ? err.message : String(err);\n\t\t\tthrow new Error(`Failed to initialize web search queue lock file at ${this.lockFilePath}: ${msg}`);\n\t\t}\n\n\t\tlet release: (() => Promise<void>) | undefined;\n\t\ttry {\n\t\t\trelease = await lockfile.lock(this.lockFilePath, {\n\t\t\t\tstale: 60_000,\n\t\t\t\tretries: { retries: 10, factor: 2, minTimeout: 100, maxTimeout: 10_000, randomize: true },\n\t\t\t});\n\n\t\t\t// Read last search timestamp\n\t\t\tlet lastSearchTime = 0;\n\t\t\ttry {\n\t\t\t\tconst raw = readFileSync(this.timeFilePath, \"utf-8\");\n\t\t\t\tconst data = JSON.parse(raw) as TimestampData;\n\t\t\t\tif (typeof data.lastSearchTime === \"number\") {\n\t\t\t\t\tlastSearchTime = data.lastSearchTime;\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// Missing or malformed — treat as 0\n\t\t\t}\n\n\t\t\t// Enforce rate limit\n\t\t\tconst delayNeeded = Math.max(0, this.rateLimitMs - (Date.now() - lastSearchTime));\n\t\t\tif (delayNeeded > 0) {\n\t\t\t\tawait new Promise((resolve) => setTimeout(resolve, delayNeeded));\n\t\t\t}\n\n\t\t\t// Execute the search operation\n\t\t\ttry {\n\t\t\t\treturn await fn();\n\t\t\t} finally {\n\t\t\t\t// Ensure time file directory exists before writing\n\t\t\t\tconst timeDir = dirname(this.timeFilePath);\n\t\t\t\tif (!existsSync(timeDir)) {\n\t\t\t\t\tmkdirSync(timeDir, { recursive: true });\n\t\t\t\t}\n\t\t\t\t// Update timestamp even on error to prevent retry storms\n\t\t\t\ttry {\n\t\t\t\t\tconst timestampData: TimestampData = { lastSearchTime: Date.now() };\n\t\t\t\t\twriteFileSync(this.timeFilePath, JSON.stringify(timestampData));\n\t\t\t\t} catch (tsErr) {\n\t\t\t\t\t// Don't let timestamp write failure mask the original error\n\t\t\t\t\tconsole.error(`Failed to write search timestamp: ${tsErr}`);\n\t\t\t\t}\n\t\t\t}\n\t\t} finally {\n\t\t\ttry {\n\t\t\t\tawait release?.();\n\t\t\t} catch {\n\t\t\t\t// Swallow unlock errors\n\t\t\t}\n\t\t}\n\t}\n}\n"]}
1
+ {"version":3,"file":"web-search-queue.js","sourceRoot":"","sources":["../../../src/core/tools/web-search-queue.ts"],"names":[],"mappings":"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","sourcesContent":["import { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport lockfile from \"proper-lockfile\";\nimport { getAgentDir } from \"../../config.js\";\nimport { log } from \"../logger.js\";\n\nexport interface WebSearchQueueOptions {\n\trateLimitMs?: number;\n\tlockFilePath?: string;\n\ttimeFilePath?: string;\n}\n\ninterface TimestampData {\n\tlastSearchTime: number;\n}\n\nexport class WebSearchQueue {\n\tprivate readonly rateLimitMs: number;\n\tprivate readonly lockFilePath: string;\n\tprivate readonly timeFilePath: string;\n\n\tconstructor(options: WebSearchQueueOptions = {}) {\n\t\tthis.rateLimitMs = options.rateLimitMs ?? 10_000;\n\t\tthis.lockFilePath = options.lockFilePath ?? join(getAgentDir(), \"web-search-queue.lock\");\n\t\tthis.timeFilePath = options.timeFilePath ?? join(getAgentDir(), \"web-search-queue.time\");\n\t}\n\n\tasync enqueue<T>(fn: () => Promise<T>): Promise<T> {\n\t\t// Ensure parent directory and lock file exist (proper-lockfile requires the file to exist)\n\t\ttry {\n\t\t\tconst dir = dirname(this.lockFilePath);\n\t\t\tif (!existsSync(dir)) {\n\t\t\t\tmkdirSync(dir, { recursive: true });\n\t\t\t}\n\t\t\tif (!existsSync(this.lockFilePath)) {\n\t\t\t\twriteFileSync(this.lockFilePath, \"\");\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tconst msg = err instanceof Error ? err.message : String(err);\n\t\t\tthrow new Error(`Failed to initialize web search queue lock file at ${this.lockFilePath}: ${msg}`);\n\t\t}\n\n\t\tlet release: (() => Promise<void>) | undefined;\n\t\ttry {\n\t\t\trelease = await lockfile.lock(this.lockFilePath, {\n\t\t\t\tstale: 60_000,\n\t\t\t\tretries: { retries: 10, factor: 2, minTimeout: 100, maxTimeout: 10_000, randomize: true },\n\t\t\t});\n\n\t\t\t// Read last search timestamp\n\t\t\tlet lastSearchTime = 0;\n\t\t\ttry {\n\t\t\t\tconst raw = readFileSync(this.timeFilePath, \"utf-8\");\n\t\t\t\tconst data = JSON.parse(raw) as TimestampData;\n\t\t\t\tif (typeof data.lastSearchTime === \"number\") {\n\t\t\t\t\tlastSearchTime = data.lastSearchTime;\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// Missing or malformed — treat as 0\n\t\t\t}\n\n\t\t\t// Enforce rate limit\n\t\t\tconst delayNeeded = Math.max(0, this.rateLimitMs - (Date.now() - lastSearchTime));\n\t\t\tif (delayNeeded > 0) {\n\t\t\t\tawait new Promise((resolve) => setTimeout(resolve, delayNeeded));\n\t\t\t}\n\n\t\t\t// Execute the search operation\n\t\t\ttry {\n\t\t\t\treturn await fn();\n\t\t\t} finally {\n\t\t\t\t// Ensure time file directory exists before writing\n\t\t\t\tconst timeDir = dirname(this.timeFilePath);\n\t\t\t\tif (!existsSync(timeDir)) {\n\t\t\t\t\tmkdirSync(timeDir, { recursive: true });\n\t\t\t\t}\n\t\t\t\t// Update timestamp even on error to prevent retry storms\n\t\t\t\ttry {\n\t\t\t\t\tconst timestampData: TimestampData = { lastSearchTime: Date.now() };\n\t\t\t\t\twriteFileSync(this.timeFilePath, JSON.stringify(timestampData));\n\t\t\t\t} catch (tsErr) {\n\t\t\t\t\t// Don't let timestamp write failure mask the original error\n\t\t\t\t\tlog.warn(`Failed to write search timestamp: ${tsErr}`);\n\t\t\t\t}\n\t\t\t}\n\t\t} finally {\n\t\t\ttry {\n\t\t\t\tawait release?.();\n\t\t\t} catch {\n\t\t\t\t// Swallow unlock errors\n\t\t\t}\n\t\t}\n\t}\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"web.d.ts","sourceRoot":"","sources":["../../../src/core/tools/web.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAElD,OAAO,EAAE,KAAK,MAAM,EAAQ,MAAM,mBAAmB,CAAC;AAGtD,OAAO,KAAK,EAAE,cAAc,EAA2B,MAAM,wBAAwB,CAAC;AAGtF,OAAO,EAAiC,KAAK,gBAAgB,EAAgB,MAAM,eAAe,CAAC;AAgLnG,QAAA,MAAM,eAAe;;EAEnB,CAAC;AAEH,MAAM,MAAM,kBAAkB,GAAG,MAAM,CAAC,OAAO,eAAe,CAAC,CAAC;AAEhE,MAAM,WAAW,oBAAoB;IACpC,UAAU,CAAC,EAAE,gBAAgB,CAAC;CAC9B;AAED,UAAU,YAAY;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;CAChB;AAuFD,MAAM,WAAW,eAAe;IAC/B,OAAO,CAAC,EAAE,KAAK,GAAG,SAAS,GAAG,OAAO,CAAC;IACtC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAkCD,wBAAgB,eAAe,IAAI,eAAe,CAyCjD;AAMD,wBAAsB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAa1E;AAuCD,wBAAgB,6BAA6B,CAC5C,IAAI,EAAE,MAAM,GACV,cAAc,CAAC,OAAO,eAAe,EAAE,oBAAoB,GAAG,SAAS,CAAC,CA2C1E;AAED,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC,OAAO,eAAe,CAAC,CAElF;AAED,eAAO,MAAM,uBAAuB;;0CAA+C,CAAC;AACpF,eAAO,MAAM,aAAa;;QAAqC,CAAC;AAMhE,QAAA,MAAM,cAAc;;EAElB,CAAC;AAEH,MAAM,MAAM,iBAAiB,GAAG,MAAM,CAAC,OAAO,cAAc,CAAC,CAAC;AAE9D,MAAM,WAAW,mBAAmB;IACnC,UAAU,CAAC,EAAE,gBAAgB,CAAC;IAC9B,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC3B;AA0CD,wBAAgB,4BAA4B,CAC3C,IAAI,EAAE,MAAM,GACV,cAAc,CAAC,OAAO,cAAc,EAAE,mBAAmB,GAAG,SAAS,CAAC,CAmHxE;AAED,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC,OAAO,cAAc,CAAC,CAEhF;AAED,eAAO,MAAM,sBAAsB;;yCAA8C,CAAC;AAClF,eAAO,MAAM,YAAY;;QAAoC,CAAC","sourcesContent":["import { existsSync, readFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport type { AgentTool } from \"@dreb/agent-core\";\nimport { Text } from \"@dreb/tui\";\nimport { type Static, Type } from \"@sinclair/typebox\";\nimport { CONFIG_DIR_NAME } from \"../../config.js\";\nimport { keyHint } from \"../../modes/interactive/components/keybinding-hints.js\";\nimport type { ToolDefinition, ToolRenderResultOptions } from \"../extensions/types.js\";\nimport { getTextOutput, invalidArgText, str } from \"./render-utils.js\";\nimport { wrapToolDefinition } from \"./tool-definition-wrapper.js\";\nimport { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from \"./truncate.js\";\nimport { WebSearchQueue } from \"./web-search-queue.js\";\n\n// ---------------------------------------------------------------------------\n// Shared: HTTP fetching and HTML extraction\n// ---------------------------------------------------------------------------\n\nconst FETCH_TIMEOUT_MS = 30_000;\nconst MAX_CONTENT_LENGTH = 100_000;\nconst CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes\n\nconst fetchCache = new Map<string, { content: WebFetchResult; timestamp: number }>();\n\ninterface WebFetchResult {\n\turl: string;\n\ttitle: string;\n\tcontent: string;\n\tfetchedAt: string;\n}\n\nfunction stripHtmlToText(html: string): string {\n\tlet text = html;\n\t// Remove script/style/nav/footer blocks entirely\n\ttext = text.replace(/<(script|style|nav|footer|header|aside|iframe|noscript)\\b[^>]*>[\\s\\S]*?<\\/\\1>/gi, \"\");\n\t// Convert block elements to newlines\n\ttext = text.replace(/<\\/(p|div|li|tr|h[1-6]|blockquote|pre|section|article)>/gi, \"\\n\");\n\ttext = text.replace(/<(br|hr)\\s*\\/?>/gi, \"\\n\");\n\t// Convert links to text with URL\n\ttext = text.replace(/<a\\b[^>]*href=\"([^\"]*)\"[^>]*>([\\s\\S]*?)<\\/a>/gi, \"$2 ($1)\");\n\t// Convert headings to markdown-style\n\ttext = text.replace(/<h([1-6])\\b[^>]*>([\\s\\S]*?)<\\/h\\1>/gi, (_match, level, content) => {\n\t\treturn `\\n${\"#\".repeat(Number(level))} ${content.trim()}\\n`;\n\t});\n\t// Convert list items\n\ttext = text.replace(/<li\\b[^>]*>/gi, \"\\n- \");\n\t// Strip all remaining tags\n\ttext = text.replace(/<[^>]+>/g, \"\");\n\t// Decode common HTML entities\n\ttext = text.replace(/&amp;/g, \"&\");\n\ttext = text.replace(/&lt;/g, \"<\");\n\ttext = text.replace(/&gt;/g, \">\");\n\ttext = text.replace(/&quot;/g, '\"');\n\ttext = text.replace(/&#39;/g, \"'\");\n\ttext = text.replace(/&nbsp;/g, \" \");\n\t// Collapse whitespace\n\ttext = text.replace(/[ \\t]+/g, \" \");\n\ttext = text.replace(/\\n{3,}/g, \"\\n\\n\");\n\treturn text.trim();\n}\n\nfunction extractTitle(html: string): string {\n\tconst match = html.match(/<title\\b[^>]*>([\\s\\S]*?)<\\/title>/i);\n\treturn match ? match[1].trim().replace(/&amp;/g, \"&\").replace(/&lt;/g, \"<\").replace(/&gt;/g, \">\") : \"\";\n}\n\nconst FETCH_HEADERS = {\n\t\"User-Agent\": \"dreb/1.0 (web fetch tool)\",\n\tAccept: \"text/html,application/xhtml+xml,text/plain,application/pdf\",\n};\n\n// Block fetches to private/internal networks to prevent SSRF\nconst BLOCKED_HOSTNAMES = new Set([\"localhost\", \"127.0.0.1\", \"[::1]\", \"0.0.0.0\"]);\n\nfunction isPrivateHost(hostname: string): boolean {\n\tif (BLOCKED_HOSTNAMES.has(hostname)) return true;\n\t// IPv4 private ranges\n\tconst ipv4Match = hostname.match(/^(\\d+)\\.(\\d+)\\.\\d+\\.\\d+$/);\n\tif (ipv4Match) {\n\t\tconst [, first, second] = ipv4Match.map(Number);\n\t\tif (first === 10) return true; // 10.0.0.0/8\n\t\tif (first === 172 && second >= 16 && second <= 31) return true; // 172.16.0.0/12\n\t\tif (first === 192 && second === 168) return true; // 192.168.0.0/16\n\t\tif (first === 169 && second === 254) return true; // link-local 169.254.0.0/16\n\t}\n\t// IPv6 loopback, link-local, and ULA (fc00::/7)\n\tif (hostname.startsWith(\"[\")) {\n\t\tconst lh = hostname.toLowerCase();\n\t\tif (lh.includes(\"::1\") || lh.startsWith(\"[fe80:\") || lh.startsWith(\"[fc\") || lh.startsWith(\"[fd\")) return true;\n\t}\n\treturn false;\n}\n\nfunction buildResponse(response: Response): Promise<{ body: string | Buffer; contentType: string }> {\n\tconst ct = response.headers.get(\"content-type\") || \"\";\n\tif (ct.includes(\"application/pdf\")) {\n\t\treturn response.arrayBuffer().then((buf) => ({ body: Buffer.from(buf), contentType: ct }));\n\t}\n\treturn response.text().then((text) => ({ body: text, contentType: ct }));\n}\n\nasync function httpFetch(url: string): Promise<{ body: string | Buffer; contentType: string }> {\n\tconst originalHost = new URL(url).hostname;\n\tif (isPrivateHost(originalHost)) {\n\t\tthrow new Error(`Blocked: ${originalHost} is a private/internal address`);\n\t}\n\n\t// Manual redirect loop to enforce same-host on every hop\n\tlet currentUrl = url;\n\tconst maxRedirects = 10;\n\tfor (let i = 0; i <= maxRedirects; i++) {\n\t\tconst response = await fetch(currentUrl, {\n\t\t\tmethod: \"GET\",\n\t\t\theaders: FETCH_HEADERS,\n\t\t\tredirect: \"manual\",\n\t\t\tsignal: AbortSignal.timeout(FETCH_TIMEOUT_MS),\n\t\t});\n\n\t\tif (response.status >= 300 && response.status < 400) {\n\t\t\tconst location = response.headers.get(\"location\");\n\t\t\tif (!location) {\n\t\t\t\tthrow new Error(`HTTP ${response.status}: redirect with no Location header`);\n\t\t\t}\n\t\t\tconst redirectUrl = new URL(location, currentUrl);\n\t\t\t// Block private IPs before revealing them in cross-host messages\n\t\t\tif (isPrivateHost(redirectUrl.hostname)) {\n\t\t\t\tthrow new Error(`Blocked: redirect to private/internal address`);\n\t\t\t}\n\t\t\tif (redirectUrl.hostname !== originalHost) {\n\t\t\t\treturn {\n\t\t\t\t\tbody: `Cross-host redirect detected.\\nOriginal: ${url}\\nRedirects to: ${redirectUrl.href}\\n\\nThe redirect target is on a different host. Fetch the new URL directly if you want to follow it.`,\n\t\t\t\t\tcontentType: \"text/plain\",\n\t\t\t\t};\n\t\t\t}\n\t\t\tcurrentUrl = redirectUrl.href;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (!response.ok) {\n\t\t\tconst errorBody = await response.text();\n\t\t\tthrow new Error(`HTTP ${response.status}: ${errorBody.slice(0, 200)}`);\n\t\t}\n\n\t\treturn buildResponse(response);\n\t}\n\tthrow new Error(`Too many redirects (${maxRedirects})`);\n}\n\n// -- PDF text extraction (basic) ---------------------------------------------\n\nfunction extractPdfText(buffer: Buffer): string {\n\t// Minimal PDF text extraction — only works on uncompressed PDFs with literal\n\t// string objects in BT/ET text blocks. Most production PDFs use FlateDecode\n\t// compression and will fall through to the failure message.\n\t// latin1 preserves raw byte values 0-255 as code points for safe regex matching.\n\tconst raw = buffer.toString(\"latin1\");\n\tconst textChunks: string[] = [];\n\n\tconst btEtRegex = /BT\\s([\\s\\S]*?)ET/g;\n\tfor (const match of raw.matchAll(btEtRegex)) {\n\t\tconst block = match[1];\n\t\tconst strRegex = /\\(([^)]*)\\)/g;\n\t\tfor (const strMatch of block.matchAll(strRegex)) {\n\t\t\tconst decoded = strMatch[1]\n\t\t\t\t.replace(/\\\\n/g, \"\\n\")\n\t\t\t\t.replace(/\\\\r/g, \"\\r\")\n\t\t\t\t.replace(/\\\\t/g, \"\\t\")\n\t\t\t\t.replace(/\\\\\\(/g, \"(\")\n\t\t\t\t.replace(/\\\\\\)/g, \")\")\n\t\t\t\t.replace(/\\\\\\\\/g, \"\\\\\");\n\t\t\tif (decoded.trim()) {\n\t\t\t\ttextChunks.push(decoded);\n\t\t\t}\n\t\t}\n\t}\n\n\tif (textChunks.length === 0) {\n\t\treturn \"[PDF text extraction failed — the PDF may use embedded fonts or image-based content that requires OCR]\";\n\t}\n\n\treturn textChunks.join(\" \").replace(/\\s+/g, \" \").trim();\n}\n\n// ---------------------------------------------------------------------------\n// web_search tool\n// ---------------------------------------------------------------------------\n\nconst webSearchSchema = Type.Object({\n\tquery: Type.String({ description: \"The search query\" }),\n});\n\nexport type WebSearchToolInput = Static<typeof webSearchSchema>;\n\nexport interface WebSearchToolDetails {\n\ttruncation?: TruncationResult;\n}\n\ninterface SearchResult {\n\ttitle: string;\n\turl: string;\n\tsnippet: string;\n}\n\nasync function searchDuckDuckGo(query: string): Promise<SearchResult[]> {\n\tconst encodedQuery = encodeURIComponent(query);\n\tconst response = await fetch(`https://html.duckduckgo.com/html/?q=${encodedQuery}`, {\n\t\tmethod: \"GET\",\n\t\theaders: {\n\t\t\t\"User-Agent\": \"dreb/1.0 (web search tool)\",\n\t\t\tAccept: \"text/html\",\n\t\t},\n\t\tredirect: \"follow\",\n\t\tsignal: AbortSignal.timeout(FETCH_TIMEOUT_MS),\n\t});\n\tif (!response.ok) {\n\t\tthrow new Error(`DuckDuckGo search failed: HTTP ${response.status}`);\n\t}\n\tconst html = await response.text();\n\tconst results: SearchResult[] = [];\n\n\t// Parse DuckDuckGo HTML results — split on result block class\n\tconst resultBlocks = html.split(/class=\"result results_links/);\n\tfor (const block of resultBlocks.slice(1, 11)) {\n\t\tconst titleMatch = block.match(/class=\"result__a\"[^>]*href=\"([^\"]*)\"[^>]*>([\\s\\S]*?)<\\/a>/);\n\t\tconst snippetMatch = block.match(/class=\"result__snippet\"[^>]*>([\\s\\S]*?)<\\/(?:a|td|div)/);\n\n\t\tif (titleMatch) {\n\t\t\tlet url = titleMatch[1];\n\t\t\t// DDG wraps URLs in a redirect — extract the actual URL\n\t\t\tconst uddgMatch = url.match(/uddg=([^&]*)/);\n\t\t\tif (uddgMatch) {\n\t\t\t\turl = decodeURIComponent(uddgMatch[1]);\n\t\t\t}\n\t\t\tconst title = titleMatch[2].replace(/<[^>]+>/g, \"\").trim();\n\t\t\tconst snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, \"\").trim() : \"\";\n\t\t\tif (title && url) {\n\t\t\t\tresults.push({ title, url, snippet });\n\t\t\t}\n\t\t}\n\t}\n\tif (results.length === 0 && html.length > 1000) {\n\t\t// Got a substantial response but parsed 0 results — DDG HTML structure may have changed\n\t\tconsole.error(\"Warning: DDG returned HTML but 0 results were parsed. The HTML structure may have changed.\");\n\t}\n\treturn results;\n}\n\nasync function searchSearXNG(query: string, baseUrl: string): Promise<SearchResult[]> {\n\tconst encodedQuery = encodeURIComponent(query);\n\tconst response = await fetch(`${baseUrl}/search?q=${encodedQuery}&format=json`, {\n\t\tmethod: \"GET\",\n\t\theaders: { Accept: \"application/json\" },\n\t\tsignal: AbortSignal.timeout(FETCH_TIMEOUT_MS),\n\t});\n\tif (!response.ok) {\n\t\tthrow new Error(`SearXNG search failed: HTTP ${response.status}`);\n\t}\n\tconst data = (await response.json()) as { results?: Array<{ title: string; url: string; content?: string }> };\n\treturn (data.results || []).slice(0, 10).map((r) => ({\n\t\ttitle: r.title,\n\t\turl: r.url,\n\t\tsnippet: r.content || \"\",\n\t}));\n}\n\nasync function searchBrave(query: string, apiKey: string): Promise<SearchResult[]> {\n\tconst encodedQuery = encodeURIComponent(query);\n\tconst response = await fetch(`https://api.search.brave.com/res/v1/web/search?q=${encodedQuery}`, {\n\t\tmethod: \"GET\",\n\t\theaders: {\n\t\t\tAccept: \"application/json\",\n\t\t\t\"X-Subscription-Token\": apiKey,\n\t\t},\n\t\tsignal: AbortSignal.timeout(FETCH_TIMEOUT_MS),\n\t});\n\tif (!response.ok) {\n\t\tthrow new Error(`Brave search failed: HTTP ${response.status}`);\n\t}\n\tconst data = (await response.json()) as {\n\t\tweb?: { results?: Array<{ title: string; url: string; description?: string }> };\n\t};\n\treturn (data.web?.results || []).slice(0, 10).map((r) => ({\n\t\ttitle: r.title,\n\t\turl: r.url,\n\t\tsnippet: r.description || \"\",\n\t}));\n}\n\nexport interface WebSearchConfig {\n\tbackend?: \"ddg\" | \"searxng\" | \"brave\";\n\tsearxngUrl?: string;\n\tbraveApiKey?: string;\n\trateLimitMs?: number;\n}\n\ninterface DrebConfig {\n\tsearch?: {\n\t\tbackend?: string;\n\t\tsearxng_url?: string;\n\t\tbrave_api_key?: string;\n\t\trate_limit_ms?: number;\n\t};\n}\n\nconst VALID_BACKENDS = [\"ddg\", \"searxng\", \"brave\"] as const;\n\nfunction loadDrebConfig(): DrebConfig {\n\t// Config file precedence: project-local > home directory. First valid file wins.\n\tconst candidates = [\n\t\tjoin(process.cwd(), CONFIG_DIR_NAME, \"config.json\"),\n\t\tjoin(process.cwd(), \".dreb\", \"config.json\"),\n\t\tjoin(homedir(), CONFIG_DIR_NAME, \"config.json\"),\n\t\tjoin(homedir(), \".dreb\", \"config.json\"),\n\t];\n\tfor (const configPath of candidates) {\n\t\tif (existsSync(configPath)) {\n\t\t\ttry {\n\t\t\t\treturn JSON.parse(readFileSync(configPath, \"utf-8\")) as DrebConfig;\n\t\t\t} catch (err) {\n\t\t\t\tconst msg = err instanceof Error ? err.message : String(err);\n\t\t\t\tconsole.error(`Warning: failed to parse config at ${configPath}: ${msg}`);\n\t\t\t}\n\t\t}\n\t}\n\treturn {};\n}\n\nexport function getSearchConfig(): WebSearchConfig {\n\tconst fileConfig = loadDrebConfig();\n\t// Environment variables override config file\n\tconst rawBackend = process.env.DREB_SEARCH_BACKEND || fileConfig.search?.backend;\n\tlet backend: WebSearchConfig[\"backend\"] = \"ddg\";\n\tif (rawBackend) {\n\t\tif ((VALID_BACKENDS as readonly string[]).includes(rawBackend)) {\n\t\t\tbackend = rawBackend as WebSearchConfig[\"backend\"];\n\t\t} else {\n\t\t\tconsole.error(\n\t\t\t\t`Warning: unrecognized search backend \"${rawBackend}\", falling back to ddg. Valid: ${VALID_BACKENDS.join(\", \")}`,\n\t\t\t);\n\t\t}\n\t}\n\n\tconst rateLimitEnv = process.env.DREB_WEB_SEARCH_RATE_LIMIT_MS;\n\tlet rateLimitMs = 10_000;\n\tif (rateLimitEnv) {\n\t\tconst parsed = parseInt(rateLimitEnv, 10);\n\t\tif (!Number.isNaN(parsed) && parsed >= 0) {\n\t\t\trateLimitMs = parsed;\n\t\t} else {\n\t\t\tconsole.error(`Warning: invalid DREB_WEB_SEARCH_RATE_LIMIT_MS \"${rateLimitEnv}\", using default`);\n\t\t}\n\t} else if (fileConfig.search?.rate_limit_ms !== undefined) {\n\t\tconst parsed = parseInt(String(fileConfig.search.rate_limit_ms), 10);\n\t\tif (!Number.isNaN(parsed) && parsed >= 0) {\n\t\t\trateLimitMs = parsed;\n\t\t} else {\n\t\t\tconsole.error(\n\t\t\t\t`Warning: invalid search.rate_limit_ms in config file \"${fileConfig.search.rate_limit_ms}\", using default`,\n\t\t\t);\n\t\t}\n\t}\n\n\treturn {\n\t\tbackend,\n\t\tsearxngUrl: process.env.DREB_SEARXNG_URL || fileConfig.search?.searxng_url || \"http://localhost:8888\",\n\t\tbraveApiKey: process.env.DREB_BRAVE_API_KEY || fileConfig.search?.brave_api_key,\n\t\trateLimitMs,\n\t};\n}\n\nfunction getSearchQueue(): WebSearchQueue {\n\treturn new WebSearchQueue({ rateLimitMs: getSearchConfig().rateLimitMs });\n}\n\nexport async function executeSearch(query: string): Promise<SearchResult[]> {\n\treturn getSearchQueue().enqueue(async () => {\n\t\tconst config = getSearchConfig();\n\t\tswitch (config.backend) {\n\t\t\tcase \"searxng\":\n\t\t\t\treturn searchSearXNG(query, config.searxngUrl!);\n\t\t\tcase \"brave\":\n\t\t\t\tif (!config.braveApiKey) throw new Error(\"DREB_BRAVE_API_KEY not set\");\n\t\t\t\treturn searchBrave(query, config.braveApiKey);\n\t\t\tdefault:\n\t\t\t\treturn searchDuckDuckGo(query);\n\t\t}\n\t});\n}\n\nfunction formatSearchCall(\n\targs: { query: string } | undefined,\n\ttheme: typeof import(\"../../modes/interactive/theme/theme.js\").theme,\n): string {\n\tconst query = str(args?.query);\n\tconst invalidArg = invalidArgText(theme);\n\treturn (\n\t\ttheme.fg(\"toolTitle\", theme.bold(\"web_search\")) +\n\t\t\" \" +\n\t\t(query === null ? invalidArg : theme.fg(\"accent\", `\"${query}\"`))\n\t);\n}\n\nfunction formatSearchResult(\n\tresult: {\n\t\tcontent: Array<{ type: string; text?: string }>;\n\t\tdetails?: WebSearchToolDetails;\n\t},\n\toptions: ToolRenderResultOptions,\n\ttheme: typeof import(\"../../modes/interactive/theme/theme.js\").theme,\n\tshowImages: boolean,\n): string {\n\tconst output = getTextOutput(result, showImages).trim();\n\tlet text = \"\";\n\tif (output) {\n\t\tconst lines = output.split(\"\\n\");\n\t\tconst maxLines = options.expanded ? lines.length : 15;\n\t\tconst displayLines = lines.slice(0, maxLines);\n\t\tconst remaining = lines.length - maxLines;\n\t\ttext += `\\n${displayLines.map((line) => theme.fg(\"toolOutput\", line)).join(\"\\n\")}`;\n\t\tif (remaining > 0) {\n\t\t\ttext += `${theme.fg(\"muted\", `\\n... (${remaining} more lines,`)} ${keyHint(\"app.tools.expand\", \"to expand\")})`;\n\t\t}\n\t}\n\treturn text;\n}\n\nexport function createWebSearchToolDefinition(\n\t_cwd: string,\n): ToolDefinition<typeof webSearchSchema, WebSearchToolDetails | undefined> {\n\treturn {\n\t\tname: \"web_search\",\n\t\tlabel: \"web_search\",\n\t\tdescription:\n\t\t\t\"Search the web. Returns titles, URLs, and snippets. Configure backend via DREB_SEARCH_BACKEND env var (ddg, searxng, brave).\",\n\t\tpromptSnippet: \"Search the web for information\",\n\t\tparameters: webSearchSchema,\n\t\tasync execute(_toolCallId, { query }: { query: string }) {\n\t\t\tlet results: SearchResult[];\n\t\t\ttry {\n\t\t\t\tresults = await executeSearch(query);\n\t\t\t} catch (error) {\n\t\t\t\tconst msg = error instanceof Error ? error.message : String(error);\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: `Search failed for \"${query}\": ${msg}` }],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\t\t\tif (results.length === 0) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: `No results found for: ${query}` }],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\t\t\tconst formatted = results.map((r, i) => `${i + 1}. ${r.title}\\n ${r.url}\\n ${r.snippet}`).join(\"\\n\\n\");\n\t\t\tconst output = `Search results for: ${query}\\n\\n${formatted}`;\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\", text: output }],\n\t\t\t\tdetails: undefined,\n\t\t\t};\n\t\t},\n\t\trenderCall(args, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatSearchCall(args, theme));\n\t\t\treturn text;\n\t\t},\n\t\trenderResult(result, options, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatSearchResult(result as any, options, theme, context.showImages));\n\t\t\treturn text;\n\t\t},\n\t};\n}\n\nexport function createWebSearchTool(cwd: string): AgentTool<typeof webSearchSchema> {\n\treturn wrapToolDefinition(createWebSearchToolDefinition(cwd));\n}\n\nexport const webSearchToolDefinition = createWebSearchToolDefinition(process.cwd());\nexport const webSearchTool = createWebSearchTool(process.cwd());\n\n// ---------------------------------------------------------------------------\n// web_fetch tool\n// ---------------------------------------------------------------------------\n\nconst webFetchSchema = Type.Object({\n\turl: Type.String({ description: \"The URL to fetch\" }),\n});\n\nexport type WebFetchToolInput = Static<typeof webFetchSchema>;\n\nexport interface WebFetchToolDetails {\n\ttruncation?: TruncationResult;\n\ttruncatedContent?: boolean;\n}\n\nfunction formatFetchCall(\n\targs: { url: string } | undefined,\n\ttheme: typeof import(\"../../modes/interactive/theme/theme.js\").theme,\n): string {\n\tconst url = str(args?.url);\n\tconst invalidArg = invalidArgText(theme);\n\treturn `${theme.fg(\"toolTitle\", theme.bold(\"web_fetch\"))} ${url === null ? invalidArg : theme.fg(\"accent\", url || \"\")}`;\n}\n\nfunction formatFetchResult(\n\tresult: {\n\t\tcontent: Array<{ type: string; text?: string }>;\n\t\tdetails?: WebFetchToolDetails;\n\t},\n\toptions: ToolRenderResultOptions,\n\ttheme: typeof import(\"../../modes/interactive/theme/theme.js\").theme,\n\tshowImages: boolean,\n): string {\n\tconst output = getTextOutput(result, showImages).trim();\n\tlet text = \"\";\n\tif (output) {\n\t\tconst lines = output.split(\"\\n\");\n\t\tconst maxLines = options.expanded ? lines.length : 30;\n\t\tconst displayLines = lines.slice(0, maxLines);\n\t\tconst remaining = lines.length - maxLines;\n\t\ttext += `\\n${displayLines.map((line) => theme.fg(\"toolOutput\", line)).join(\"\\n\")}`;\n\t\tif (remaining > 0) {\n\t\t\ttext += `${theme.fg(\"muted\", `\\n... (${remaining} more lines,`)} ${keyHint(\"app.tools.expand\", \"to expand\")})`;\n\t\t}\n\t}\n\tconst details = result.details;\n\tif (details?.truncatedContent || details?.truncation?.truncated) {\n\t\tconst warnings: string[] = [];\n\t\tif (details.truncatedContent) warnings.push(`~${Math.round(MAX_CONTENT_LENGTH / 1024)}KB content limit`);\n\t\tif (details.truncation?.truncated) warnings.push(`${formatSize(DEFAULT_MAX_BYTES)} output limit`);\n\t\ttext += `\\n${theme.fg(\"warning\", `[Truncated: ${warnings.join(\", \")}]`)}`;\n\t}\n\treturn text;\n}\n\nexport function createWebFetchToolDefinition(\n\t_cwd: string,\n): ToolDefinition<typeof webFetchSchema, WebFetchToolDetails | undefined> {\n\treturn {\n\t\tname: \"web_fetch\",\n\t\tlabel: \"web_fetch\",\n\t\tdescription: `Fetch a URL and return its text content. Extracts readable text from HTML pages. Supports PDF text extraction. Content truncated to ~${Math.round(MAX_CONTENT_LENGTH / 1024)}KB. Results cached for 15 minutes.`,\n\t\tpromptSnippet: \"Fetch a URL and extract its text content\",\n\t\tparameters: webFetchSchema,\n\t\tasync execute(_toolCallId, { url }: { url: string }) {\n\t\t\t// Validate URL\n\t\t\tlet parsed: URL;\n\t\t\ttry {\n\t\t\t\tparsed = new URL(url);\n\t\t\t} catch {\n\t\t\t\t// URL constructor threw — input is not a valid URL\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: `Invalid URL: ${url}` }],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tif (!parsed.protocol.startsWith(\"http\")) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: `Unsupported protocol: ${parsed.protocol}` }],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\t// Check cache (15-minute TTL, evict stale entries)\n\t\t\tconst cached = fetchCache.get(url);\n\t\t\tif (cached) {\n\t\t\t\tif (Date.now() - cached.timestamp < CACHE_TTL_MS) {\n\t\t\t\t\tconst r = cached.content;\n\t\t\t\t\tconst output = `${r.title}\\n${r.url}\\nFetched: ${r.fetchedAt} (cached)\\n\\n${r.content}`;\n\t\t\t\t\treturn {\n\t\t\t\t\t\tcontent: [{ type: \"text\", text: output }],\n\t\t\t\t\t\tdetails: undefined,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\tfetchCache.delete(url);\n\t\t\t}\n\n\t\t\t// Fetch (with same-host redirect enforcement)\n\t\t\tlet body: string | Buffer;\n\t\t\tlet contentType: string;\n\t\t\ttry {\n\t\t\t\tconst result = await httpFetch(url);\n\t\t\t\tbody = result.body;\n\t\t\t\tcontentType = result.contentType;\n\t\t\t} catch (error) {\n\t\t\t\tconst msg = error instanceof Error ? error.message : String(error);\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: `Failed to fetch ${url}: ${msg}` }],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\t// Extract content based on content type\n\t\t\tlet text: string;\n\t\t\tlet title: string;\n\t\t\tconst details: WebFetchToolDetails = {};\n\t\t\tconst fetchedAt = new Date().toISOString();\n\n\t\t\tif (contentType.includes(\"application/pdf\")) {\n\t\t\t\ttitle = url;\n\t\t\t\ttext = extractPdfText(body as Buffer);\n\t\t\t} else if (contentType.includes(\"text/html\") || contentType.includes(\"application/xhtml\")) {\n\t\t\t\tconst htmlBody = body as string;\n\t\t\t\ttitle = extractTitle(htmlBody) || url;\n\t\t\t\ttext = stripHtmlToText(htmlBody);\n\t\t\t} else if (\n\t\t\t\tcontentType.includes(\"text/plain\") ||\n\t\t\t\tcontentType.includes(\"application/json\") ||\n\t\t\t\tcontentType.includes(\"text/xml\") ||\n\t\t\t\tcontentType.includes(\"application/xml\")\n\t\t\t) {\n\t\t\t\ttitle = url;\n\t\t\t\ttext = body as string;\n\t\t\t} else {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: `Unsupported content type: ${contentType}` }],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\t// Truncate to prevent context overflow (~100K characters)\n\t\t\tif (text.length > MAX_CONTENT_LENGTH) {\n\t\t\t\ttext = `${text.slice(0, MAX_CONTENT_LENGTH)}\\n\\n[Content truncated at ~${Math.round(MAX_CONTENT_LENGTH / 1024)}KB]`;\n\t\t\t\tdetails.truncatedContent = true;\n\t\t\t}\n\n\t\t\tconst fetchResult: WebFetchResult = { url, title, content: text, fetchedAt };\n\t\t\tfetchCache.set(url, { content: fetchResult, timestamp: Date.now() });\n\n\t\t\tconst output = `${title}\\n${url}\\nFetched: ${fetchedAt}\\n\\n${text}`;\n\t\t\tconst truncation = truncateHead(output, { maxLines: Number.MAX_SAFE_INTEGER });\n\t\t\tif (truncation.truncated) {\n\t\t\t\tdetails.truncation = truncation;\n\t\t\t}\n\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\", text: truncation.content }],\n\t\t\t\tdetails: Object.keys(details).length > 0 ? details : undefined,\n\t\t\t};\n\t\t},\n\t\trenderCall(args, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatFetchCall(args, theme));\n\t\t\treturn text;\n\t\t},\n\t\trenderResult(result, options, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatFetchResult(result as any, options, theme, context.showImages));\n\t\t\treturn text;\n\t\t},\n\t};\n}\n\nexport function createWebFetchTool(cwd: string): AgentTool<typeof webFetchSchema> {\n\treturn wrapToolDefinition(createWebFetchToolDefinition(cwd));\n}\n\nexport const webFetchToolDefinition = createWebFetchToolDefinition(process.cwd());\nexport const webFetchTool = createWebFetchTool(process.cwd());\n"]}
1
+ {"version":3,"file":"web.d.ts","sourceRoot":"","sources":["../../../src/core/tools/web.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAElD,OAAO,EAAE,KAAK,MAAM,EAAQ,MAAM,mBAAmB,CAAC;AAGtD,OAAO,KAAK,EAAE,cAAc,EAA2B,MAAM,wBAAwB,CAAC;AAItF,OAAO,EAAiC,KAAK,gBAAgB,EAAgB,MAAM,eAAe,CAAC;AAgLnG,QAAA,MAAM,eAAe;;EAEnB,CAAC;AAEH,MAAM,MAAM,kBAAkB,GAAG,MAAM,CAAC,OAAO,eAAe,CAAC,CAAC;AAEhE,MAAM,WAAW,oBAAoB;IACpC,UAAU,CAAC,EAAE,gBAAgB,CAAC;CAC9B;AAED,UAAU,YAAY;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;CAChB;AAuFD,MAAM,WAAW,eAAe;IAC/B,OAAO,CAAC,EAAE,KAAK,GAAG,SAAS,GAAG,OAAO,CAAC;IACtC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAkCD,wBAAgB,eAAe,IAAI,eAAe,CAyCjD;AAMD,wBAAsB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAa1E;AAuCD,wBAAgB,6BAA6B,CAC5C,IAAI,EAAE,MAAM,GACV,cAAc,CAAC,OAAO,eAAe,EAAE,oBAAoB,GAAG,SAAS,CAAC,CA2C1E;AAED,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC,OAAO,eAAe,CAAC,CAElF;AAED,eAAO,MAAM,uBAAuB;;0CAA+C,CAAC;AACpF,eAAO,MAAM,aAAa;;QAAqC,CAAC;AAMhE,QAAA,MAAM,cAAc;;EAElB,CAAC;AAEH,MAAM,MAAM,iBAAiB,GAAG,MAAM,CAAC,OAAO,cAAc,CAAC,CAAC;AAE9D,MAAM,WAAW,mBAAmB;IACnC,UAAU,CAAC,EAAE,gBAAgB,CAAC;IAC9B,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC3B;AA0CD,wBAAgB,4BAA4B,CAC3C,IAAI,EAAE,MAAM,GACV,cAAc,CAAC,OAAO,cAAc,EAAE,mBAAmB,GAAG,SAAS,CAAC,CAmHxE;AAED,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC,OAAO,cAAc,CAAC,CAEhF;AAED,eAAO,MAAM,sBAAsB;;yCAA8C,CAAC;AAClF,eAAO,MAAM,YAAY;;QAAoC,CAAC","sourcesContent":["import { existsSync, readFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport type { AgentTool } from \"@dreb/agent-core\";\nimport { Text } from \"@dreb/tui\";\nimport { type Static, Type } from \"@sinclair/typebox\";\nimport { CONFIG_DIR_NAME } from \"../../config.js\";\nimport { keyHint } from \"../../modes/interactive/components/keybinding-hints.js\";\nimport type { ToolDefinition, ToolRenderResultOptions } from \"../extensions/types.js\";\nimport { log } from \"../logger.js\";\nimport { getTextOutput, invalidArgText, str } from \"./render-utils.js\";\nimport { wrapToolDefinition } from \"./tool-definition-wrapper.js\";\nimport { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from \"./truncate.js\";\nimport { WebSearchQueue } from \"./web-search-queue.js\";\n\n// ---------------------------------------------------------------------------\n// Shared: HTTP fetching and HTML extraction\n// ---------------------------------------------------------------------------\n\nconst FETCH_TIMEOUT_MS = 30_000;\nconst MAX_CONTENT_LENGTH = 100_000;\nconst CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes\n\nconst fetchCache = new Map<string, { content: WebFetchResult; timestamp: number }>();\n\ninterface WebFetchResult {\n\turl: string;\n\ttitle: string;\n\tcontent: string;\n\tfetchedAt: string;\n}\n\nfunction stripHtmlToText(html: string): string {\n\tlet text = html;\n\t// Remove script/style/nav/footer blocks entirely\n\ttext = text.replace(/<(script|style|nav|footer|header|aside|iframe|noscript)\\b[^>]*>[\\s\\S]*?<\\/\\1>/gi, \"\");\n\t// Convert block elements to newlines\n\ttext = text.replace(/<\\/(p|div|li|tr|h[1-6]|blockquote|pre|section|article)>/gi, \"\\n\");\n\ttext = text.replace(/<(br|hr)\\s*\\/?>/gi, \"\\n\");\n\t// Convert links to text with URL\n\ttext = text.replace(/<a\\b[^>]*href=\"([^\"]*)\"[^>]*>([\\s\\S]*?)<\\/a>/gi, \"$2 ($1)\");\n\t// Convert headings to markdown-style\n\ttext = text.replace(/<h([1-6])\\b[^>]*>([\\s\\S]*?)<\\/h\\1>/gi, (_match, level, content) => {\n\t\treturn `\\n${\"#\".repeat(Number(level))} ${content.trim()}\\n`;\n\t});\n\t// Convert list items\n\ttext = text.replace(/<li\\b[^>]*>/gi, \"\\n- \");\n\t// Strip all remaining tags\n\ttext = text.replace(/<[^>]+>/g, \"\");\n\t// Decode common HTML entities\n\ttext = text.replace(/&amp;/g, \"&\");\n\ttext = text.replace(/&lt;/g, \"<\");\n\ttext = text.replace(/&gt;/g, \">\");\n\ttext = text.replace(/&quot;/g, '\"');\n\ttext = text.replace(/&#39;/g, \"'\");\n\ttext = text.replace(/&nbsp;/g, \" \");\n\t// Collapse whitespace\n\ttext = text.replace(/[ \\t]+/g, \" \");\n\ttext = text.replace(/\\n{3,}/g, \"\\n\\n\");\n\treturn text.trim();\n}\n\nfunction extractTitle(html: string): string {\n\tconst match = html.match(/<title\\b[^>]*>([\\s\\S]*?)<\\/title>/i);\n\treturn match ? match[1].trim().replace(/&amp;/g, \"&\").replace(/&lt;/g, \"<\").replace(/&gt;/g, \">\") : \"\";\n}\n\nconst FETCH_HEADERS = {\n\t\"User-Agent\": \"dreb/1.0 (web fetch tool)\",\n\tAccept: \"text/html,application/xhtml+xml,text/plain,application/pdf\",\n};\n\n// Block fetches to private/internal networks to prevent SSRF\nconst BLOCKED_HOSTNAMES = new Set([\"localhost\", \"127.0.0.1\", \"[::1]\", \"0.0.0.0\"]);\n\nfunction isPrivateHost(hostname: string): boolean {\n\tif (BLOCKED_HOSTNAMES.has(hostname)) return true;\n\t// IPv4 private ranges\n\tconst ipv4Match = hostname.match(/^(\\d+)\\.(\\d+)\\.\\d+\\.\\d+$/);\n\tif (ipv4Match) {\n\t\tconst [, first, second] = ipv4Match.map(Number);\n\t\tif (first === 10) return true; // 10.0.0.0/8\n\t\tif (first === 172 && second >= 16 && second <= 31) return true; // 172.16.0.0/12\n\t\tif (first === 192 && second === 168) return true; // 192.168.0.0/16\n\t\tif (first === 169 && second === 254) return true; // link-local 169.254.0.0/16\n\t}\n\t// IPv6 loopback, link-local, and ULA (fc00::/7)\n\tif (hostname.startsWith(\"[\")) {\n\t\tconst lh = hostname.toLowerCase();\n\t\tif (lh.includes(\"::1\") || lh.startsWith(\"[fe80:\") || lh.startsWith(\"[fc\") || lh.startsWith(\"[fd\")) return true;\n\t}\n\treturn false;\n}\n\nfunction buildResponse(response: Response): Promise<{ body: string | Buffer; contentType: string }> {\n\tconst ct = response.headers.get(\"content-type\") || \"\";\n\tif (ct.includes(\"application/pdf\")) {\n\t\treturn response.arrayBuffer().then((buf) => ({ body: Buffer.from(buf), contentType: ct }));\n\t}\n\treturn response.text().then((text) => ({ body: text, contentType: ct }));\n}\n\nasync function httpFetch(url: string): Promise<{ body: string | Buffer; contentType: string }> {\n\tconst originalHost = new URL(url).hostname;\n\tif (isPrivateHost(originalHost)) {\n\t\tthrow new Error(`Blocked: ${originalHost} is a private/internal address`);\n\t}\n\n\t// Manual redirect loop to enforce same-host on every hop\n\tlet currentUrl = url;\n\tconst maxRedirects = 10;\n\tfor (let i = 0; i <= maxRedirects; i++) {\n\t\tconst response = await fetch(currentUrl, {\n\t\t\tmethod: \"GET\",\n\t\t\theaders: FETCH_HEADERS,\n\t\t\tredirect: \"manual\",\n\t\t\tsignal: AbortSignal.timeout(FETCH_TIMEOUT_MS),\n\t\t});\n\n\t\tif (response.status >= 300 && response.status < 400) {\n\t\t\tconst location = response.headers.get(\"location\");\n\t\t\tif (!location) {\n\t\t\t\tthrow new Error(`HTTP ${response.status}: redirect with no Location header`);\n\t\t\t}\n\t\t\tconst redirectUrl = new URL(location, currentUrl);\n\t\t\t// Block private IPs before revealing them in cross-host messages\n\t\t\tif (isPrivateHost(redirectUrl.hostname)) {\n\t\t\t\tthrow new Error(`Blocked: redirect to private/internal address`);\n\t\t\t}\n\t\t\tif (redirectUrl.hostname !== originalHost) {\n\t\t\t\treturn {\n\t\t\t\t\tbody: `Cross-host redirect detected.\\nOriginal: ${url}\\nRedirects to: ${redirectUrl.href}\\n\\nThe redirect target is on a different host. Fetch the new URL directly if you want to follow it.`,\n\t\t\t\t\tcontentType: \"text/plain\",\n\t\t\t\t};\n\t\t\t}\n\t\t\tcurrentUrl = redirectUrl.href;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (!response.ok) {\n\t\t\tconst errorBody = await response.text();\n\t\t\tthrow new Error(`HTTP ${response.status}: ${errorBody.slice(0, 200)}`);\n\t\t}\n\n\t\treturn buildResponse(response);\n\t}\n\tthrow new Error(`Too many redirects (${maxRedirects})`);\n}\n\n// -- PDF text extraction (basic) ---------------------------------------------\n\nfunction extractPdfText(buffer: Buffer): string {\n\t// Minimal PDF text extraction — only works on uncompressed PDFs with literal\n\t// string objects in BT/ET text blocks. Most production PDFs use FlateDecode\n\t// compression and will fall through to the failure message.\n\t// latin1 preserves raw byte values 0-255 as code points for safe regex matching.\n\tconst raw = buffer.toString(\"latin1\");\n\tconst textChunks: string[] = [];\n\n\tconst btEtRegex = /BT\\s([\\s\\S]*?)ET/g;\n\tfor (const match of raw.matchAll(btEtRegex)) {\n\t\tconst block = match[1];\n\t\tconst strRegex = /\\(([^)]*)\\)/g;\n\t\tfor (const strMatch of block.matchAll(strRegex)) {\n\t\t\tconst decoded = strMatch[1]\n\t\t\t\t.replace(/\\\\n/g, \"\\n\")\n\t\t\t\t.replace(/\\\\r/g, \"\\r\")\n\t\t\t\t.replace(/\\\\t/g, \"\\t\")\n\t\t\t\t.replace(/\\\\\\(/g, \"(\")\n\t\t\t\t.replace(/\\\\\\)/g, \")\")\n\t\t\t\t.replace(/\\\\\\\\/g, \"\\\\\");\n\t\t\tif (decoded.trim()) {\n\t\t\t\ttextChunks.push(decoded);\n\t\t\t}\n\t\t}\n\t}\n\n\tif (textChunks.length === 0) {\n\t\treturn \"[PDF text extraction failed — the PDF may use embedded fonts or image-based content that requires OCR]\";\n\t}\n\n\treturn textChunks.join(\" \").replace(/\\s+/g, \" \").trim();\n}\n\n// ---------------------------------------------------------------------------\n// web_search tool\n// ---------------------------------------------------------------------------\n\nconst webSearchSchema = Type.Object({\n\tquery: Type.String({ description: \"The search query\" }),\n});\n\nexport type WebSearchToolInput = Static<typeof webSearchSchema>;\n\nexport interface WebSearchToolDetails {\n\ttruncation?: TruncationResult;\n}\n\ninterface SearchResult {\n\ttitle: string;\n\turl: string;\n\tsnippet: string;\n}\n\nasync function searchDuckDuckGo(query: string): Promise<SearchResult[]> {\n\tconst encodedQuery = encodeURIComponent(query);\n\tconst response = await fetch(`https://html.duckduckgo.com/html/?q=${encodedQuery}`, {\n\t\tmethod: \"GET\",\n\t\theaders: {\n\t\t\t\"User-Agent\": \"dreb/1.0 (web search tool)\",\n\t\t\tAccept: \"text/html\",\n\t\t},\n\t\tredirect: \"follow\",\n\t\tsignal: AbortSignal.timeout(FETCH_TIMEOUT_MS),\n\t});\n\tif (!response.ok) {\n\t\tthrow new Error(`DuckDuckGo search failed: HTTP ${response.status}`);\n\t}\n\tconst html = await response.text();\n\tconst results: SearchResult[] = [];\n\n\t// Parse DuckDuckGo HTML results — split on result block class\n\tconst resultBlocks = html.split(/class=\"result results_links/);\n\tfor (const block of resultBlocks.slice(1, 11)) {\n\t\tconst titleMatch = block.match(/class=\"result__a\"[^>]*href=\"([^\"]*)\"[^>]*>([\\s\\S]*?)<\\/a>/);\n\t\tconst snippetMatch = block.match(/class=\"result__snippet\"[^>]*>([\\s\\S]*?)<\\/(?:a|td|div)/);\n\n\t\tif (titleMatch) {\n\t\t\tlet url = titleMatch[1];\n\t\t\t// DDG wraps URLs in a redirect — extract the actual URL\n\t\t\tconst uddgMatch = url.match(/uddg=([^&]*)/);\n\t\t\tif (uddgMatch) {\n\t\t\t\turl = decodeURIComponent(uddgMatch[1]);\n\t\t\t}\n\t\t\tconst title = titleMatch[2].replace(/<[^>]+>/g, \"\").trim();\n\t\t\tconst snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, \"\").trim() : \"\";\n\t\t\tif (title && url) {\n\t\t\t\tresults.push({ title, url, snippet });\n\t\t\t}\n\t\t}\n\t}\n\tif (results.length === 0 && html.length > 1000) {\n\t\t// Got a substantial response but parsed 0 results — DDG HTML structure may have changed\n\t\tlog.warn(\"Warning: DDG returned HTML but 0 results were parsed. The HTML structure may have changed.\");\n\t}\n\treturn results;\n}\n\nasync function searchSearXNG(query: string, baseUrl: string): Promise<SearchResult[]> {\n\tconst encodedQuery = encodeURIComponent(query);\n\tconst response = await fetch(`${baseUrl}/search?q=${encodedQuery}&format=json`, {\n\t\tmethod: \"GET\",\n\t\theaders: { Accept: \"application/json\" },\n\t\tsignal: AbortSignal.timeout(FETCH_TIMEOUT_MS),\n\t});\n\tif (!response.ok) {\n\t\tthrow new Error(`SearXNG search failed: HTTP ${response.status}`);\n\t}\n\tconst data = (await response.json()) as { results?: Array<{ title: string; url: string; content?: string }> };\n\treturn (data.results || []).slice(0, 10).map((r) => ({\n\t\ttitle: r.title,\n\t\turl: r.url,\n\t\tsnippet: r.content || \"\",\n\t}));\n}\n\nasync function searchBrave(query: string, apiKey: string): Promise<SearchResult[]> {\n\tconst encodedQuery = encodeURIComponent(query);\n\tconst response = await fetch(`https://api.search.brave.com/res/v1/web/search?q=${encodedQuery}`, {\n\t\tmethod: \"GET\",\n\t\theaders: {\n\t\t\tAccept: \"application/json\",\n\t\t\t\"X-Subscription-Token\": apiKey,\n\t\t},\n\t\tsignal: AbortSignal.timeout(FETCH_TIMEOUT_MS),\n\t});\n\tif (!response.ok) {\n\t\tthrow new Error(`Brave search failed: HTTP ${response.status}`);\n\t}\n\tconst data = (await response.json()) as {\n\t\tweb?: { results?: Array<{ title: string; url: string; description?: string }> };\n\t};\n\treturn (data.web?.results || []).slice(0, 10).map((r) => ({\n\t\ttitle: r.title,\n\t\turl: r.url,\n\t\tsnippet: r.description || \"\",\n\t}));\n}\n\nexport interface WebSearchConfig {\n\tbackend?: \"ddg\" | \"searxng\" | \"brave\";\n\tsearxngUrl?: string;\n\tbraveApiKey?: string;\n\trateLimitMs?: number;\n}\n\ninterface DrebConfig {\n\tsearch?: {\n\t\tbackend?: string;\n\t\tsearxng_url?: string;\n\t\tbrave_api_key?: string;\n\t\trate_limit_ms?: number;\n\t};\n}\n\nconst VALID_BACKENDS = [\"ddg\", \"searxng\", \"brave\"] as const;\n\nfunction loadDrebConfig(): DrebConfig {\n\t// Config file precedence: project-local > home directory. First valid file wins.\n\tconst candidates = [\n\t\tjoin(process.cwd(), CONFIG_DIR_NAME, \"config.json\"),\n\t\tjoin(process.cwd(), \".dreb\", \"config.json\"),\n\t\tjoin(homedir(), CONFIG_DIR_NAME, \"config.json\"),\n\t\tjoin(homedir(), \".dreb\", \"config.json\"),\n\t];\n\tfor (const configPath of candidates) {\n\t\tif (existsSync(configPath)) {\n\t\t\ttry {\n\t\t\t\treturn JSON.parse(readFileSync(configPath, \"utf-8\")) as DrebConfig;\n\t\t\t} catch (err) {\n\t\t\t\tconst msg = err instanceof Error ? err.message : String(err);\n\t\t\t\tlog.warn(`Warning: failed to parse config at ${configPath}: ${msg}`);\n\t\t\t}\n\t\t}\n\t}\n\treturn {};\n}\n\nexport function getSearchConfig(): WebSearchConfig {\n\tconst fileConfig = loadDrebConfig();\n\t// Environment variables override config file\n\tconst rawBackend = process.env.DREB_SEARCH_BACKEND || fileConfig.search?.backend;\n\tlet backend: WebSearchConfig[\"backend\"] = \"ddg\";\n\tif (rawBackend) {\n\t\tif ((VALID_BACKENDS as readonly string[]).includes(rawBackend)) {\n\t\t\tbackend = rawBackend as WebSearchConfig[\"backend\"];\n\t\t} else {\n\t\t\tlog.warn(\n\t\t\t\t`Warning: unrecognized search backend \"${rawBackend}\", falling back to ddg. Valid: ${VALID_BACKENDS.join(\", \")}`,\n\t\t\t);\n\t\t}\n\t}\n\n\tconst rateLimitEnv = process.env.DREB_WEB_SEARCH_RATE_LIMIT_MS;\n\tlet rateLimitMs = 10_000;\n\tif (rateLimitEnv) {\n\t\tconst parsed = parseInt(rateLimitEnv, 10);\n\t\tif (!Number.isNaN(parsed) && parsed >= 0) {\n\t\t\trateLimitMs = parsed;\n\t\t} else {\n\t\t\tlog.warn(`Warning: invalid DREB_WEB_SEARCH_RATE_LIMIT_MS \"${rateLimitEnv}\", using default`);\n\t\t}\n\t} else if (fileConfig.search?.rate_limit_ms !== undefined) {\n\t\tconst parsed = parseInt(String(fileConfig.search.rate_limit_ms), 10);\n\t\tif (!Number.isNaN(parsed) && parsed >= 0) {\n\t\t\trateLimitMs = parsed;\n\t\t} else {\n\t\t\tlog.warn(\n\t\t\t\t`Warning: invalid search.rate_limit_ms in config file \"${fileConfig.search.rate_limit_ms}\", using default`,\n\t\t\t);\n\t\t}\n\t}\n\n\treturn {\n\t\tbackend,\n\t\tsearxngUrl: process.env.DREB_SEARXNG_URL || fileConfig.search?.searxng_url || \"http://localhost:8888\",\n\t\tbraveApiKey: process.env.DREB_BRAVE_API_KEY || fileConfig.search?.brave_api_key,\n\t\trateLimitMs,\n\t};\n}\n\nfunction getSearchQueue(): WebSearchQueue {\n\treturn new WebSearchQueue({ rateLimitMs: getSearchConfig().rateLimitMs });\n}\n\nexport async function executeSearch(query: string): Promise<SearchResult[]> {\n\treturn getSearchQueue().enqueue(async () => {\n\t\tconst config = getSearchConfig();\n\t\tswitch (config.backend) {\n\t\t\tcase \"searxng\":\n\t\t\t\treturn searchSearXNG(query, config.searxngUrl!);\n\t\t\tcase \"brave\":\n\t\t\t\tif (!config.braveApiKey) throw new Error(\"DREB_BRAVE_API_KEY not set\");\n\t\t\t\treturn searchBrave(query, config.braveApiKey);\n\t\t\tdefault:\n\t\t\t\treturn searchDuckDuckGo(query);\n\t\t}\n\t});\n}\n\nfunction formatSearchCall(\n\targs: { query: string } | undefined,\n\ttheme: typeof import(\"../../modes/interactive/theme/theme.js\").theme,\n): string {\n\tconst query = str(args?.query);\n\tconst invalidArg = invalidArgText(theme);\n\treturn (\n\t\ttheme.fg(\"toolTitle\", theme.bold(\"web_search\")) +\n\t\t\" \" +\n\t\t(query === null ? invalidArg : theme.fg(\"accent\", `\"${query}\"`))\n\t);\n}\n\nfunction formatSearchResult(\n\tresult: {\n\t\tcontent: Array<{ type: string; text?: string }>;\n\t\tdetails?: WebSearchToolDetails;\n\t},\n\toptions: ToolRenderResultOptions,\n\ttheme: typeof import(\"../../modes/interactive/theme/theme.js\").theme,\n\tshowImages: boolean,\n): string {\n\tconst output = getTextOutput(result, showImages).trim();\n\tlet text = \"\";\n\tif (output) {\n\t\tconst lines = output.split(\"\\n\");\n\t\tconst maxLines = options.expanded ? lines.length : 15;\n\t\tconst displayLines = lines.slice(0, maxLines);\n\t\tconst remaining = lines.length - maxLines;\n\t\ttext += `\\n${displayLines.map((line) => theme.fg(\"toolOutput\", line)).join(\"\\n\")}`;\n\t\tif (remaining > 0) {\n\t\t\ttext += `${theme.fg(\"muted\", `\\n... (${remaining} more lines,`)} ${keyHint(\"app.tools.expand\", \"to expand\")})`;\n\t\t}\n\t}\n\treturn text;\n}\n\nexport function createWebSearchToolDefinition(\n\t_cwd: string,\n): ToolDefinition<typeof webSearchSchema, WebSearchToolDetails | undefined> {\n\treturn {\n\t\tname: \"web_search\",\n\t\tlabel: \"web_search\",\n\t\tdescription:\n\t\t\t\"Search the web. Returns titles, URLs, and snippets. Configure backend via DREB_SEARCH_BACKEND env var (ddg, searxng, brave).\",\n\t\tpromptSnippet: \"Search the web for information\",\n\t\tparameters: webSearchSchema,\n\t\tasync execute(_toolCallId, { query }: { query: string }) {\n\t\t\tlet results: SearchResult[];\n\t\t\ttry {\n\t\t\t\tresults = await executeSearch(query);\n\t\t\t} catch (error) {\n\t\t\t\tconst msg = error instanceof Error ? error.message : String(error);\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: `Search failed for \"${query}\": ${msg}` }],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\t\t\tif (results.length === 0) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: `No results found for: ${query}` }],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\t\t\tconst formatted = results.map((r, i) => `${i + 1}. ${r.title}\\n ${r.url}\\n ${r.snippet}`).join(\"\\n\\n\");\n\t\t\tconst output = `Search results for: ${query}\\n\\n${formatted}`;\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\", text: output }],\n\t\t\t\tdetails: undefined,\n\t\t\t};\n\t\t},\n\t\trenderCall(args, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatSearchCall(args, theme));\n\t\t\treturn text;\n\t\t},\n\t\trenderResult(result, options, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatSearchResult(result as any, options, theme, context.showImages));\n\t\t\treturn text;\n\t\t},\n\t};\n}\n\nexport function createWebSearchTool(cwd: string): AgentTool<typeof webSearchSchema> {\n\treturn wrapToolDefinition(createWebSearchToolDefinition(cwd));\n}\n\nexport const webSearchToolDefinition = createWebSearchToolDefinition(process.cwd());\nexport const webSearchTool = createWebSearchTool(process.cwd());\n\n// ---------------------------------------------------------------------------\n// web_fetch tool\n// ---------------------------------------------------------------------------\n\nconst webFetchSchema = Type.Object({\n\turl: Type.String({ description: \"The URL to fetch\" }),\n});\n\nexport type WebFetchToolInput = Static<typeof webFetchSchema>;\n\nexport interface WebFetchToolDetails {\n\ttruncation?: TruncationResult;\n\ttruncatedContent?: boolean;\n}\n\nfunction formatFetchCall(\n\targs: { url: string } | undefined,\n\ttheme: typeof import(\"../../modes/interactive/theme/theme.js\").theme,\n): string {\n\tconst url = str(args?.url);\n\tconst invalidArg = invalidArgText(theme);\n\treturn `${theme.fg(\"toolTitle\", theme.bold(\"web_fetch\"))} ${url === null ? invalidArg : theme.fg(\"accent\", url || \"\")}`;\n}\n\nfunction formatFetchResult(\n\tresult: {\n\t\tcontent: Array<{ type: string; text?: string }>;\n\t\tdetails?: WebFetchToolDetails;\n\t},\n\toptions: ToolRenderResultOptions,\n\ttheme: typeof import(\"../../modes/interactive/theme/theme.js\").theme,\n\tshowImages: boolean,\n): string {\n\tconst output = getTextOutput(result, showImages).trim();\n\tlet text = \"\";\n\tif (output) {\n\t\tconst lines = output.split(\"\\n\");\n\t\tconst maxLines = options.expanded ? lines.length : 30;\n\t\tconst displayLines = lines.slice(0, maxLines);\n\t\tconst remaining = lines.length - maxLines;\n\t\ttext += `\\n${displayLines.map((line) => theme.fg(\"toolOutput\", line)).join(\"\\n\")}`;\n\t\tif (remaining > 0) {\n\t\t\ttext += `${theme.fg(\"muted\", `\\n... (${remaining} more lines,`)} ${keyHint(\"app.tools.expand\", \"to expand\")})`;\n\t\t}\n\t}\n\tconst details = result.details;\n\tif (details?.truncatedContent || details?.truncation?.truncated) {\n\t\tconst warnings: string[] = [];\n\t\tif (details.truncatedContent) warnings.push(`~${Math.round(MAX_CONTENT_LENGTH / 1024)}KB content limit`);\n\t\tif (details.truncation?.truncated) warnings.push(`${formatSize(DEFAULT_MAX_BYTES)} output limit`);\n\t\ttext += `\\n${theme.fg(\"warning\", `[Truncated: ${warnings.join(\", \")}]`)}`;\n\t}\n\treturn text;\n}\n\nexport function createWebFetchToolDefinition(\n\t_cwd: string,\n): ToolDefinition<typeof webFetchSchema, WebFetchToolDetails | undefined> {\n\treturn {\n\t\tname: \"web_fetch\",\n\t\tlabel: \"web_fetch\",\n\t\tdescription: `Fetch a URL and return its text content. Extracts readable text from HTML pages. Supports PDF text extraction. Content truncated to ~${Math.round(MAX_CONTENT_LENGTH / 1024)}KB. Results cached for 15 minutes.`,\n\t\tpromptSnippet: \"Fetch a URL and extract its text content\",\n\t\tparameters: webFetchSchema,\n\t\tasync execute(_toolCallId, { url }: { url: string }) {\n\t\t\t// Validate URL\n\t\t\tlet parsed: URL;\n\t\t\ttry {\n\t\t\t\tparsed = new URL(url);\n\t\t\t} catch {\n\t\t\t\t// URL constructor threw — input is not a valid URL\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: `Invalid URL: ${url}` }],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tif (!parsed.protocol.startsWith(\"http\")) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: `Unsupported protocol: ${parsed.protocol}` }],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\t// Check cache (15-minute TTL, evict stale entries)\n\t\t\tconst cached = fetchCache.get(url);\n\t\t\tif (cached) {\n\t\t\t\tif (Date.now() - cached.timestamp < CACHE_TTL_MS) {\n\t\t\t\t\tconst r = cached.content;\n\t\t\t\t\tconst output = `${r.title}\\n${r.url}\\nFetched: ${r.fetchedAt} (cached)\\n\\n${r.content}`;\n\t\t\t\t\treturn {\n\t\t\t\t\t\tcontent: [{ type: \"text\", text: output }],\n\t\t\t\t\t\tdetails: undefined,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\tfetchCache.delete(url);\n\t\t\t}\n\n\t\t\t// Fetch (with same-host redirect enforcement)\n\t\t\tlet body: string | Buffer;\n\t\t\tlet contentType: string;\n\t\t\ttry {\n\t\t\t\tconst result = await httpFetch(url);\n\t\t\t\tbody = result.body;\n\t\t\t\tcontentType = result.contentType;\n\t\t\t} catch (error) {\n\t\t\t\tconst msg = error instanceof Error ? error.message : String(error);\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: `Failed to fetch ${url}: ${msg}` }],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\t// Extract content based on content type\n\t\t\tlet text: string;\n\t\t\tlet title: string;\n\t\t\tconst details: WebFetchToolDetails = {};\n\t\t\tconst fetchedAt = new Date().toISOString();\n\n\t\t\tif (contentType.includes(\"application/pdf\")) {\n\t\t\t\ttitle = url;\n\t\t\t\ttext = extractPdfText(body as Buffer);\n\t\t\t} else if (contentType.includes(\"text/html\") || contentType.includes(\"application/xhtml\")) {\n\t\t\t\tconst htmlBody = body as string;\n\t\t\t\ttitle = extractTitle(htmlBody) || url;\n\t\t\t\ttext = stripHtmlToText(htmlBody);\n\t\t\t} else if (\n\t\t\t\tcontentType.includes(\"text/plain\") ||\n\t\t\t\tcontentType.includes(\"application/json\") ||\n\t\t\t\tcontentType.includes(\"text/xml\") ||\n\t\t\t\tcontentType.includes(\"application/xml\")\n\t\t\t) {\n\t\t\t\ttitle = url;\n\t\t\t\ttext = body as string;\n\t\t\t} else {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: `Unsupported content type: ${contentType}` }],\n\t\t\t\t\tdetails: undefined,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\t// Truncate to prevent context overflow (~100K characters)\n\t\t\tif (text.length > MAX_CONTENT_LENGTH) {\n\t\t\t\ttext = `${text.slice(0, MAX_CONTENT_LENGTH)}\\n\\n[Content truncated at ~${Math.round(MAX_CONTENT_LENGTH / 1024)}KB]`;\n\t\t\t\tdetails.truncatedContent = true;\n\t\t\t}\n\n\t\t\tconst fetchResult: WebFetchResult = { url, title, content: text, fetchedAt };\n\t\t\tfetchCache.set(url, { content: fetchResult, timestamp: Date.now() });\n\n\t\t\tconst output = `${title}\\n${url}\\nFetched: ${fetchedAt}\\n\\n${text}`;\n\t\t\tconst truncation = truncateHead(output, { maxLines: Number.MAX_SAFE_INTEGER });\n\t\t\tif (truncation.truncated) {\n\t\t\t\tdetails.truncation = truncation;\n\t\t\t}\n\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\", text: truncation.content }],\n\t\t\t\tdetails: Object.keys(details).length > 0 ? details : undefined,\n\t\t\t};\n\t\t},\n\t\trenderCall(args, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatFetchCall(args, theme));\n\t\t\treturn text;\n\t\t},\n\t\trenderResult(result, options, theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatFetchResult(result as any, options, theme, context.showImages));\n\t\t\treturn text;\n\t\t},\n\t};\n}\n\nexport function createWebFetchTool(cwd: string): AgentTool<typeof webFetchSchema> {\n\treturn wrapToolDefinition(createWebFetchToolDefinition(cwd));\n}\n\nexport const webFetchToolDefinition = createWebFetchToolDefinition(process.cwd());\nexport const webFetchTool = createWebFetchTool(process.cwd());\n"]}
@@ -5,6 +5,7 @@ import { Text } from "@dreb/tui";
5
5
  import { Type } from "@sinclair/typebox";
6
6
  import { CONFIG_DIR_NAME } from "../../config.js";
7
7
  import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
8
+ import { log } from "../logger.js";
8
9
  import { getTextOutput, invalidArgText, str } from "./render-utils.js";
9
10
  import { wrapToolDefinition } from "./tool-definition-wrapper.js";
10
11
  import { DEFAULT_MAX_BYTES, formatSize, truncateHead } from "./truncate.js";
@@ -201,7 +202,7 @@ async function searchDuckDuckGo(query) {
201
202
  }
202
203
  if (results.length === 0 && html.length > 1000) {
203
204
  // Got a substantial response but parsed 0 results — DDG HTML structure may have changed
204
- console.error("Warning: DDG returned HTML but 0 results were parsed. The HTML structure may have changed.");
205
+ log.warn("Warning: DDG returned HTML but 0 results were parsed. The HTML structure may have changed.");
205
206
  }
206
207
  return results;
207
208
  }
@@ -258,7 +259,7 @@ function loadDrebConfig() {
258
259
  }
259
260
  catch (err) {
260
261
  const msg = err instanceof Error ? err.message : String(err);
261
- console.error(`Warning: failed to parse config at ${configPath}: ${msg}`);
262
+ log.warn(`Warning: failed to parse config at ${configPath}: ${msg}`);
262
263
  }
263
264
  }
264
265
  }
@@ -274,7 +275,7 @@ export function getSearchConfig() {
274
275
  backend = rawBackend;
275
276
  }
276
277
  else {
277
- console.error(`Warning: unrecognized search backend "${rawBackend}", falling back to ddg. Valid: ${VALID_BACKENDS.join(", ")}`);
278
+ log.warn(`Warning: unrecognized search backend "${rawBackend}", falling back to ddg. Valid: ${VALID_BACKENDS.join(", ")}`);
278
279
  }
279
280
  }
280
281
  const rateLimitEnv = process.env.DREB_WEB_SEARCH_RATE_LIMIT_MS;
@@ -285,7 +286,7 @@ export function getSearchConfig() {
285
286
  rateLimitMs = parsed;
286
287
  }
287
288
  else {
288
- console.error(`Warning: invalid DREB_WEB_SEARCH_RATE_LIMIT_MS "${rateLimitEnv}", using default`);
289
+ log.warn(`Warning: invalid DREB_WEB_SEARCH_RATE_LIMIT_MS "${rateLimitEnv}", using default`);
289
290
  }
290
291
  }
291
292
  else if (fileConfig.search?.rate_limit_ms !== undefined) {
@@ -294,7 +295,7 @@ export function getSearchConfig() {
294
295
  rateLimitMs = parsed;
295
296
  }
296
297
  else {
297
- console.error(`Warning: invalid search.rate_limit_ms in config file "${fileConfig.search.rate_limit_ms}", using default`);
298
+ log.warn(`Warning: invalid search.rate_limit_ms in config file "${fileConfig.search.rate_limit_ms}", using default`);
298
299
  }
299
300
  }
300
301
  return {