Skip to content
Merged
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ Entries before v0.5.0 were written retroactively as summaries.

## [Unreleased]

### Changed

- `run_python` / `run_style_python`: file writes now always persist — the
`save` flag is deprecated and ignored (silent data loss when omitting
`save=True` on Cloud is no longer possible). The deck's PPTX artifact
refreshes automatically whenever the deck changes; `measure_slides`
remains the trigger for the expensive verification pass (render, text
overflow measurement, previews)
- Cloud sandbox write-back is now diff-based (changed/new files only),
preventing a stale sandbox copy from overwriting newer S3 writes

## [0.5.1] - 2026-07-31

### Added
Expand All @@ -23,6 +34,10 @@ Entries before v0.5.0 were written retroactively as summaries.

### Fixed

- Cloud: superseded PPTX artifacts are now deleted after each refresh — the
automatic artifact refresh no longer accumulates orphaned objects in S3
(`update_deck` returns previous values via `UPDATED_OLD`).

- **Cloud agent output-token limit**: model profiles now set an explicit
`max_tokens` (Claude 32768, others 8192) — Bedrock's small default truncated
long single-call outputs (e.g. writing `specs/brief.md` from a long article)
Expand Down
6 changes: 3 additions & 3 deletions agent/prompts/wiring/style_remote.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ In this environment, `run_style_python` does NOT inject `read_style` /
`write_style` helpers. Use normal file I/O with these parameters instead:

Workspace layout:
- `style.html` — the target style file (read/write; saved back when `save=True`)
- `style.html` — the target style file (read/write; persisted automatically when changed)
- `ref/{name}.html` — reference styles (read-only; loaded via the `ref_styles` parameter)

Usage patterns:
- Read a reference: `run_style_python(code="html = open('ref/corporate-executive.html').read(); print(html[:500])", ref_styles=["corporate-executive"])`
- Create/edit the style: `run_style_python(code="open('style.html','w').write(html)", style_name="style-20260506-1430", save=True)`
- Create/edit the style: `run_style_python(code="open('style.html','w').write(html)", style_name="style-20260506-1430")`
- Read back for incremental edits: `run_style_python(code="print(open('style.html').read())", style_name="style-20260506-1430")`

The user's first message contains `[Style: <name>]` — pass it as the
`style_name` parameter on every call (not as a `write_style` argument).
`save=True` is what persists the style; without it your edits are discarded.
Writes to style.html persist automatically — there is no save flag.
2 changes: 1 addition & 1 deletion clients/claude-code/agents/sdpm-composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ your **assigned slide slugs**. You write ONLY those slugs. Work silently — no
interaction, no Phase 3.

Client note: you may open preview PNG files with the CC-native **Read** tool, but write
deck files only through `run_python` (never Write/Edit — `save=True` must stay the
deck files only through `run_python` (never Write/Edit — `run_python` must stay the
single writer).
4 changes: 2 additions & 2 deletions docs/en/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ S3 (resource bucket):

### Deck Workspace

Using `run_python(deck_id=..., save=True)` loads the entire deck workspace into the sandbox.
The agent can read and write files using standard Python file I/O (`open`, `json.load`, etc.), and `save=True` writes changes back to S3.
Using `run_python(deck_id=...)` loads the entire deck workspace into the sandbox.
The agent can read and write files using standard Python file I/O (`open`, `json.load`, etc.); modified files are written back to S3 automatically after every execution.

```
deck.json — deck metadata (template, fonts, defaultTextColor)
Expand Down
23 changes: 11 additions & 12 deletions personas/composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ are written for the CLI (`pptx_builder.py …`); on MCP translate every CLI comm
| `pptx_builder.py workflows <name>` | `read_workflows(["<name>"])` |
| `pptx_builder.py guides <name>` | `read_guides(["<name>"])` |
| `pptx_builder.py examples <name>` | `read_examples(["<name>"])` |
| `pptx_builder.py measure {json} -p {n}` | `run_python(..., save=True, measure_slides=["{slug}"])` |
| `pptx_builder.py measure {json} -p {n}` | `run_python(..., measure_slides=["{slug}"])` |
| `pptx_builder.py preview {json}` | covered by the same call — it returns `preview_files` |
| `pptx_builder.py image-size {path} --width {px}` | no tool — compute proportional size in `run_python` (`new_h = round(orig_h * target_w / orig_w)`) |
| `pptx_builder.py code-block …` | `code_to_slide(...)` |
Expand All @@ -82,24 +82,23 @@ data = {
write_json("slides/{slug}.json", data)
''',
deck_id="<absolute deck path>",
save=True,
measure_slides=["{slug}"],
)
```

`save=True` triggers `lint_and_sanitize` (it rewrites `slides/{slug}.json`), the PPTX
build, PNG render, and returns `preview_files` (PNG paths), `warnings`, and
`lint_diagnostics` — filtered to the slugs you measured. Do not write deck files through
any other mechanism (no client-native Write/Edit) — `save=True` must stay the single
writer. Inside the sandbox use `read_json` / `read_text` / `list_files`; `open()` is
blocked.
Writes always persist — there is no save flag. `measure_slides` triggers
`lint_and_sanitize` (it rewrites `slides/{slug}.json`), the PPTX build, PNG render, and
returns `preview_files` (PNG paths), `warnings`, and `lint_diagnostics` — filtered to
the slugs you measured. Do not write deck files through any other mechanism (no
client-native Write/Edit) — `run_python` must stay the single writer. Inside the
sandbox use `read_json` / `read_text` / `list_files`; `open()` is blocked.

### Per-slide save loop (MANDATORY)
### Per-slide write loop (MANDATORY)

Write and save **one slide at a time** — never batch-write multiple `slides/*.json` in a
Write **one slide at a time** — never batch-write multiple `slides/*.json` in a
single call (risks output truncation). Per slug:

**write → `run_python(save=True, measure_slides=["{slug}"])` → inspect returned
**write → `run_python(measure_slides=["{slug}"])` → inspect returned
`preview_files` + `warnings` → fix if needed → next slug.**

## Working Philosophy
Expand Down Expand Up @@ -159,7 +158,7 @@ own **every** slide in the deck for this call. Read all `slides/*.json` directly
overlap, alignment on a single slide) are OUT OF SCOPE — do not touch them, even if you
notice them; a separate per-slide fix pass handles those.

