From e0c19607b7e1ae5700a1db2fe9000d1e1fd24353 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 07:51:04 +0000 Subject: [PATCH] fix: allow MEDIA local paths with spaces --- CHANGELOG.md | 1 + src/auto-reply/reply/get-reply-run.ts | 2 +- src/media/parse.test.ts | 18 ++++++++ src/media/parse.ts | 64 ++++++++++++++++++++++++--- 4 files changed, 78 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a6a80d80..e013f902e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.clawd.bot - **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert. ### Fixes +- Media: accept MEDIA paths with spaces/tilde and prefer the message tool hint for image replies. - Config: avoid stack traces for invalid configs and log the config path. - CLI: read Codex CLI account_id for workspace billing. (#1422) Thanks @aj47. - Doctor: avoid recreating WhatsApp config when only legacy routing keys remain. (#900) diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 1b0bfd4eb..28c9e24cb 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -247,7 +247,7 @@ export async function runPreparedReply( const prefixedBody = [threadStarterNote, prefixedBodyBase].filter(Boolean).join("\n\n"); const mediaNote = buildInboundMediaNote(ctx); const mediaReplyHint = mediaNote - ? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body." + ? "To send an image back, prefer the message tool (media/path/filePath). If you must inline, use MEDIA:/path or MEDIA:https://example.com/image.jpg (spaces ok, quote if needed). Keep caption in the text body." : undefined; let prefixedCommandBody = mediaNote ? [mediaNote, mediaReplyHint, prefixedBody ?? ""].filter(Boolean).join("\n").trim() diff --git a/src/media/parse.test.ts b/src/media/parse.test.ts index f910d851c..74c1eb52d 100644 --- a/src/media/parse.test.ts +++ b/src/media/parse.test.ts @@ -9,6 +9,24 @@ describe("splitMediaFromOutput", () => { expect(result.text).toBe("Hello world"); }); + it("captures media paths with spaces", () => { + const result = splitMediaFromOutput("MEDIA:/Users/pete/My File.png"); + expect(result.mediaUrls).toEqual(["/Users/pete/My File.png"]); + expect(result.text).toBe(""); + }); + + it("captures quoted media paths with spaces", () => { + const result = splitMediaFromOutput('MEDIA:"/Users/pete/My File.png"'); + expect(result.mediaUrls).toEqual(["/Users/pete/My File.png"]); + expect(result.text).toBe(""); + }); + + it("captures tilde media paths with spaces", () => { + const result = splitMediaFromOutput("MEDIA:~/Pictures/My File.png"); + expect(result.mediaUrls).toEqual(["~/Pictures/My File.png"]); + expect(result.text).toBe(""); + }); + it("keeps audio_as_voice detection stable across calls", () => { const input = "Hello [[audio_as_voice]]"; const first = splitMediaFromOutput(input); diff --git a/src/media/parse.ts b/src/media/parse.ts index 31271ceb7..4e35d6775 100644 --- a/src/media/parse.ts +++ b/src/media/parse.ts @@ -14,11 +14,26 @@ function cleanCandidate(raw: string) { return raw.replace(/^[`"'[{(]+/, "").replace(/[`"'\\})\],]+$/, ""); } -function isValidMedia(candidate: string) { +function isValidMedia(candidate: string, opts?: { allowSpaces?: boolean }) { if (!candidate) return false; - if (candidate.length > 1024) return false; - if (/\s/.test(candidate)) return false; - return /^https?:\/\//i.test(candidate) || candidate.startsWith("/") || candidate.startsWith("./"); + if (candidate.length > 4096) return false; + if (!opts?.allowSpaces && /\s/.test(candidate)) return false; + if (/^https?:\/\//i.test(candidate)) return true; + if (candidate.startsWith("/")) return true; + if (candidate.startsWith("./")) return true; + if (candidate.startsWith("../")) return true; + if (candidate.startsWith("~")) return true; + return false; +} + +function unwrapQuoted(value: string): string | undefined { + const trimmed = value.trim(); + if (trimmed.length < 2) return undefined; + const first = trimmed[0]; + const last = trimmed[trimmed.length - 1]; + if (first !== last) return undefined; + if (first !== `"` && first !== "'" && first !== "`") return undefined; + return trimmed.slice(1, -1).trim(); } // Check if a character offset is inside any fenced code block @@ -73,18 +88,55 @@ export function splitMediaFromOutput(raw: string): { pieces.push(line.slice(cursor, start)); const payload = match[1]; - const parts = payload.split(/\s+/).filter(Boolean); + const unwrapped = unwrapQuoted(payload); + const payloadValue = unwrapped ?? payload; + const parts = unwrapped ? [unwrapped] : payload.split(/\s+/).filter(Boolean); + const mediaStartIndex = media.length; + let validCount = 0; const invalidParts: string[] = []; for (const part of parts) { const candidate = normalizeMediaSource(cleanCandidate(part)); - if (isValidMedia(candidate)) { + if (isValidMedia(candidate, unwrapped ? { allowSpaces: true } : undefined)) { media.push(candidate); hasValidMedia = true; + validCount += 1; } else { invalidParts.push(part); } } + const trimmedPayload = payloadValue.trim(); + const looksLikeLocalPath = + trimmedPayload.startsWith("/") || + trimmedPayload.startsWith("./") || + trimmedPayload.startsWith("../") || + trimmedPayload.startsWith("~") || + trimmedPayload.startsWith("file://"); + if ( + !unwrapped && + validCount === 1 && + invalidParts.length > 0 && + /\s/.test(payloadValue) && + looksLikeLocalPath + ) { + const fallback = normalizeMediaSource(cleanCandidate(payloadValue)); + if (isValidMedia(fallback, { allowSpaces: true })) { + media.splice(mediaStartIndex, media.length - mediaStartIndex, fallback); + hasValidMedia = true; + validCount = 1; + invalidParts.length = 0; + } + } + + if (!hasValidMedia) { + const fallback = normalizeMediaSource(cleanCandidate(payloadValue)); + if (isValidMedia(fallback, { allowSpaces: true })) { + media.push(fallback); + hasValidMedia = true; + invalidParts.length = 0; + } + } + if (hasValidMedia && invalidParts.length > 0) { pieces.push(invalidParts.join(" ")); }