Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export {
type ChatMessage,
type ChatSession,
createBashTool,
createEditFileTool,
createReadTool,
createSession,
DEFAULT_MAX_BYTES,
Expand Down
23 changes: 23 additions & 0 deletions packages/powerpoint/src/lib/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -789,6 +790,28 @@ markDirty();
This pattern applies all text changes in one slide re-import, avoiding per-shape flashing.
The \`replaceTextBody\` helper preserves \`<a:bodyPr>\` and \`<a:lstStyle>\` 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 <slide> <script.js> [--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 <N> /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.
Expand Down
8 changes: 7 additions & 1 deletion packages/powerpoint/src/lib/tools/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -15,6 +19,7 @@ export function createPptTools(ctx: AgentContext) {
return [
// fs tools
createReadTool(ctx),
createEditFileTool(ctx),
createBashTool(ctx),
// PPT read tools
screenshotSlideTool,
Expand All @@ -33,6 +38,7 @@ export function createPptTools(ctx: AgentContext) {

export {
createBashTool,
createEditFileTool,
createReadTool,
createEditSlideChartTool,
createEditSlideMasterTool,
Expand Down
150 changes: 150 additions & 0 deletions packages/powerpoint/src/lib/vfs/custom-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8Array> } },
Expand Down Expand Up @@ -783,11 +785,159 @@ const insertIconCmd: DescribedCommand = {
},
};

const editSlideXmlCmd: DescribedCommand = {
promptSnippet:
"- edit-slide-xml <slide> <script.js> [--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<string, string> = {};
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 <slide> <script.js> [--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<string> {
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,
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading