@gajae-code/coding-agent 0.11.2 → 0.11.4

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 (101) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/types/cli/launch-disposition.d.ts +23 -0
  3. package/dist/types/cli/worktree-cli.d.ts +8 -25
  4. package/dist/types/commands/gc.d.ts +4 -0
  5. package/dist/types/commands/worktree.d.ts +7 -32
  6. package/dist/types/config/settings-schema.d.ts +85 -10
  7. package/dist/types/gjc-runtime/gc-runtime.d.ts +9 -0
  8. package/dist/types/gjc-runtime/state-argv.d.ts +38 -0
  9. package/dist/types/main.d.ts +1 -0
  10. package/dist/types/modes/components/custom-editor.d.ts +8 -3
  11. package/dist/types/modes/components/tool-transcript-format.d.ts +19 -0
  12. package/dist/types/modes/controllers/input-controller.d.ts +8 -2
  13. package/dist/types/modes/prompt-action-autocomplete.d.ts +4 -0
  14. package/dist/types/sdk/broker/broker.d.ts +1 -0
  15. package/dist/types/sdk/broker/discovery.d.ts +10 -0
  16. package/dist/types/sdk/broker/ensure.d.ts +24 -1
  17. package/dist/types/sdk/broker/internal.d.ts +3 -0
  18. package/dist/types/sdk/broker/session-index.d.ts +12 -0
  19. package/dist/types/sdk/broker/state-version.d.ts +2 -1
  20. package/dist/types/sdk/bus/index.d.ts +2 -0
  21. package/dist/types/sdk/session.d.ts +2 -0
  22. package/dist/types/tools/ask.d.ts +39 -12
  23. package/dist/types/tools/browser/screenshot-format.d.ts +8 -0
  24. package/dist/types/tools/browser/tab-protocol.d.ts +11 -0
  25. package/dist/types/tools/browser/tab-supervisor.d.ts +1 -1
  26. package/dist/types/tools/browser/tab-worker-path-resolver.d.ts +2 -0
  27. package/dist/types/tools/browser/tab-worker-smoke.d.ts +16 -0
  28. package/dist/types/tools/path-utils.d.ts +9 -14
  29. package/dist/types/tools/read.d.ts +2 -0
  30. package/dist/types/tools/render-utils.d.ts +1 -8
  31. package/dist/types/tools/resource-gc.d.ts +14 -0
  32. package/dist/types/tools/subagent.d.ts +1 -0
  33. package/dist/types/utils/file-mentions.d.ts +3 -3
  34. package/dist/types/utils/image-loading.d.ts +23 -0
  35. package/dist/types/utils/image-resize.d.ts +2 -0
  36. package/dist/types/utils/pasted-image-loading.d.ts +39 -0
  37. package/dist/types/utils/pasted-image-path.d.ts +21 -19
  38. package/dist/types/web/scrapers/html-to-markdown.d.ts +5 -0
  39. package/dist/types/web/scrapers/types.d.ts +1 -5
  40. package/package.json +24 -8
  41. package/scripts/run-threat-model-check.ts +60 -0
  42. package/src/cli/launch-disposition.ts +42 -0
  43. package/src/cli/worktree-cli.ts +9 -287
  44. package/src/cli.ts +10 -9
  45. package/src/commands/gc.ts +11 -1
  46. package/src/commands/sdk.ts +3 -2
  47. package/src/commands/worktree.ts +10 -53
  48. package/src/config/model-profiles.ts +15 -15
  49. package/src/config/settings-schema.ts +55 -10
  50. package/src/eval/js/shared/runtime.ts +1 -1
  51. package/src/gjc-runtime/gc-render.ts +19 -1
  52. package/src/gjc-runtime/gc-runtime.ts +68 -3
  53. package/src/gjc-runtime/state-argv.ts +292 -0
  54. package/src/gjc-runtime/state-runtime.ts +51 -212
  55. package/src/main.ts +99 -22
  56. package/src/modes/components/custom-editor.ts +103 -32
  57. package/src/modes/components/custom-provider-wizard.ts +26 -1
  58. package/src/modes/components/session-observer-overlay.ts +10 -23
  59. package/src/modes/components/tool-transcript-format.ts +72 -0
  60. package/src/modes/components/transcript-viewer-overlay.ts +42 -2
  61. package/src/modes/controllers/input-controller.ts +98 -37
  62. package/src/modes/controllers/selector-controller.ts +1 -1
  63. package/src/modes/interactive-mode.ts +13 -2
  64. package/src/modes/prompt-action-autocomplete.ts +7 -0
  65. package/src/sdk/broker/broker.ts +139 -32
  66. package/src/sdk/broker/discovery.ts +125 -0
  67. package/src/sdk/broker/ensure.ts +103 -19
  68. package/src/sdk/broker/internal.ts +10 -0
  69. package/src/sdk/broker/session-index.ts +228 -15
  70. package/src/sdk/broker/state-version.ts +5 -2
  71. package/src/sdk/bus/index.ts +4 -1
  72. package/src/sdk/bus/telegram-daemon.ts +50 -10
  73. package/src/sdk/host/host.ts +13 -1
  74. package/src/sdk/session.ts +4 -1
  75. package/src/session/internal/managed-session-scope.ts +15 -1
  76. package/src/tools/ask.ts +216 -41
  77. package/src/tools/bash-allowed-prefixes.ts +79 -29
  78. package/src/tools/browser/launch.ts +3 -1
  79. package/src/tools/browser/readable.ts +1 -1
  80. package/src/tools/browser/screenshot-format.ts +34 -0
  81. package/src/tools/browser/tab-protocol.ts +3 -0
  82. package/src/tools/browser/tab-supervisor.ts +125 -122
  83. package/src/tools/browser/tab-worker-entry.ts +33 -14
  84. package/src/tools/browser/tab-worker-path-resolver.ts +67 -0
  85. package/src/tools/browser/tab-worker-smoke.ts +87 -0
  86. package/src/tools/browser/tab-worker.ts +7 -5
  87. package/src/tools/output-meta.ts +6 -2
  88. package/src/tools/path-utils.ts +42 -35
  89. package/src/tools/read.ts +189 -64
  90. package/src/tools/render-utils.ts +1 -27
  91. package/src/tools/resource-gc.ts +123 -34
  92. package/src/tools/subagent.ts +43 -2
  93. package/src/utils/file-mentions.ts +4 -4
  94. package/src/utils/image-loading.ts +85 -34
  95. package/src/utils/image-resize.ts +16 -9
  96. package/src/utils/pasted-image-loading.ts +514 -0
  97. package/src/utils/pasted-image-path.ts +223 -98
  98. package/src/web/scrapers/html-to-markdown.ts +68 -0
  99. package/src/web/scrapers/readthedocs.ts +1 -1
  100. package/src/web/scrapers/types.ts +1 -68
  101. package/src/web/search/providers/openai-compatible.ts +76 -23