Fix via `run_python(save=True, measure_slides=[...])`. If the deck is already
Fix via `run_python(measure_slides=[...])`. If the deck is already
consistent, respond with a brief summary and return — over-editing causes new
inconsistencies.

Expand Down
2 changes: 1 addition & 1 deletion personas/single.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Respond in the same language as the user.
spec-driven-presentation-maker: AI-powered PowerPoint generation from JSON.

## Architecture
- The agent edits workspace files via `run_python(deck_id=..., save=True)` using normal file I/O
- The agent edits workspace files via `run_python(deck_id=...)` using normal file I/O (writes always persist)
- MCP tools handle: workflow guidance, initialization, PPTX generation, preview, references
- MCP tools do NOT handle: slide editing, spec writing (agent responsibility via run_python)

Expand Down
2 changes: 1 addition & 1 deletion personas/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ Each group runs as an independent composer in parallel. Groups cannot share info
inconsistencies (labeling, decorative elements, typography, writing style, hierarchy).
2. **Verification**: view the post-review renders yourself. If you composed
sequentially, use the `preview_files` returned from your
`run_python(save=True)` calls. If you dispatched composers, their tool
`run_python(measure_slides=[...])` calls. If you dispatched composers, their tool
results are not visible to you — view the previews another way (this is
the one exception to "do not call preview tools directly"):
- a `get_preview` tool exists → call `get_preview(deck_id, slugs=[...all slugs...])`
Expand Down
6 changes: 3 additions & 3 deletions personas/vibe.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ First load references (read_workflows(["create-new-2-compose","slide-json-spec"]
read_guides(["grid"]), read_examples(["components/all","patterns"])), then read
specs/brief.md, specs/outline.md, specs/art-direction.html for context.
Compose ONLY your assigned slugs, one at a time, via run_python's write_json, and use
the preview_files (PNG) returned by run_python(save=True, measure_slides=[slug]) as the
the preview_files (PNG) returned by run_python(measure_slides=[slug]) as the
source of truth. Do NOT touch other slides, deck.json, or specs/. art-direction is
FROZEN. Do NOT advance to Phase 3. Return a summary plus any warnings.
```
Expand Down Expand Up @@ -163,8 +163,8 @@ Each group runs as an independent composer in parallel. Groups cannot share info
1. **Consistency review pass**: dispatch a single composer with ALL slugs in the deck
and the instruction: "Consistency review."
2. **Verification**: view the post-review renders yourself. If you composed
sequentially, use the `preview_files` returned from your `run_python(save=True)`
calls. If you dispatched composers, their tool results are not visible to you —
sequentially, use the `preview_files` returned from your
`run_python(measure_slides=[...])` calls. If you dispatched composers, their tool results are not visible to you —
view the previews another way (the one exception to "do not call preview tools
directly"): call `get_preview(deck_id, slugs=[...all slugs...])` if that tool
exists, otherwise read the PNG files at `<deck>/preview/<slug>.png` with your
Expand Down
43 changes: 21 additions & 22 deletions sdpm/references/guides/import-pptx.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,9 @@ Populate `specs/brief.md` and `specs/outline.md` **before** Step 4
builds the deck. `specs/art-direction.html` is intentionally deferred
to Step 5 — the rendered slide previews from Step 4 are a far better
input for it than the upload-time image extraction. Each sub-step
uses `run_python(save=True)` so the intermediate state is persisted —
Cloud discards the sandbox VM between calls, so `save=False` would
lose the write.
uses `run_python` — writes always persist automatically (no save
flag), so intermediate state survives even though Cloud discards the
sandbox VM between calls.

You generate these specs from the PPTX content you imported in Step 2.
Do not call `hearing` in Step 3 — if a particular field is thin, leave
Expand All @@ -123,10 +123,10 @@ for name in sorted(files):
print(name, "::", title)
```

Run that via `run_python(code=<above>, deck_id=deck_id, save=False)`
Run that via `run_python(code=<above>, deck_id=deck_id)`
(Cloud: prepend `purpose="Inspect PPTX slides"`).

Then write `specs/brief.md` in a second call with `save=True`:
Then write `specs/brief.md` in a second call:

```python
short_id = "<result['shortId']>"
Expand All @@ -146,7 +146,7 @@ write_text("specs/brief.md", "\n".join(lines) + "\n")
print("brief.md written")
```

Call as `run_python(code=<above>, deck_id=deck_id, save=True)`
Call as `run_python(code=<above>, deck_id=deck_id)`
(Cloud: prepend `purpose="Write brief.md from PPTX content"`).

### 3-2. outline.md (LLM summarization)
Expand All @@ -167,7 +167,7 @@ write_text("specs/outline.md", "\n".join(lines) + "\n")
print("outline.md written:", len(pairs))
```

Call with `run_python(code=<above>, deck_id=deck_id, save=True)`
Call with `run_python(code=<above>, deck_id=deck_id)`
(Cloud: add `purpose="Write outline.md from PPTX content"`).

Requirements (outline lint will otherwise reject the write on Cloud):
Expand All @@ -183,12 +183,12 @@ Requirements (outline lint will otherwise reject the write on Cloud):

Copy the PPTX-derived slide JSON into `slides/`, merge deck metadata
into `deck.json` (using the deck-local `template.pptx`), and build the
deck in a **single** `run_python` call with `save=True`.
deck in a **single** `run_python` call with `measure_slides`.

**Do not split Step 4 into multiple calls.** Each Cloud `run_python`
runs in a fresh sandbox VM that is discarded afterward, so intermediate
`save=False` writes are lost. Keeping Step 4 in one call ensures the
copy, S3 writeback, build, preview, and compose all share a single VM.
runs in a fresh sandbox VM that is discarded afterward. Keeping Step 4
in one call ensures the copy, S3 writeback, build, preview, and compose
all share a single VM.

Assemble the slug list from Step 3-2 as a Python literal:

Expand Down Expand Up @@ -261,24 +261,23 @@ Call as:
run_python(
code=<above>,
deck_id=deck_id,
save=True,
measure_slides=slugs,
)
```

Cloud: prepend `purpose="Import PPTX slides into deck and build"`.

Because `specs/outline.md` was populated in Step 3-2, `save=True`
triggers a full build that includes every slide, followed by preview
and SVG compose. The PPTX-derived placeholder template means **layout
Because `specs/outline.md` was populated in Step 3-2, the build
includes every slide, followed by preview and SVG compose (triggered
by `measure_slides`). The PPTX-derived placeholder template means **layout
mismatch is impossible** — the build should succeed in one shot.

After the `run_python` call returns successfully, call
`generate_pptx(deck_id=deck_id)` once. This persists `output.pptx`
to the deck workspace and updates the deck record's `pptxS3Key`, so
the Web UI can offer a "Download PPTX" button immediately. Without
this call the UI sees no PPTX yet and hides the download action,
even though the slides have rendered.
The deck's PPTX artifact and the deck record's `pptxS3Key` refresh
automatically when the deck changes, so the Web UI's "Download PPTX"
action works without further steps. Call `generate_pptx(deck_id=deck_id)`
only as the final handoff — it additionally produces the WebP preview
set, updates the final PPTX artifact, and returns a full-deck warnings
report.

---

Expand Down Expand Up @@ -510,7 +509,7 @@ lists, charts, or specific data into the demonstration slides — that
content lives in `slides/` (placed by Step 4), not in the style
specification.

Write incrementally via `run_python(save=True)` — one call for the
Write incrementally via `run_python` — one call for the
skeleton + `:root` + first slide, then one or two more for the
remaining slides (per the create-style workflow's incremental writing
guidance):
Expand Down
70 changes: 55 additions & 15 deletions servers/local/sandbox_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,28 @@ def _rejection_message(violations: list[str], has_deck: bool) -> str:
return "\n".join(lines)


def _build_snapshot(deck_dir: Path) -> dict[str, tuple[int, int]]:
"""Snapshot (mtime_ns, size) of files that affect the built PPTX.

Used to detect whether a run_python execution changed the deck, so the
output.pptx artifact can be rebuilt automatically. specs/outline.md is
included because slide order comes from the outline.
"""
snap: dict[str, tuple[int, int]] = {}
for rel in ("deck.json", "presentation.json", "specs/outline.md"):
p = deck_dir / rel
if p.is_file():
st = p.stat()
snap[rel] = (st.st_mtime_ns, st.st_size)
for sub in ("slides", "includes"):
d = deck_dir / sub
if d.is_dir():
for p in d.glob("*.json"):
st = p.stat()
snap[f"{sub}/{p.name}"] = (st.st_mtime_ns, st.st_size)
return snap


def run_python(purpose: str, code: str, deck_id: str = "", save: bool = False,
measure_slides: list[str] | None = None) -> str:
"""Execute Python code in a sandboxed environment.
Expand Down Expand Up @@ -97,18 +119,33 @@ def run_python(purpose: str, code: str, deck_id: str = "", save: bool = False,

**Always specify measure_slides when editing slides.**

## Persistence & build (no flags needed)

- File writes always persist — anything written via write_json/write_text
is saved immediately. There is no "unsaved" state.
- output.pptx rebuilds automatically whenever the deck changed
(deck.json / slides/ / includes/ / specs/outline.md).
- measure_slides triggers the expensive verification pass (render + text
overflow measurement + preview PNGs) for the given slugs only.

Args:
purpose: Brief user-facing description of what this code does. Shown in UI.
code: Python code to execute (no import statements allowed).
deck_id: Deck output_dir path. Optional.
save: When True, triggers PPTX build + preview + SVG compose after execution.
save: Deprecated and ignored. Writes always persist and output.pptx
rebuilds automatically when the deck changed.
measure_slides: Slide slugs to measure after execution (e.g. ["title", "feature-a"]).

Returns:
JSON: {"output", "measure"?, "pptx"?, "preview"?, "compose"?}
"""
result: dict[str, Any] = {}
cwd = deck_id if deck_id and Path(deck_id).is_dir() else None
if save:
result["deprecated"] = (
"'save' is ignored: writes always persist and output.pptx "
"rebuilds automatically when the deck changed."
)

from sandbox import check_code, make_runner

Expand All @@ -117,6 +154,8 @@ def run_python(purpose: str, code: str, deck_id: str = "", save: bool = False,
result["output"] = _rejection_message(violations, has_deck=bool(cwd))
return json.dumps(result, ensure_ascii=False)

pre_snap = _build_snapshot(Path(cwd)) if cwd else {}

try:
runner = make_runner(deck_id if cwd else "")
args = [sys.executable, "-c", runner]
Expand Down Expand Up @@ -166,18 +205,25 @@ def run_python(purpose: str, code: str, deck_id: str = "", save: bool = False,
for d in diags:
d["slug"] = slug
lint_diagnostics.extend(diags)
slide_file.write_text(
json.dumps(cleaned, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
# Rewrite only when sanitization changed the content —
# unconditional rewrites would bump mtime every call and
# falsely mark the deck as changed (auto-rebuild churn).
if cleaned != slide_data:
slide_file.write_text(
json.dumps(cleaned, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
except (json.JSONDecodeError, TypeError):
pass
if lint_diagnostics:
errs = result.setdefault("errors", {})
errs["lintDiagnostics"] = lint_diagnostics

# Post-processing: build PPTX + iso measure/compose/preview (lockless)
if save:
# Post-processing: build PPTX + iso measure/compose/preview (lockless).
# output.pptx is a derived artifact — it follows deck changes automatically.
# measure_slides additionally triggers the expensive verification pass.
deck_changed = _build_snapshot(deck_dir) != pre_snap
if deck_changed or measure_slides:
import shutil

from sdpm.api import generate, parse_outline_slugs
Expand Down Expand Up @@ -417,19 +463,13 @@ def _fp(c: dict) -> str:
shutil.rmtree(iso_dir, ignore_errors=True)

else:
# save=True without measure_slides: output.pptx only, skip compose/measure/preview
# Deck changed without measure_slides: output.pptx only,
# skip compose/measure/preview
if _build_warnings:
result["warnings"] = _build_warnings
if _build_lint:
result["lint_diagnostics"] = _build_lint

elif measure_slides:
try:
from sdpm.api import measure as _sdpm_measure
result["measure"] = _sdpm_measure(json_path=deck_input, slides=list(measure_slides))
except Exception as e:
result["measure"] = f"Measure error: {e}"

return json.dumps(result, ensure_ascii=False)


Expand Down
Loading
Loading