diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 76e5d34..25ec876 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,6 +13,7 @@ export { type ChatMessage, type ChatSession, createBashTool, + createEditFileTool, createReadTool, createSession, DEFAULT_MAX_BYTES, diff --git a/packages/powerpoint/src/lib/system-prompt.ts b/packages/powerpoint/src/lib/system-prompt.ts index c31491e..f753af5 100644 --- a/packages/powerpoint/src/lib/system-prompt.ts +++ b/packages/powerpoint/src/lib/system-prompt.ts @@ -16,6 +16,7 @@ Available tools: FILES & SHELL: - read: Read uploaded files (images, CSV, text). Images are returned for visual analysis. +- edit_file: Write (\`content\`) or edit (\`edits: [{old_text, new_text}]\`) a file in the virtual filesystem. Use \`edits\` to change a few fields in an existing script instead of rewriting the whole file. - bash: Execute bash commands in a sandboxed virtual filesystem. User uploads are in /home/user/uploads/. Custom commands available in bash: ${customCommandsList} @@ -789,6 +790,28 @@ markDirty(); This pattern applies all text changes in one slide re-import, avoiding per-shape flashing. The \`replaceTextBody\` helper preserves \`\` and \`\` just like \`edit_slide_text\` does. +## Reusing OOXML Templates Across Slides (edit-slide-xml CLI) + +When you need to build **multiple slides that share the same OOXML structure** (e.g. a retrospective where every slide is a stylized tweet card, a pitch deck with a repeated chapter-header layout, a series of data cards), do NOT paste the same 200-line XML template into many \`edit_slide_xml\` tool calls. That wastes tokens and makes bug fixes cost O(slides). + +Instead use the \`edit-slide-xml\` **bash CLI**: + +\`\`\` +edit-slide-xml [--lib=a.js,b.js] +\`\`\` + +Workflow: +1. **Write the template once** to the VFS with \`edit_file\` (e.g. \`/home/user/scratch/card_template.js\`). Define reusable helpers as \`const buildCard = async (cfg) => { ... };\`. They can freely reference the injected globals (\`zip\`, \`markDirty\`, \`escapeXml\`, \`DOMParser\`, \`XMLSerializer\`, \`readFile\`, etc.) — same sandbox as the \`edit_slide_xml\` tool. +2. **Per slide**, write a tiny script to the VFS with \`edit_file\` that defines a \`CONFIG\` object and calls the helper: \`await buildCard(CONFIG);\`. Keep these scripts small (20–40 lines). +3. **Apply** with \`bash edit-slide-xml /home/user/scratch/slide_N.js --lib=/home/user/scratch/card_template.js\`. +4. **Revise a field** on a built slide by calling \`edit_file\` with targeted \`edits\` on the script — do NOT rewrite the whole file — then re-run the CLI to apply. + +When to use which: +- **\`edit_slide_xml\` tool**: one-off slide edits, diagrams specific to a single slide, ad-hoc XML surgery. Inline code, no VFS round-trip. +- **\`edit-slide-xml\` CLI**: any time the same structure will be applied to 2+ slides, or when you expect to iterate (revise fields, re-render). The template lives in the VFS and bug fixes happen in one place. + +Rule of thumb: if you would otherwise paste the same template into 3+ tool calls, promote it to a lib file and switch to the CLI. + ## Auto-sizing Shapes After Text Changes After editing text in a shape (via \`execute_office_js\` or after using \`edit_slide_text\`), set the shape to auto-size so it fits the text content. This is critical for overlap detection — PowerPoint needs to recalculate dimensions based on the new text before you can read accurate \`width\` and \`height\` values. diff --git a/packages/powerpoint/src/lib/tools/index.ts b/packages/powerpoint/src/lib/tools/index.ts index 96d0a3a..9032861 100644 --- a/packages/powerpoint/src/lib/tools/index.ts +++ b/packages/powerpoint/src/lib/tools/index.ts @@ -1,5 +1,9 @@ import type { AgentContext } from "@office-agents/core"; -import { createBashTool, createReadTool } from "@office-agents/core"; +import { + createBashTool, + createEditFileTool, + createReadTool, +} from "@office-agents/core"; import { duplicateSlideTool } from "./duplicate-slide"; import { createEditSlideChartTool } from "./edit-slide-chart"; import { createEditSlideMasterTool } from "./edit-slide-master"; @@ -15,6 +19,7 @@ export function createPptTools(ctx: AgentContext) { return [ // fs tools createReadTool(ctx), + createEditFileTool(ctx), createBashTool(ctx), // PPT read tools screenshotSlideTool, @@ -33,6 +38,7 @@ export function createPptTools(ctx: AgentContext) { export { createBashTool, + createEditFileTool, createReadTool, createEditSlideChartTool, createEditSlideMasterTool, diff --git a/packages/powerpoint/src/lib/vfs/custom-commands.ts b/packages/powerpoint/src/lib/vfs/custom-commands.ts index 0d2922d..533cf59 100644 --- a/packages/powerpoint/src/lib/vfs/custom-commands.ts +++ b/packages/powerpoint/src/lib/vfs/custom-commands.ts @@ -3,9 +3,11 @@ import { type DescribedCommand, getSharedCustomCommands, type StorageNamespace, + sandboxedEval, } from "@office-agents/core"; import { defineCommand } from "just-bash/browser"; import { safeRun, withSlideZip } from "../pptx/slide-zip"; +import { escapeXml } from "../pptx/xml-utils"; async function resolveVfsPath( ctx: { cwd: string; fs: { readFileBuffer(p: string): Promise } }, @@ -783,11 +785,159 @@ const insertIconCmd: DescribedCommand = { }, }; +const editSlideXmlCmd: DescribedCommand = { + promptSnippet: + "- edit-slide-xml [--lib=a.js,b.js] — Run a JS script from the VFS against a slide's OOXML (1-based slide). Script body runs in the same sandbox as the edit_slide_xml tool with globals: zip, markDirty, escapeXml, readFile, readFileBuffer, writeFile, DOMParser, XMLSerializer. Use --lib to prepend one or more helper files (comma-separated). Put reusable template code in a lib file and keep each per-slide script short.", + command: { + name: "edit-slide-xml", + load: async () => + defineCommand("edit-slide-xml", async (args, ctx) => { + const flags: Record = {}; + const positional: string[] = []; + for (const arg of args) { + const match = arg.match(/^--(\w+)=(.+)$/); + if (match) { + flags[match[1]] = match[2]; + } else { + positional.push(arg); + } + } + + if (positional.length < 2) { + return { + stdout: "", + stderr: + "Usage: edit-slide-xml [--lib=a.js,b.js]\n" + + " slide - 1-based slide number\n" + + " script.js - Path to script file in VFS (absolute or relative to cwd)\n" + + " --lib - Comma-separated helper files prepended to the script\n" + + "\n" + + "The script runs as the body of an async function in the same sandbox as\n" + + "the edit_slide_xml tool. Available globals:\n" + + " zip, markDirty, escapeXml, readFile, readFileBuffer, writeFile,\n" + + " DOMParser, XMLSerializer, console, Math, Date\n" + + "\n" + + "Libs are concatenated before the script. Helpers defined as `const fn = ...`\n" + + "in a lib can be called from the script.\n", + exitCode: 1, + }; + } + + const [slideArg, scriptPath] = positional; + const slideNum = Number.parseInt(slideArg, 10); + if (Number.isNaN(slideNum) || slideNum < 1) { + return { + stdout: "", + stderr: "Slide must be a positive number (1-based)", + exitCode: 1, + }; + } + const slideIndex = slideNum - 1; + + const cwd = ctx.cwd; + const resolveVfs = (p: string): string => + p.startsWith("/") ? p : `${cwd}/${p}`; + + const decoder = new TextDecoder(); + async function readText(p: string): Promise { + const buf = await ctx.fs.readFileBuffer(resolveVfs(p)); + return decoder.decode(buf); + } + + let scriptSource: string; + try { + scriptSource = await readText(scriptPath); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return { + stdout: "", + stderr: `Failed to read script ${scriptPath}: ${msg}`, + exitCode: 1, + }; + } + + const libPaths = flags.lib + ? flags.lib + .split(",") + .map((p) => p.trim()) + .filter(Boolean) + : []; + const libSources: string[] = []; + for (const libPath of libPaths) { + try { + const src = await readText(libPath); + libSources.push(`// --- lib: ${libPath} ---\n${src}`); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return { + stdout: "", + stderr: `Failed to read lib ${libPath}: ${msg}`, + exitCode: 1, + }; + } + } + + const combined = + libSources.length > 0 + ? `${libSources.join("\n\n")}\n\n// --- script: ${scriptPath} ---\n${scriptSource}` + : scriptSource; + + try { + const result = await safeRun(async (context) => { + return withSlideZip(context, slideIndex, async (args) => { + return sandboxedEval(combined, { + ...args, + escapeXml, + readFile: (p: string) => readText(p), + readFileBuffer: (p: string) => + ctx.fs.readFileBuffer(resolveVfs(p)), + writeFile: async (p: string, content: string | Uint8Array) => { + const full = resolveVfs(p); + const dir = full.substring(0, full.lastIndexOf("/")); + if (dir && dir !== "/") { + try { + await ctx.fs.mkdir(dir, { recursive: true }); + } catch { + // exists + } + } + await ctx.fs.writeFile(full, content); + }, + DOMParser, + XMLSerializer, + }); + }); + }); + + const libSummary = + libPaths.length > 0 ? ` (libs: ${libPaths.join(", ")})` : ""; + const resultSummary = + result === undefined || result === null + ? "" + : ` → ${JSON.stringify(result).slice(0, 200)}`; + return { + stdout: `Applied ${scriptPath} to slide ${slideNum}${libSummary}${resultSummary}`, + stderr: "", + exitCode: 0, + }; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return { + stdout: "", + stderr: `edit-slide-xml failed: ${msg}`, + exitCode: 1, + }; + } + }), + }, +}; + export function getCustomCommands(ns: StorageNamespace): CustomCommandsResult { const local: DescribedCommand[] = [ insertImageCmd, searchIconsCmd, insertIconCmd, + editSlideXmlCmd, ]; const shared = getSharedCustomCommands({ ns, diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index ba38e1a..c130b20 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -83,6 +83,7 @@ export { } from "./storage"; // Tools export { createBashTool } from "./tools/bash"; +export { createEditFileTool } from "./tools/edit-file"; export { createReadTool } from "./tools/read-file"; export { defineTool, diff --git a/packages/sdk/src/tools/edit-file.ts b/packages/sdk/src/tools/edit-file.ts new file mode 100644 index 0000000..8b86dcd --- /dev/null +++ b/packages/sdk/src/tools/edit-file.ts @@ -0,0 +1,155 @@ +import { Type } from "@sinclair/typebox"; +import type { AgentContext } from "../context"; +import { defineTool, toolError, toolSuccess } from "./types"; + +interface EditOp { + old_text: string; + new_text: string; +} + +function resolvePath(path: string): string { + return path.startsWith("/") ? path : `/home/user/uploads/${path}`; +} + +function countOccurrences(haystack: string, needle: string): number { + if (needle.length === 0) return 0; + let count = 0; + let idx = 0; + while (true) { + const found = haystack.indexOf(needle, idx); + if (found === -1) break; + count++; + idx = found + needle.length; + } + return count; +} + +export function createEditFileTool(ctx: AgentContext) { + return defineTool({ + name: "edit_file", + label: "Edit File", + description: + "Write or edit a file in the virtual filesystem. " + + "Pass `content` to create or overwrite the entire file. " + + "Pass `edits` (array of { old_text, new_text }) to apply targeted replacements; " + + "each old_text must match exactly once in the current file contents. " + + "Exactly one of `content` or `edits` must be provided. " + + "Parent directories are created automatically.", + parameters: Type.Object({ + path: Type.String({ + description: + "File path. Absolute (starting with /) or relative to /home/user/uploads/.", + }), + content: Type.Optional( + Type.String({ + description: + "Full file contents. Creates the file if missing, overwrites if present. " + + "Mutually exclusive with `edits`.", + }), + ), + edits: Type.Optional( + Type.Array( + Type.Object({ + old_text: Type.String({ + description: + "Exact text to find. Must match exactly once in the file.", + }), + new_text: Type.String({ + description: "Replacement text.", + }), + }), + { + description: + "Ordered list of replacements applied sequentially. Each old_text " + + "must be unique in the file at the moment it is applied.", + }, + ), + ), + explanation: Type.Optional( + Type.String({ + description: "Brief explanation (max 50 chars)", + maxLength: 50, + }), + ), + }), + execute: async (_toolCallId, params) => { + const hasContent = params.content !== undefined; + const hasEdits = Array.isArray(params.edits) && params.edits.length > 0; + + if (hasContent && hasEdits) { + return toolError( + "Pass either `content` (full rewrite) or `edits` (targeted replacements), not both.", + ); + } + if (!hasContent && !hasEdits) { + return toolError( + "Must provide either `content` (full rewrite) or a non-empty `edits` array.", + ); + } + + const fullPath = resolvePath(params.path); + + try { + if (hasContent) { + const existed = await ctx.fileExists(fullPath); + await ctx.writeFile(fullPath, params.content as string); + const bytes = new TextEncoder().encode( + params.content as string, + ).length; + return toolSuccess({ + success: true, + path: fullPath, + action: existed ? "overwrote" : "created", + bytes, + }); + } + + if (!(await ctx.fileExists(fullPath))) { + return toolError( + `File not found: ${fullPath}. Use \`content\` to create a new file, or write it first.`, + ); + } + + let text = await ctx.readFile(fullPath); + const applied: Array<{ index: number; bytesDelta: number }> = []; + + const edits = params.edits as EditOp[]; + for (let i = 0; i < edits.length; i++) { + const { old_text, new_text } = edits[i]; + if (old_text.length === 0) { + return toolError(`Edit #${i + 1}: old_text must not be empty.`); + } + const occurrences = countOccurrences(text, old_text); + if (occurrences === 0) { + return toolError( + `Edit #${i + 1}: old_text not found in ${fullPath}. ` + + "It must match exactly (including whitespace).", + ); + } + if (occurrences > 1) { + return toolError( + `Edit #${i + 1}: old_text matches ${occurrences} times in ${fullPath}. ` + + "Provide more surrounding context so it is unique.", + ); + } + const before = text.length; + text = text.replace(old_text, new_text); + applied.push({ index: i + 1, bytesDelta: text.length - before }); + } + + await ctx.writeFile(fullPath, text); + return toolSuccess({ + success: true, + path: fullPath, + action: "edited", + edits: applied.length, + bytes: new TextEncoder().encode(text).length, + }); + } catch (error) { + const msg = + error instanceof Error ? error.message : "Failed to edit file"; + return toolError(msg); + } + }, + }); +}