@@ -1,14 +1,3 @@
1
- /**
2
- * Resolve pasted editor text to an image file path.
3
- *
4
- * Some terminal/clipboard integrations paste a temporary filesystem path after a
5
- * copied image is pasted into the editor (for example `/tmp/clipboard-...png`).
6
- * Only those recognized clipboard temp files are auto-attached and replaced with
7
- * an `[image N] source="/path"` reference so the model receives both the image
8
- * payload and the raw temp file path. Ordinary image paths remain literal prompt
9
- * text; users attach saved files explicitly with `@path/to/image.png`.
10
- */
11
- import * as fs from "node:fs";
12
1
  import * as os from "node:os";
13
2
  import * as path from "node:path";
14
3
  import { fileURLToPath } from "node:url";
@@ -16,12 +5,12 @@ import { fileURLToPath } from "node:url";
16
5
  const IMAGE_FILE_EXTENSION_PATTERN = /\.(?:png|jpe?g|gif|webp)$/i;
17
6
  const CLIPBOARD_TEMP_BASENAME_PATTERN = /^clipboard-\d{4}-\d{2}-\d{2}-\d{6}-[A-Za-z0-9-]+\.(?:png|jpe?g|gif|webp)$/i;
18
7
 
8
+ export const MAX_PASTED_IMAGE_COUNT = 16;
9
+ export const MAX_PASTED_IMAGE_PASTE_CHARACTERS = 1024 * 1024;
10
+ export const MAX_PASTED_IMAGE_PATH_CHARACTERS = 32 * 1024;
11
+
19
12
  export interface DecodePastedPathOptions {
20
- /**
21
- * Platform whose path semantics apply when decoding (shell unescaping,
22
- * `file://` drive letters / UNC hosts). Defaults to `process.platform`;
23
- * injectable so tests can pin the win32 contract from any host.
24
- */
13
+ /** Platform whose path and shell semantics apply. Defaults to `process.platform`. */
25
14
  platform?: NodeJS.Platform;
26
15
  /** Home directory for `~/` expansion. Defaults to `os.homedir()`. */
27
16
  homedir?: string;
@@ -32,17 +21,30 @@ export interface ResolvePastedImagePathOptions extends DecodePastedPathOptions {
32
21
  cwd?: string;
33
22
  }
34
23
 
35
- /**
36
- * Convert a `file://` URI to a filesystem path for the given platform, or
37
- * `undefined` when the URI is invalid.
38
- *
39
- * The production path (injected platform === host platform, always true
40
- * outside tests) delegates to Node's `fileURLToPath`. When tests inject a
41
- * foreign platform, a mirror of `fileURLToPath`'s rules is applied instead —
42
- * Bun (1.3) accepts but ignores `fileURLToPath`'s `windows` option, so the
43
- * native call cannot emulate a foreign platform.
44
- */
45
- function decodeFileUrl(candidate: string, platform: NodeJS.Platform): string | undefined {
24
+ export type PastedImagePathParseResult =
25
+ | {
26
+ kind: "paths";
27
+ paths: string[];
28
+ requiresConfirmation: boolean;
29
+ }
30
+ | {
31
+ kind: "too-many";
32
+ maxCandidates: number;
33
+ };
34
+
35
+ type PastedPathListState = "normal" | "escape" | "single-quote" | "double-quote";
36
+ type EscapeReturnState = "normal" | "double-quote";
37
+
38
+ type TokenizePastedPathsResult =
39
+ | { kind: "candidates"; candidates: string[] }
40
+ | { kind: "too-many"; maxCandidates: number; candidates: string[] }
41
+ | { kind: "invalid" };
42
+
43
+ function pathApi(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 {
44
+ return platform === "win32" ? path.win32 : path.posix;
45
+ }
46
+
47
+ function decodeFileUrl(candidate: string, platform: NodeJS.Platform, allowRemoteHost: boolean): string | undefined {
46
48
  let url: URL;
47
49
  try {
48
50
  url = new URL(candidate);
@@ -50,6 +52,7 @@ function decodeFileUrl(candidate: string, platform: NodeJS.Platform): string | u
50
52
  return undefined;
51
53
  }
52
54
  if (url.protocol !== "file:") return undefined;
55
+ if (!allowRemoteHost && url.hostname && url.hostname !== "localhost") return undefined;
53
56
 
54
57
  if (platform === process.platform) {
55
58
  try {
@@ -59,7 +62,6 @@ function decodeFileUrl(candidate: string, platform: NodeJS.Platform): string | u
59
62
  }
60
63
  }
61
64
 
62
- // Foreign-platform mirror of fileURLToPath (tests only).
63
65
  if (/%2f/i.test(url.pathname) || (platform === "win32" && /%5c/i.test(url.pathname))) return undefined;
64
66
  let pathname: string;
65
67
  try {
@@ -69,10 +71,8 @@ function decodeFileUrl(candidate: string, platform: NodeJS.Platform): string | u
69
71
  }
70
72
  if (platform === "win32") {
71
73
  if (url.hostname && url.hostname !== "localhost") {
72
- // UNC: file://server/share/img.png -> \\server\share\img.png
73
- return `\\\\${url.hostname}${pathname.replaceAll("/", "\\")}`;
74
+ return allowRemoteHost ? `\\\\${url.hostname}${pathname.replaceAll("/", "\\")}` : undefined;
74
75
  }
75
- // Drive letter required: file:///C:/x -> C:\x
76
76
  if (!/^\/[A-Za-z]:/.test(pathname)) return undefined;
77
77
  return pathname.slice(1).replaceAll("/", "\\");
78
78
  }
@@ -80,103 +80,228 @@ function decodeFileUrl(candidate: string, platform: NodeJS.Platform): string | u
80
80
  return pathname;
81
81
  }
82
82
 
83
- /**
84
- * Decode pasted text into a filesystem path candidate. No filesystem access.
85
- *
86
- * Handles terminal drag-drop shell escaping (`\ `, `\(`, ...; skipped on
87
- * win32 where `\` is the path separator), quoted paths, `file://` URIs
88
- * (drive-letter, `file://localhost`, and UNC forms on win32), and `~/`
89
- * expansion. Returns `undefined` when the text is empty, spans multiple
90
- * lines, or is an invalid `file://` URI.
91
- */
92
- export function decodePastedPathCandidate(text: string, options?: DecodePastedPathOptions): string | undefined {
93
- const platform = options?.platform ?? process.platform;
83
+ function isRemoteWindowsPath(candidate: string): boolean {
84
+ return candidate.startsWith("\\\\") || candidate.startsWith("//");
85
+ }
86
+
87
+ function expandHome(candidate: string, options: DecodePastedPathOptions, platform: NodeJS.Platform): string {
88
+ if (!candidate.startsWith("~/")) return candidate;
89
+ return pathApi(platform).join(options.homedir ?? os.homedir(), candidate.slice(2));
90
+ }
91
+
92
+ /** Decode one pasted path candidate without filesystem access. */
93
+ export function decodePastedPathCandidate(text: string, options: DecodePastedPathOptions = {}): string | undefined {
94
+ const platform = options.platform ?? process.platform;
94
95
  let candidate = text.trim();
95
96
  if (!candidate || /[\r\n]/.test(candidate)) return undefined;
96
97
 
97
- // Quoted paths (some terminals quote instead of escaping).
98
98
  if (
99
99
  candidate.length >= 2 &&
100
100
  (candidate.startsWith('"') || candidate.startsWith("'")) &&
101
- candidate.endsWith(candidate[0])
101
+ candidate.endsWith(candidate[0] ?? "")
102
102
  ) {
103
103
  candidate = candidate.slice(1, -1);
104
104
  }
105
105
 
106
106
  if (candidate.startsWith("file://")) {
107
- const decoded = decodeFileUrl(candidate, platform);
107
+ const decoded = decodeFileUrl(candidate, platform, true);
108
108
  if (decoded === undefined) return undefined;
109
109
  candidate = decoded;
110
110
  } else if (platform !== "win32") {
111
- // Terminal drag-drop escapes shell-special characters (`\ `, `\(`, ...).
112
- // Skipped on Windows where `\` is the path separator.
113
111
  candidate = candidate.replace(/\\(.)/g, "$1");
114
112
  }
113
+ return expandHome(candidate, options, platform);
114
+ }
115
115
 
116
- if (candidate.startsWith("~/")) {
117
- candidate = path.join(options?.homedir ?? os.homedir(), candidate.slice(2));
118
- }
116
+ function isAsciiWhitespace(character: string): boolean {
117
+ return (
118
+ character === " " ||
119
+ character === "\t" ||
120
+ character === "\n" ||
121
+ character === "\r" ||
122
+ character === "\v" ||
123
+ character === "\f"
124
+ );
125
+ }
119
126
 
127
+ function decodeListToken(
128
+ candidate: string,
129
+ options: DecodePastedPathOptions,
130
+ platform: NodeJS.Platform,
131
+ ): string | undefined {
132
+ if (candidate.startsWith("file://")) {
133
+ const decoded = decodeFileUrl(candidate, platform, false);
134
+ if (decoded === undefined) return undefined;
135
+ candidate = decoded;
136
+ }
137
+ candidate = expandHome(candidate, options, platform);
138
+ if (platform === "win32" && isRemoteWindowsPath(candidate)) return undefined;
120
139
  return candidate;
121
140
  }
122
141
 
123
- /**
124
- * Sniff the file header for a supported image signature (PNG, JPEG, GIF,
125
- * WEBP). Prevents existing non-image files with image-looking extensions
126
- * (e.g. a text file named `not-image.png`) from being treated as image
127
- * candidates — consuming such a paste would lose the raw path when the
128
- * image loader later rejects the content.
129
- */
130
- function hasSupportedImageMagic(filePath: string): boolean {
131
- let fd: number;
132
- try {
133
- fd = fs.openSync(filePath, "r");
134
- } catch {
135
- return false;
136
- }
137
- try {
138
- // Zero-filled; short reads leave trailing zeros so comparisons fail safely.
139
- const header = Buffer.alloc(12);
140
- fs.readSync(fd, header, 0, 12, 0);
141
- if (header[0] === 0x89 && header[1] === 0x50 && header[2] === 0x4e && header[3] === 0x47) return true; // PNG
142
- if (header[0] === 0xff && header[1] === 0xd8 && header[2] === 0xff) return true; // JPEG
143
- const ascii6 = header.toString("latin1", 0, 6);
144
- if (ascii6 === "GIF87a" || ascii6 === "GIF89a") return true; // GIF
145
- if (header.toString("latin1", 0, 4) === "RIFF" && header.toString("latin1", 8, 12) === "WEBP") return true; // WEBP
146
- return false;
147
- } catch {
148
- return false;
149
- } finally {
150
- fs.closeSync(fd);
142
+ function tokenizePastedPathCandidates(
143
+ text: string,
144
+ options: DecodePastedPathOptions,
145
+ maxCandidates: number,
146
+ ): TokenizePastedPathsResult {
147
+ if (text.length > MAX_PASTED_IMAGE_PASTE_CHARACTERS) return { kind: "invalid" };
148
+ const platform = options.platform ?? process.platform;
149
+ const candidates: string[] = [];
150
+ let state: PastedPathListState = "normal";
151
+ let escapeReturnState: EscapeReturnState = "normal";
152
+ let candidate = "";
153
+ let candidateStarted = false;
154
+
155
+ const startCandidate = (): TokenizePastedPathsResult | undefined => {
156
+ if (candidateStarted) return undefined;
157
+ if (candidates.length >= maxCandidates) return { kind: "too-many", maxCandidates, candidates: [...candidates] };
158
+ candidateStarted = true;
159
+ return undefined;
160
+ };
161
+
162
+ const appendCandidate = (character: string): boolean => {
163
+ candidate += character;
164
+ return candidate.length <= MAX_PASTED_IMAGE_PATH_CHARACTERS;
165
+ };
166
+
167
+ const finishCandidate = (): TokenizePastedPathsResult | undefined => {
168
+ if (!candidateStarted) return undefined;
169
+ if (!candidate) return { kind: "invalid" };
170
+ if (candidates.length >= maxCandidates) return { kind: "too-many", maxCandidates, candidates: [...candidates] };
171
+ const decoded = decodeListToken(candidate, options, platform);
172
+ if (decoded === undefined) return { kind: "invalid" };
173
+ candidates.push(decoded);
174
+ candidate = "";
175
+ candidateStarted = false;
176
+ return undefined;
177
+ };
178
+
179
+ for (const character of text) {
180
+ if (state === "escape") {
181
+ if (!appendCandidate(character)) return { kind: "invalid" };
182
+ state = escapeReturnState;
183
+ continue;
184
+ }
185
+ if (state === "single-quote") {
186
+ if (character === "'") state = "normal";
187
+ else if (!appendCandidate(character)) return { kind: "invalid" };
188
+ continue;
189
+ }
190
+ if (state === "double-quote") {
191
+ if (character === '"') {
192
+ state = "normal";
193
+ } else if (character === "\\" && platform !== "win32") {
194
+ escapeReturnState = "double-quote";
195
+ state = "escape";
196
+ } else if (!appendCandidate(character)) {
197
+ return { kind: "invalid" };
198
+ }
199
+ continue;
200
+ }
201
+
202
+ if (isAsciiWhitespace(character)) {
203
+ const result = finishCandidate();
204
+ if (result) return result;
205
+ } else if (character === "'") {
206
+ const result = startCandidate();
207
+ if (result) return result;
208
+ state = "single-quote";
209
+ } else if (character === '"') {
210
+ const result = startCandidate();
211
+ if (result) return result;
212
+ state = "double-quote";
213
+ } else if (character === "\\" && platform !== "win32") {
214
+ const result = startCandidate();
215
+ if (result) return result;
216
+ escapeReturnState = "normal";
217
+ state = "escape";
218
+ } else {
219
+ const result = startCandidate();
220
+ if (result) return result;
221
+ if (!appendCandidate(character)) return { kind: "invalid" };
222
+ }
151
223
  }
224
+
225
+ if (state !== "normal") return { kind: "invalid" };
226
+ const finalResult = finishCandidate();
227
+ if (finalResult) return finalResult;
228
+ return candidates.length === 0 ? { kind: "invalid" } : { kind: "candidates", candidates };
152
229
  }
153
230
 
154
- function isRecognizedClipboardTempPath(filePath: string): boolean {
155
- const resolved = path.resolve(filePath);
156
- const tempRoot = path.resolve(os.tmpdir());
157
- if (resolved !== tempRoot && !resolved.startsWith(`${tempRoot}${path.sep}`)) return false;
158
- return CLIPBOARD_TEMP_BASENAME_PATTERN.test(path.basename(resolved));
231
+ /** Tokenize a bounded complete path list without filesystem access. */
232
+ export function decodePastedPathCandidates(
233
+ text: string,
234
+ options: DecodePastedPathOptions = {},
235
+ maxCandidates = MAX_PASTED_IMAGE_COUNT,
236
+ ): string[] | undefined {
237
+ const result = tokenizePastedPathCandidates(text, options, maxCandidates);
238
+ return result.kind === "candidates" ? result.candidates : undefined;
159
239
  }
160
240
 
161
- /**
162
- * Returns the resolved path when the whole pasted text is a recognized clipboard
163
- * temp path to an existing image file, otherwise `undefined` (the paste is
164
- * inserted as text). Arbitrary saved image paths are not auto-attached here;
165
- * users attach them explicitly with `@path/to/image.png`.
166
- */
167
- export function resolvePastedImagePath(text: string, options?: ResolvePastedImagePathOptions): string | undefined {
241
+ function resolveCandidate(
242
+ candidate: string,
243
+ options: ResolvePastedImagePathOptions,
244
+ platform: NodeJS.Platform,
245
+ ): string {
246
+ return pathApi(platform).resolve(options.cwd ?? process.cwd(), candidate);
247
+ }
248
+
249
+ function isRecognizedClipboardTempPath(filePath: string, platform: NodeJS.Platform): boolean {
250
+ const api = pathApi(platform);
251
+ const resolved = api.resolve(filePath);
252
+ const tempRoot = api.resolve(os.tmpdir());
253
+ if (resolved !== tempRoot && !resolved.startsWith(`${tempRoot}${api.sep}`)) return false;
254
+ return CLIPBOARD_TEMP_BASENAME_PATTERN.test(api.basename(resolved));
255
+ }
256
+
257
+ /** Resolve one recognized clipboard-temp image path lexically, before any filesystem access. */
258
+ export function resolvePastedImagePath(text: string, options: ResolvePastedImagePathOptions = {}): string | undefined {
259
+ const platform = options.platform ?? process.platform;
168
260
  const candidate = decodePastedPathCandidate(text, options);
169
261
  if (!candidate || !IMAGE_FILE_EXTENSION_PATTERN.test(candidate)) return undefined;
262
+ if (platform === "win32" && isRemoteWindowsPath(candidate)) return undefined;
263
+ const resolved = resolveCandidate(candidate, options, platform);
264
+ return isRecognizedClipboardTempPath(resolved, platform) ? resolved : undefined;
265
+ }
170
266
 
171
- const resolved = path.resolve(options?.cwd ?? process.cwd(), candidate);
172
- if (!isRecognizedClipboardTempPath(resolved)) return undefined;
173
- try {
174
- if (!fs.statSync(resolved).isFile()) return undefined;
175
- } catch {
176
- return undefined;
267
+ /**
268
+ * Parse a complete image path paste without filesystem access. A single path
269
+ * keeps the clipboard-temp-only policy; multi-path saved-image pastes require
270
+ * explicit confirmation before any path is opened.
271
+ */
272
+ export function parsePastedImagePaths(
273
+ text: string,
274
+ options: ResolvePastedImagePathOptions = {},
275
+ ): PastedImagePathParseResult | undefined {
276
+ const platform = options.platform ?? process.platform;
277
+ const tokenized = tokenizePastedPathCandidates(text, options, MAX_PASTED_IMAGE_COUNT);
278
+ if (tokenized.kind === "too-many") {
279
+ if (
280
+ tokenized.candidates.length !== MAX_PASTED_IMAGE_COUNT ||
281
+ tokenized.candidates.some(
282
+ candidate =>
283
+ !IMAGE_FILE_EXTENSION_PATTERN.test(candidate) ||
284
+ (platform === "win32" && isRemoteWindowsPath(candidate)),
285
+ )
286
+ ) {
287
+ return undefined;
288
+ }
289
+ return { kind: "too-many", maxCandidates: tokenized.maxCandidates };
290
+ }
291
+ if (tokenized.kind !== "candidates") return undefined;
292
+
293
+ if (tokenized.candidates.length === 1) {
294
+ const resolved = resolvePastedImagePath(text, options);
295
+ return resolved ? { kind: "paths", paths: [resolved], requiresConfirmation: false } : undefined;
296
+ }
297
+
298
+ const paths: string[] = [];
299
+ for (const candidate of tokenized.candidates) {
300
+ if (!IMAGE_FILE_EXTENSION_PATTERN.test(candidate)) return undefined;
301
+ if (platform === "win32" && isRemoteWindowsPath(candidate)) return undefined;
302
+ paths.push(resolveCandidate(candidate, options, platform));
177
303
  }
178
- if (!hasSupportedImageMagic(resolved)) return undefined;
179
- return resolved;
304
+ return { kind: "paths", paths, requiresConfirmation: true };
180
305
  }
181
306
 
182
307
  export function formatPastedImageReference(placeholder: string, imagePath: string): string {
@@ -0,0 +1,68 @@
1
+ import type TurndownService from "turndown";
2
+
3
+ /** Module-level Turndown instance — built lazily on first use. */
4
+ let turndownPromise: Promise<TurndownService> | undefined;
5
+
6
+ type TurndownListParent = {
7
+ nodeName: string;
8
+ getAttribute(name: string): string | null;
9
+ children: ArrayLike<unknown>;
10
+ };
11
+
12
+ function getTurndown(): Promise<TurndownService> {
13
+ turndownPromise ||= initTurndown();
14
+ return turndownPromise;
15
+ }
16
+
17
+ async function initTurndown(): Promise<TurndownService> {
18
+ const [{ default: TurndownService }, { gfm }] = await Promise.all([
19
+ import("turndown"),
20
+ import("turndown-plugin-gfm"),
21
+ ]);
22
+ const turndown = new TurndownService({
23
+ headingStyle: "atx",
24
+ codeBlockStyle: "fenced",
25
+ bulletListMarker: "-",
26
+ });
27
+ turndown.use(gfm);
28
+ turndown.addRule("strikethrough", {
29
+ filter: ["del", "s", "strike"],
30
+ replacement(content) {
31
+ return `~~${content}~~`;
32
+ },
33
+ });
34
+ turndown.addRule("heading", {
35
+ filter: ["h1", "h2", "h3", "h4", "h5", "h6"],
36
+ replacement(content, node) {
37
+ const level = Number(node.nodeName.charAt(1));
38
+ const prefix = "#".repeat(level);
39
+ const cleaned = content.replace(/\\([.])/g, "$1").trim();
40
+ return `\n\n${prefix} ${cleaned}\n\n`;
41
+ },
42
+ });
43
+ turndown.addRule("listItem", {
44
+ filter: "li",
45
+ replacement(content, node, options) {
46
+ content = content.replace(/^\n+/, "").replace(/\n+$/, "\n").replace(/\n/gm, "\n ");
47
+ const parent = node.parentNode as unknown as TurndownListParent | null;
48
+ let prefix = `${options.bulletListMarker} `;
49
+ if (parent?.nodeName === "OL") {
50
+ const start = parent.getAttribute("start");
51
+ const index = Array.prototype.indexOf.call(parent.children, node);
52
+ prefix = `${(start ? Number(start) : 1) + index}. `;
53
+ }
54
+ return prefix + content + (node.nextSibling ? "\n" : "");
55
+ },
56
+ });
57
+ return turndown;
58
+ }
59
+
60
+ /**
61
+ * Convert HTML to markdown using Turndown with GFM support.
62
+ * Strips script/style tags before conversion.
63
+ */
64
+ export async function htmlToBasicMarkdown(html: string): Promise<string> {
65
+ const cleaned = html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "");
66
+ const turndown = await getTurndown();
67
+ return turndown.turndown(cleaned).trim();
68
+ }
@@ -60,7 +60,7 @@ export const handleReadTheDocs: SpecialHandler = async (
60
60
  ?.querySelectorAll(
61
61
  ".headerlink, .viewcode-link, nav, .sidebar, footer, .related, .sphinxsidebar, .toctree-wrapper",
62
62
  )
63
- .forEach((el: Element) => {
63
+ .forEach((el: { remove(): void }) => {
64
64
  el.remove();
65
65
  });
66
66
 
@@ -2,8 +2,6 @@
2
2
  * Shared types and utilities for web-fetch handlers
3
3
  */
4
4
  import { ptree } from "@gajae-code/utils";
5
- import type TurndownService from "turndown";
6
-
7
5
  import type { AgentStorage } from "../../session/agent-storage";
8
6
  import { ToolAbortError } from "../../tools/tool-errors";
9
7
  import type { AddressResolver } from "../insane/url-guard";
@@ -259,72 +257,7 @@ export async function loadPage(url: string, options: LoadPageOptions = {}): Prom
259
257
  return { content: "", contentType: "", finalUrl: initialUrl, ok: false };
260
258
  }
261
259
 
262
- /** Module-level Turndown instance — built lazily on first use. */
263
- let turndownPromise: Promise<TurndownService> | undefined;
264
-
265
- type TurndownListParent = {
266
- nodeName: string;
267
- getAttribute(name: string): string | null;
268
- children: ArrayLike<unknown>;
269
- };
270
-
271
- function getTurndown(): Promise<TurndownService> {
272
- turndownPromise ||= initTurndown();
273
- return turndownPromise;
274
- }
275
-
276
- async function initTurndown(): Promise<TurndownService> {
277
- const [{ default: TurndownService }, { gfm }] = await Promise.all([
278
- import("turndown"),
279
- import("turndown-plugin-gfm"),
280
- ]);
281
- const turndown = new TurndownService({
282
- headingStyle: "atx",
283
- codeBlockStyle: "fenced",
284
- bulletListMarker: "-",
285
- });
286
- turndown.use(gfm);
287
- turndown.addRule("strikethrough", {
288
- filter: ["del", "s", "strike"],
289
- replacement(content) {
290
- return `~~${content}~~`;
291
- },
292
- });
293
- turndown.addRule("heading", {
294
- filter: ["h1", "h2", "h3", "h4", "h5", "h6"],
295
- replacement(content, node) {
296
- const level = Number(node.nodeName.charAt(1));
297
- const prefix = "#".repeat(level);
298
- const cleaned = content.replace(/\\([.])/g, "$1").trim();
299
- return `\n\n${prefix} ${cleaned}\n\n`;
300
- },
301
- });
302
- turndown.addRule("listItem", {
303
- filter: "li",
304
- replacement(content, node, options) {
305
- content = content.replace(/^\n+/, "").replace(/\n+$/, "\n").replace(/\n/gm, "\n ");
306
- const parent = node.parentNode as unknown as TurndownListParent | null;
307
- let prefix = `${options.bulletListMarker} `;
308
- if (parent?.nodeName === "OL") {
309
- const start = parent.getAttribute("start");
310
- const index = Array.prototype.indexOf.call(parent.children, node);
311
- prefix = `${(start ? Number(start) : 1) + index}. `;
312
- }
313
- return prefix + content + (node.nextSibling ? "\n" : "");
314
- },
315
- });
316
- return turndown;
317
- }
318
-
319
- /**
320
- * Convert HTML to markdown using Turndown with GFM support.
321
- * Strips script/style tags before conversion.
322
- */
323
- export async function htmlToBasicMarkdown(html: string): Promise<string> {
324
- const cleaned = html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "");
325
- const turndown = await getTurndown();
326
- return turndown.turndown(cleaned).trim();
327
- }
260
+ export { htmlToBasicMarkdown } from "./html-to-markdown";
328
261
 
329
262
  /**
330
263
  * Build a RenderResult from markdown content. Calls finalizeOutput internally.