From 87535309c3ac1d1966d16e8b06671de3e125d2da Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Tue, 7 Apr 2026 15:06:53 +0100 Subject: [PATCH] feat(cloud-agent): add cloud prompt builder and file persistence --- apps/code/src/main/trpc/routers/os.ts | 50 +++- .../editor/utils/cloud-prompt.test.ts | 164 ++++++++++++ .../features/editor/utils/cloud-prompt.ts | 237 ++++++++++++++++++ .../message-editor/utils/persistFile.test.ts | 126 ++++++++++ .../message-editor/utils/persistFile.ts | 66 +++++ 5 files changed, 631 insertions(+), 12 deletions(-) create mode 100644 apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts create mode 100644 apps/code/src/renderer/features/editor/utils/cloud-prompt.ts create mode 100644 apps/code/src/renderer/features/message-editor/utils/persistFile.test.ts create mode 100644 apps/code/src/renderer/features/message-editor/utils/persistFile.ts diff --git a/apps/code/src/main/trpc/routers/os.ts b/apps/code/src/main/trpc/routers/os.ts index 24f9dd781..effee8014 100644 --- a/apps/code/src/main/trpc/routers/os.ts +++ b/apps/code/src/main/trpc/routers/os.ts @@ -39,6 +39,7 @@ const expandHomePath = (searchPath: string): string => const MAX_IMAGE_DIMENSION = 1568; const JPEG_QUALITY = 85; +const CLIPBOARD_TEMP_DIR = path.join(os.tmpdir(), "posthog-code-clipboard"); interface DownscaledImage { buffer: Buffer; @@ -88,6 +89,17 @@ function downscaleImage(raw: Buffer, mimeType: string): DownscaledImage { }; } +async function createClipboardTempFilePath( + displayName: string, +): Promise { + const safeName = path.basename(displayName) || "attachment"; + await fsPromises.mkdir(CLIPBOARD_TEMP_DIR, { recursive: true }); + const tempDir = await fsPromises.mkdtemp( + path.join(CLIPBOARD_TEMP_DIR, "attachment-"), + ); + return path.join(tempDir, safeName); +} + const claudeSettingsPath = path.join(os.homedir(), ".claude", "settings.json"); export const osRouter = router({ @@ -136,6 +148,25 @@ export const osRouter = router({ return result.filePaths[0]; }), + /** + * Show file picker dialog + */ + selectFiles: publicProcedure.output(z.array(z.string())).query(async () => { + const win = getMainWindow(); + if (!win) return []; + + const result = await dialog.showOpenDialog(win, { + title: "Select files", + properties: ["openFile", "multiSelections", "treatPackageAsDirectory"], + }); + + if (result.canceled || !result.filePaths?.length) { + return []; + } + + return result.filePaths; + }), + /** * Check if a directory has write access */ @@ -277,18 +308,18 @@ export const osRouter = router({ .input( z.object({ text: z.string(), + originalName: z.string().optional(), }), ) .mutation(async ({ input }) => { - const filename = `pasted-text-${Date.now()}.txt`; - const tempDir = path.join(os.tmpdir(), "posthog-code-clipboard"); - - await fsPromises.mkdir(tempDir, { recursive: true }); - const filePath = path.join(tempDir, filename); + const displayName = path.basename( + input.originalName ?? "pasted-text.txt", + ); + const filePath = await createClipboardTempFilePath(displayName); await fsPromises.writeFile(filePath, input.text, "utf-8"); - return { path: filePath, name: "pasted-text.txt" }; + return { path: filePath, name: displayName }; }), /** @@ -321,12 +352,7 @@ export const osRouter = router({ /\.[^.]+$/, `.${extension}`, ); - const baseName = displayName.replace(/\.[^.]+$/, ""); - const filename = `${baseName}-${Date.now()}.${extension}`; - const tempDir = path.join(os.tmpdir(), "posthog-code-clipboard"); - - await fsPromises.mkdir(tempDir, { recursive: true }); - const filePath = path.join(tempDir, filename); + const filePath = await createClipboardTempFilePath(displayName); await fsPromises.writeFile(filePath, buffer); diff --git a/apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts b/apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts new file mode 100644 index 000000000..e33895f5e --- /dev/null +++ b/apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockFs = vi.hoisted(() => ({ + readAbsoluteFile: { query: vi.fn() }, + readFileAsBase64: { query: vi.fn() }, +})); + +vi.mock("@features/message-editor/utils/imageUtils", () => ({ + isImageFile: (name: string) => + /\.(png|jpe?g|gif|webp|bmp|svg|ico|tiff?)$/i.test(name), +})); + +vi.mock("@features/code-editor/utils/imageUtils", () => ({ + getImageMimeType: (name: string) => { + const ext = name.split(".").pop()?.toLowerCase(); + const map: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + }; + return map[ext ?? ""] ?? "image/png"; + }, +})); + +vi.mock("@renderer/trpc/client", () => ({ + trpcClient: { + fs: mockFs, + }, +})); + +import { parseAttachmentUri } from "@utils/promptContent"; +import { + buildCloudPromptBlocks, + buildCloudTaskDescription, + serializeCloudPrompt, + stripAbsoluteFileTags, +} from "./cloud-prompt"; + +describe("cloud-prompt", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("strips absolute file tags but keeps repo file tags", () => { + const prompt = + 'review and '; + + expect(stripAbsoluteFileTags(prompt)).toBe( + 'review and', + ); + }); + + it("builds a safe cloud task description for local attachments", () => { + const description = buildCloudTaskDescription( + 'review and ', + ); + + expect(description).toBe( + 'review and\n\nAttached files: test.txt', + ); + }); + + it("embeds text attachments as ACP resources", async () => { + mockFs.readAbsoluteFile.query.mockResolvedValue("hello from file"); + + const blocks = await buildCloudPromptBlocks( + 'read this ', + ); + + expect(blocks).toEqual([ + { type: "text", text: "read this" }, + expect.objectContaining({ + type: "resource", + resource: expect.objectContaining({ + text: "hello from file", + mimeType: "text/plain", + }), + }), + ]); + + const attachmentBlock = blocks[1]; + expect(attachmentBlock.type).toBe("resource"); + if (attachmentBlock.type !== "resource") { + throw new Error("Expected a resource attachment block"); + } + + expect(parseAttachmentUri(attachmentBlock.resource.uri)).toEqual({ + id: attachmentBlock.resource.uri, + label: "test.txt", + }); + }); + + it("embeds image attachments as ACP image blocks", async () => { + const fakeBase64 = btoa("tiny-image-data"); + mockFs.readFileAsBase64.query.mockResolvedValue(fakeBase64); + + const blocks = await buildCloudPromptBlocks( + 'check ', + ); + + expect(blocks).toHaveLength(2); + expect(blocks[0]).toEqual({ type: "text", text: "check" }); + expect(blocks[1]).toMatchObject({ + type: "image", + data: fakeBase64, + mimeType: "image/png", + }); + }); + + it("rejects images over 5 MB", async () => { + // 5 MB in base64 is ~6.67M chars; generate slightly over + const oversize = "A".repeat(7_000_000); + mockFs.readFileAsBase64.query.mockResolvedValue(oversize); + + await expect( + buildCloudPromptBlocks('see '), + ).rejects.toThrow(/too large/); + }); + + it("rejects unsupported image formats", async () => { + await expect( + buildCloudPromptBlocks('see '), + ).rejects.toThrow(/Unsupported image/); + }); + + it("throws when readAbsoluteFile returns null", async () => { + mockFs.readAbsoluteFile.query.mockResolvedValue(null); + + await expect( + buildCloudPromptBlocks('read '), + ).rejects.toThrow(/Unable to read/); + }); + + it("throws when readFileAsBase64 returns falsy for images", async () => { + mockFs.readFileAsBase64.query.mockResolvedValue(null); + + await expect( + buildCloudPromptBlocks('see '), + ).rejects.toThrow(/Unable to read/); + }); + + it("throws on empty prompt with no attachments", async () => { + await expect(buildCloudPromptBlocks("")).rejects.toThrow(/cannot be empty/); + }); + + it("serializes structured prompts for pending cloud messages", () => { + const serialized = serializeCloudPrompt([ + { type: "text", text: "read this" }, + { + type: "resource", + resource: { + uri: "attachment://test.txt", + text: "hello from file", + mimeType: "text/plain", + }, + }, + ]); + + expect(serialized).toContain("__twig_cloud_prompt_v1__:"); + expect(serialized).toContain('"type":"resource"'); + }); +}); diff --git a/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts b/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts new file mode 100644 index 000000000..92245c696 --- /dev/null +++ b/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts @@ -0,0 +1,237 @@ +import type { ContentBlock } from "@agentclientprotocol/sdk"; +import { getImageMimeType } from "@features/code-editor/utils/imageUtils"; +import { isImageFile } from "@features/message-editor/utils/imageUtils"; +import { CLOUD_PROMPT_PREFIX, serializeCloudPrompt } from "@posthog/shared"; +import { trpcClient } from "@renderer/trpc/client"; +import { getFileExtension, getFileName, isAbsolutePath } from "@utils/path"; +import { makeAttachmentUri } from "@utils/promptContent"; +import { unescapeXmlAttr } from "@utils/xml"; + +const ABSOLUTE_FILE_TAG_REGEX = //g; +const TEXT_EXTENSIONS = new Set([ + "c", + "cc", + "cfg", + "conf", + "cpp", + "cs", + "css", + "csv", + "env", + "gitignore", + "go", + "h", + "hpp", + "html", + "ini", + "java", + "js", + "json", + "jsx", + "log", + "md", + "mjs", + "py", + "rb", + "rs", + "scss", + "sh", + "sql", + "svg", + "toml", + "ts", + "tsx", + "txt", + "xml", + "yaml", + "yml", + "zsh", +]); +const TEXT_FILENAMES = new Set([ + ".env", + ".gitignore", + "Dockerfile", + "LICENSE", + "Makefile", + "README", + "README.md", +]); +const CLOUD_IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp"]); +const TEXT_MIME_TYPES: Record = { + json: "application/json", + md: "text/markdown", + svg: "image/svg+xml", + xml: "application/xml", +}; + +const MAX_EMBEDDED_TEXT_CHARS = 100_000; +const MAX_EMBEDDED_IMAGE_BYTES = 5 * 1024 * 1024; + +function isTextAttachment(filePath: string): boolean { + const fileName = getFileName(filePath); + const ext = getFileExtension(filePath); + return TEXT_FILENAMES.has(fileName) || TEXT_EXTENSIONS.has(ext); +} + +function getTextMimeType(filePath: string): string { + const ext = getFileExtension(filePath); + return TEXT_MIME_TYPES[ext] ?? "text/plain"; +} + +export function isSupportedCloudImageAttachment(filePath: string): boolean { + return CLOUD_IMAGE_EXTENSIONS.has(getFileExtension(filePath)); +} + +export function isSupportedCloudTextAttachment(filePath: string): boolean { + return isTextAttachment(filePath); +} + +function estimateBase64Bytes(base64: string): number { + const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; + return Math.floor((base64.length * 3) / 4) - padding; +} + +function truncateText(text: string): string { + if (text.length <= MAX_EMBEDDED_TEXT_CHARS) { + return text; + } + + return `${text.slice(0, MAX_EMBEDDED_TEXT_CHARS)}\n\n[Attachment truncated to ${MAX_EMBEDDED_TEXT_CHARS.toLocaleString()} characters for this cloud prompt.]`; +} + +function collectAbsoluteFileTagPaths(prompt: string): string[] { + const filePaths: string[] = []; + + for (const match of prompt.matchAll(ABSOLUTE_FILE_TAG_REGEX)) { + const decodedPath = unescapeXmlAttr(match[1]); + if (isAbsolutePath(decodedPath)) { + filePaths.push(decodedPath); + } + } + + return filePaths; +} + +function unique(values: T[]): T[] { + return Array.from(new Set(values)); +} + +function normalizePromptText(prompt: string): string { + return prompt.replace(/\n{3,}/g, "\n\n").trim(); +} + +export function stripAbsoluteFileTags(prompt: string): string { + return normalizePromptText( + prompt.replaceAll(ABSOLUTE_FILE_TAG_REGEX, (match, rawPath: string) => { + const decodedPath = unescapeXmlAttr(rawPath); + return isAbsolutePath(decodedPath) ? "" : match; + }), + ); +} + +export function getAbsoluteAttachmentPaths( + prompt: string, + filePaths: string[] = [], +): string[] { + const absolutePaths = [ + ...collectAbsoluteFileTagPaths(prompt), + ...filePaths.filter(isAbsolutePath), + ]; + return unique(absolutePaths); +} + +export function buildCloudTaskDescription( + prompt: string, + filePaths: string[] = [], +): string { + const strippedPrompt = stripAbsoluteFileTags(prompt); + const attachmentNames = getAbsoluteAttachmentPaths(prompt, filePaths).map( + getFileName, + ); + + if (attachmentNames.length === 0) { + return strippedPrompt; + } + + const attachmentSummary = `Attached files: ${attachmentNames.join(", ")}`; + return strippedPrompt + ? `${strippedPrompt}\n\n${attachmentSummary}` + : attachmentSummary; +} + +async function buildAttachmentBlock(filePath: string): Promise { + const fileName = getFileName(filePath); + const uri = makeAttachmentUri(filePath); + + if (isSupportedCloudImageAttachment(fileName)) { + const base64 = await trpcClient.fs.readFileAsBase64.query({ filePath }); + if (!base64) { + throw new Error(`Unable to read attached image ${fileName}`); + } + + if (estimateBase64Bytes(base64) > MAX_EMBEDDED_IMAGE_BYTES) { + throw new Error( + `${fileName} is too large for a cloud image attachment (max 5 MB)`, + ); + } + + return { + type: "image", + data: base64, + mimeType: getImageMimeType(fileName), + uri, + }; + } + + if (isImageFile(fileName)) { + throw new Error( + `Cloud image attachments currently support PNG, JPG, GIF, and WebP. Unsupported image: ${fileName}`, + ); + } + + if (!isTextAttachment(fileName)) { + throw new Error( + `Cloud attachments currently support text and image files. Unsupported attachment: ${fileName}`, + ); + } + + const text = await trpcClient.fs.readAbsoluteFile.query({ filePath }); + if (text === null) { + throw new Error(`Unable to read attached file ${fileName}`); + } + + return { + type: "resource", + resource: { + uri, + text: truncateText(text), + mimeType: getTextMimeType(fileName), + }, + }; +} + +export async function buildCloudPromptBlocks( + prompt: string, + filePaths: string[] = [], +): Promise { + const promptText = stripAbsoluteFileTags(prompt); + const attachmentPaths = getAbsoluteAttachmentPaths(prompt, filePaths); + + const attachmentBlocks = await Promise.all( + attachmentPaths.map(buildAttachmentBlock), + ); + + const blocks: ContentBlock[] = []; + if (promptText) { + blocks.push({ type: "text", text: promptText }); + } + blocks.push(...attachmentBlocks); + + if (blocks.length === 0) { + throw new Error("Cloud prompt cannot be empty"); + } + + return blocks; +} + +export { CLOUD_PROMPT_PREFIX, serializeCloudPrompt }; diff --git a/apps/code/src/renderer/features/message-editor/utils/persistFile.test.ts b/apps/code/src/renderer/features/message-editor/utils/persistFile.test.ts new file mode 100644 index 000000000..74a121742 --- /dev/null +++ b/apps/code/src/renderer/features/message-editor/utils/persistFile.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockSaveClipboardImage = vi.hoisted(() => vi.fn()); +const mockSaveClipboardText = vi.hoisted(() => vi.fn()); + +vi.mock("@renderer/trpc/client", () => ({ + trpcClient: { + os: { + saveClipboardImage: { + mutate: mockSaveClipboardImage, + }, + saveClipboardText: { + mutate: mockSaveClipboardText, + }, + }, + }, +})); + +vi.mock("@features/code-editor/utils/imageUtils", () => ({ + getImageMimeType: () => "image/png", +})); + +import { + persistBrowserFile, + persistImageFile, + persistTextContent, +} from "./persistFile"; + +describe("persistFile", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("passes original text filenames through clipboard persistence", async () => { + mockSaveClipboardText.mockResolvedValue({ + path: "/tmp/posthog-code-clipboard/attachment-123/notes.md", + name: "notes.md", + }); + + const result = await persistTextContent("# hello", "notes.md"); + + expect(mockSaveClipboardText).toHaveBeenCalledWith({ + text: "# hello", + originalName: "notes.md", + }); + expect(result).toEqual({ + path: "/tmp/posthog-code-clipboard/attachment-123/notes.md", + name: "notes.md", + }); + }); + + it("persists image files via saveClipboardImage", async () => { + mockSaveClipboardImage.mockResolvedValue({ + path: "/tmp/posthog-code-clipboard/attachment-789/photo.png", + name: "photo.png", + mimeType: "image/png", + }); + + const file = { + name: "photo.png", + type: "image/png", + arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)), + } as unknown as File; + + const result = await persistImageFile(file); + + expect(mockSaveClipboardImage).toHaveBeenCalledWith( + expect.objectContaining({ + mimeType: "image/png", + originalName: "photo.png", + }), + ); + expect(result).toEqual({ + path: "/tmp/posthog-code-clipboard/attachment-789/photo.png", + name: "photo.png", + mimeType: "image/png", + }); + }); + + it("routes image files through persistBrowserFile", async () => { + mockSaveClipboardImage.mockResolvedValue({ + path: "/tmp/posthog-code-clipboard/attachment-abc/img.png", + name: "img.png", + mimeType: "image/png", + }); + + const file = { + name: "img.png", + type: "image/png", + arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)), + } as unknown as File; + + const result = await persistBrowserFile(file); + + expect(result).toEqual({ + id: "/tmp/posthog-code-clipboard/attachment-abc/img.png", + label: "img.png", + }); + }); + + it("throws for unsupported file types", async () => { + const file = { name: "archive.zip" } as unknown as File; + await expect(persistBrowserFile(file)).rejects.toThrow(/Unsupported/); + }); + + it("returns the preserved filename for browser-selected text files", async () => { + mockSaveClipboardText.mockResolvedValue({ + path: "/tmp/posthog-code-clipboard/attachment-456/config.json", + name: "config.json", + }); + + const file = { + name: "config.json", + text: vi.fn().mockResolvedValue('{"ok":true}'), + } as unknown as File; + + await expect(persistBrowserFile(file)).resolves.toEqual({ + id: "/tmp/posthog-code-clipboard/attachment-456/config.json", + label: "config.json", + }); + expect(mockSaveClipboardText).toHaveBeenCalledWith({ + text: '{"ok":true}', + originalName: "config.json", + }); + }); +}); diff --git a/apps/code/src/renderer/features/message-editor/utils/persistFile.ts b/apps/code/src/renderer/features/message-editor/utils/persistFile.ts new file mode 100644 index 000000000..e13ee77a9 --- /dev/null +++ b/apps/code/src/renderer/features/message-editor/utils/persistFile.ts @@ -0,0 +1,66 @@ +import { getImageMimeType } from "@features/code-editor/utils/imageUtils"; +import { + isSupportedCloudImageAttachment, + isSupportedCloudTextAttachment, +} from "@features/editor/utils/cloud-prompt"; +import { trpcClient } from "@renderer/trpc/client"; + +const CHUNK_SIZE = 8192; + +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + const chunks: string[] = []; + for (let i = 0; i < bytes.length; i += CHUNK_SIZE) { + chunks.push(String.fromCharCode(...bytes.subarray(i, i + CHUNK_SIZE))); + } + return btoa(chunks.join("")); +} + +export interface PersistedFile { + path: string; + name: string; + mimeType?: string; +} + +export async function persistImageFile(file: File): Promise { + const arrayBuffer = await file.arrayBuffer(); + const base64Data = arrayBufferToBase64(arrayBuffer); + const mimeType = file.type || getImageMimeType(file.name); + + const result = await trpcClient.os.saveClipboardImage.mutate({ + base64Data, + mimeType, + originalName: file.name, + }); + return { path: result.path, name: result.name, mimeType: result.mimeType }; +} + +export async function persistTextContent( + text: string, + originalName?: string, +): Promise { + const result = await trpcClient.os.saveClipboardText.mutate({ + text, + originalName, + }); + return { path: result.path, name: result.name }; +} + +export async function persistBrowserFile( + file: File, +): Promise<{ id: string; label: string }> { + if (isSupportedCloudImageAttachment(file.name)) { + const result = await persistImageFile(file); + return { id: result.path, label: file.name }; + } + + if (isSupportedCloudTextAttachment(file.name)) { + const text = await file.text(); + const result = await persistTextContent(text, file.name); + return { id: result.path, label: result.name }; + } + + throw new Error( + `Unsupported attachment: ${file.name}. Cloud attachments currently support text files and PNG/JPG/GIF/WebP images.`, + ); +}